diff --git a/CLAUDE.md b/CLAUDE.md index ebed3e69a6..a9050ad089 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,6 +156,17 @@ Fluent builder pattern: `.child()`, `.class()`, `.css()` chaining with position- - **Barrel import caution** — `import { x } from "@triliumnext/core"` loads ALL core exports. Early-loading modules like `config.ts` should import specific subpaths (e.g. `@triliumnext/core/src/services/utils/index`) to avoid circular dependencies or initialization ordering issues - **Electron IPC** — In desktop mode, client API calls use Electron IPC (not HTTP). The IPC handler in `apps/server/src/routes/electron.ts` must be registered via `utils.isElectron` from the **server's** utils (which correctly checks `process.versions["electron"]`), not from core's utils +### Binary Utilities + +Use utilities from `packages/trilium-core/src/services/utils/binary.ts` for string/buffer conversions instead of manual `TextEncoder`/`TextDecoder` or `Buffer.from()` calls: + +- **`wrapStringOrBuffer(input)`** — Converts `string` to `Uint8Array`, returns `Uint8Array` unchanged. Use when a function expects `Uint8Array` but receives `string | Uint8Array`. +- **`unwrapStringOrBuffer(input)`** — Converts `Uint8Array` to `string`, returns `string` unchanged. Use when a function expects `string` but receives `string | Uint8Array`. +- **`encodeBase64(input)`** / **`decodeBase64(input)`** — Base64 encoding/decoding that works in both Node.js and browser. +- **`encodeUtf8(string)`** / **`decodeUtf8(buffer)`** — UTF-8 encoding/decoding. + +Import via `import { binary_utils } from "@triliumnext/core"` or directly from the module. + ### Database SQLite via `better-sqlite3`. SQL abstraction in `packages/trilium-core/src/services/sql/` with `DatabaseProvider` interface, prepared statement caching, and transaction support. diff --git a/apps/build-docs/package.json b/apps/build-docs/package.json index c173c96f19..cbfebddae5 100644 --- a/apps/build-docs/package.json +++ b/apps/build-docs/package.json @@ -20,7 +20,7 @@ "@triliumnext/server": "workspace:*" }, "devDependencies": { - "@redocly/cli": "2.25.4", + "@redocly/cli": "2.26.0", "archiver": "7.0.1", "fs-extra": "11.3.4", "js-yaml": "4.1.1", diff --git a/apps/client-standalone/package.json b/apps/client-standalone/package.json index 216bb524fd..420b7a99fb 100644 --- a/apps/client-standalone/package.json +++ b/apps/client-standalone/package.json @@ -32,7 +32,7 @@ "@triliumnext/highlightjs": "workspace:*", "@triliumnext/share-theme": "workspace:*", "@triliumnext/split.js": "workspace:*", - "@zumer/snapdom": "2.7.0", + "@zumer/snapdom": "2.8.0", "autocomplete.js": "0.38.1", "bootstrap": "5.3.8", "boxicons": "2.1.4", @@ -43,14 +43,16 @@ "fflate": "0.8.2", "force-graph": "1.51.2", "globals": "17.4.0", - "i18next": "26.0.3", + "i18next": "26.0.4", "i18next-http-backend": "3.0.4", + "aes-js": "3.1.2", "jquery": "4.0.0", "jquery.fancytree": "2.38.5", "js-md5": "0.8.3", "js-sha1": "0.7.0", "js-sha256": "0.11.1", "js-sha512": "0.9.0", + "scrypt-js": "3.0.1", "jsplumb": "2.15.6", "katex": "0.16.45", "knockout": "3.5.1", @@ -71,6 +73,7 @@ "vanilla-js-wheel-zoom": "9.0.4" }, "devDependencies": { + "@types/aes-js": "3.1.4", "@ckeditor/ckeditor5-inspector": "5.0.0", "@preact/preset-vite": "2.10.2", "@types/bootstrap": "5.2.10", diff --git a/apps/client-standalone/src/lightweight/backup_provider.ts b/apps/client-standalone/src/lightweight/backup_provider.ts new file mode 100644 index 0000000000..7677e88325 --- /dev/null +++ b/apps/client-standalone/src/lightweight/backup_provider.ts @@ -0,0 +1,156 @@ +import type { DatabaseBackup } from "@triliumnext/commons"; +import { BackupOptionsService, BackupService, getSql } from "@triliumnext/core"; + +const BACKUP_DIR_NAME = "backups"; +const BACKUP_FILE_PATTERN = /^backup-.*\.db$/; + +/** + * Standalone backup service using OPFS (Origin Private File System). + * Stores database backups as serialized byte arrays in OPFS. + * Falls back to no-op behavior when OPFS is not available (e.g., in tests). + */ +export default class StandaloneBackupService extends BackupService { + private backupDir: FileSystemDirectoryHandle | null = null; + private opfsAvailable: boolean | null = null; + + constructor(options: BackupOptionsService) { + super(options); + } + + private isOpfsAvailable(): boolean { + if (this.opfsAvailable === null) { + this.opfsAvailable = typeof navigator !== "undefined" + && navigator.storage + && typeof navigator.storage.getDirectory === "function"; + } + return this.opfsAvailable; + } + + private async ensureBackupDirectory(): Promise { + if (!this.isOpfsAvailable()) { + return null; + } + + if (!this.backupDir) { + const root = await navigator.storage.getDirectory(); + this.backupDir = await root.getDirectoryHandle(BACKUP_DIR_NAME, { create: true }); + } + return this.backupDir; + } + + override async backupNow(name: string): Promise { + const fileName = `backup-${name}.db`; + + // Check if OPFS is available + if (!this.isOpfsAvailable()) { + console.warn(`[Backup] OPFS not available, skipping backup: ${fileName}`); + return `/${BACKUP_DIR_NAME}/${fileName}`; + } + + try { + const dir = await this.ensureBackupDirectory(); + if (!dir) { + console.warn(`[Backup] Backup directory not available, skipping: ${fileName}`); + return `/${BACKUP_DIR_NAME}/${fileName}`; + } + + // Serialize the database + const data = getSql().serialize(); + + // Write to OPFS + const fileHandle = await dir.getFileHandle(fileName, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(data); + await writable.close(); + + console.log(`[Backup] Created backup: ${fileName} (${data.byteLength} bytes)`); + return `/${BACKUP_DIR_NAME}/${fileName}`; + } catch (error) { + console.error(`[Backup] Failed to create backup ${fileName}:`, error); + // Don't throw - backup failure shouldn't block operations + return `/${BACKUP_DIR_NAME}/${fileName}`; + } + } + + override async getExistingBackups(): Promise { + if (!this.isOpfsAvailable()) { + return []; + } + + try { + const dir = await this.ensureBackupDirectory(); + if (!dir) { + return []; + } + + const backups: DatabaseBackup[] = []; + + for await (const [name, handle] of dir.entries()) { + if (handle.kind !== "file" || !BACKUP_FILE_PATTERN.test(name)) { + continue; + } + + const file = await (handle as FileSystemFileHandle).getFile(); + backups.push({ + fileName: name, + filePath: `/${BACKUP_DIR_NAME}/${name}`, + mtime: new Date(file.lastModified) + }); + } + + // Sort by modification time, newest first + backups.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + return backups; + } catch (error) { + console.error("[Backup] Failed to list backups:", error); + return []; + } + } + + /** + * Delete a backup by filename. + */ + async deleteBackup(fileName: string): Promise { + if (!this.isOpfsAvailable()) { + return; + } + + try { + const dir = await this.ensureBackupDirectory(); + if (!dir) { + return; + } + await dir.removeEntry(fileName); + console.log(`[Backup] Deleted backup: ${fileName}`); + } catch (error) { + console.error(`[Backup] Failed to delete backup ${fileName}:`, error); + } + } + + override async getBackupContent(filePath: string): Promise { + if (!this.isOpfsAvailable()) { + return null; + } + + try { + const dir = await this.ensureBackupDirectory(); + if (!dir) { + return null; + } + + // Extract fileName from filePath (e.g., "/backups/backup-now.db" -> "backup-now.db") + const fileName = filePath.split("/").pop(); + if (!fileName || !BACKUP_FILE_PATTERN.test(fileName)) { + return null; + } + + const fileHandle = await dir.getFileHandle(fileName); + const file = await fileHandle.getFile(); + const data = await file.arrayBuffer(); + return new Uint8Array(data); + } catch (error) { + console.error(`[Backup] Failed to get backup content ${filePath}:`, error); + return null; + } + } +} diff --git a/apps/client-standalone/src/lightweight/crypto_provider.ts b/apps/client-standalone/src/lightweight/crypto_provider.ts index 0d1a6d8066..128031bba2 100644 --- a/apps/client-standalone/src/lightweight/crypto_provider.ts +++ b/apps/client-standalone/src/lightweight/crypto_provider.ts @@ -1,24 +1,22 @@ -import type { CryptoProvider } from "@triliumnext/core"; +import type { Cipher, CryptoProvider, ScryptOptions } from "@triliumnext/core"; +import { binary_utils } from "@triliumnext/core"; import { sha1 } from "js-sha1"; import { sha256 } from "js-sha256"; import { sha512 } from "js-sha512"; import { md5 } from "js-md5"; - -interface Cipher { - update(data: Uint8Array): Uint8Array; - final(): Uint8Array; -} +import { scrypt } from "scrypt-js"; +import aesjs from "aes-js"; const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; /** - * Crypto provider for browser environments using the Web Crypto API. + * Crypto provider for browser environments using pure JavaScript crypto libraries. + * Uses aes-js for synchronous AES encryption (matching Node.js behavior). */ export default class BrowserCryptoProvider implements CryptoProvider { createHash(algorithm: "md5" | "sha1" | "sha512", content: string | Uint8Array): Uint8Array { - const data = typeof content === "string" ? content : - new TextDecoder().decode(content); + const data = binary_utils.unwrapStringOrBuffer(content); let hexHash: string; if (algorithm === "md5") { @@ -38,13 +36,11 @@ export default class BrowserCryptoProvider implements CryptoProvider { } createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher { - // Web Crypto API doesn't support streaming cipher like Node.js - // We need to implement a wrapper that collects data and encrypts on final() - return new WebCryptoCipher(algorithm, key, iv, "encrypt"); + return new AesJsCipher(algorithm, key, iv, "encrypt"); } createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher { - return new WebCryptoCipher(algorithm, key, iv, "decrypt"); + return new AesJsCipher(algorithm, key, iv, "decrypt"); } randomBytes(size: number): Uint8Array { @@ -63,8 +59,8 @@ export default class BrowserCryptoProvider implements CryptoProvider { } hmac(secret: string | Uint8Array, value: string | Uint8Array): string { - const secretStr = typeof secret === "string" ? secret : new TextDecoder().decode(secret); - const valueStr = typeof value === "string" ? value : new TextDecoder().decode(value); + const secretStr = binary_utils.unwrapStringOrBuffer(secret); + const valueStr = binary_utils.unwrapStringOrBuffer(value); // sha256.hmac returns hex, convert to base64 to match Node's behavior const hexHash = sha256.hmac(secretStr, valueStr); const bytes = new Uint8Array(hexHash.length / 2); @@ -73,28 +69,50 @@ export default class BrowserCryptoProvider implements CryptoProvider { } return btoa(String.fromCharCode(...bytes)); } + + async scrypt( + password: Uint8Array | string, + salt: Uint8Array | string, + keyLength: number, + options: ScryptOptions = {} + ): Promise { + const { N = 16384, r = 8, p = 1 } = options; + const passwordBytes = binary_utils.wrapStringOrBuffer(password); + const saltBytes = binary_utils.wrapStringOrBuffer(salt); + + return scrypt(passwordBytes, saltBytes, N, r, p, keyLength); + } + + constantTimeCompare(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) { + return false; + } + + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a[i] ^ b[i]; + } + return result === 0; + } } /** - * A cipher implementation that wraps Web Crypto API. - * Note: This buffers all data until final() is called, which differs from - * Node.js's streaming cipher behavior. + * A synchronous cipher implementation using aes-js. + * Matches Node.js crypto behavior with update() and final() methods. */ -class WebCryptoCipher implements Cipher { +class AesJsCipher implements Cipher { private chunks: Uint8Array[] = []; - private algorithm: string; private key: Uint8Array; private iv: Uint8Array; private mode: "encrypt" | "decrypt"; private finalized = false; constructor( - algorithm: "aes-128-cbc", + _algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array, mode: "encrypt" | "decrypt" ) { - this.algorithm = algorithm; this.key = key; this.iv = iv; this.mode = mode; @@ -104,9 +122,9 @@ class WebCryptoCipher implements Cipher { if (this.finalized) { throw new Error("Cipher has already been finalized"); } - // Buffer the data - Web Crypto doesn't support streaming + // Buffer the data - we process everything in final() to match streaming behavior this.chunks.push(data); - // Return empty array since we process everything in final() + // Return empty array since aes-js CBC doesn't support true streaming return new Uint8Array(0); } @@ -116,24 +134,6 @@ class WebCryptoCipher implements Cipher { } this.finalized = true; - // Web Crypto API is async, but we need sync behavior - // This is a fundamental limitation that requires architectural changes - // For now, throw an error directing users to use async methods - throw new Error( - "Synchronous cipher finalization not available in browser. " + - "The Web Crypto API is async-only. Use finalizeAsync() instead." - ); - } - - /** - * Async version that actually performs the encryption/decryption. - */ - async finalizeAsync(): Promise { - if (this.finalized) { - throw new Error("Cipher has already been finalized"); - } - this.finalized = true; - // Concatenate all chunks const totalLength = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0); const data = new Uint8Array(totalLength); @@ -143,24 +143,33 @@ class WebCryptoCipher implements Cipher { offset += chunk.length; } - // Copy key and iv to ensure they're plain ArrayBuffer-backed - const keyBuffer = new Uint8Array(this.key); - const ivBuffer = new Uint8Array(this.iv); + if (this.mode === "encrypt") { + // PKCS7 padding for encryption + const blockSize = 16; + const paddingLength = blockSize - (data.length % blockSize); + const paddedData = new Uint8Array(data.length + paddingLength); + paddedData.set(data); + paddedData.fill(paddingLength, data.length); - // Import the key - const cryptoKey = await crypto.subtle.importKey( - "raw", - keyBuffer, - { name: "AES-CBC" }, - false, - [this.mode] - ); + const aesCbc = new aesjs.ModeOfOperation.cbc( + Array.from(this.key), + Array.from(this.iv) + ); + return new Uint8Array(aesCbc.encrypt(paddedData)); + } else { + // Decryption + const aesCbc = new aesjs.ModeOfOperation.cbc( + Array.from(this.key), + Array.from(this.iv) + ); + const decrypted = new Uint8Array(aesCbc.decrypt(data)); - // Perform encryption/decryption - const result = this.mode === "encrypt" - ? await crypto.subtle.encrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data) - : await crypto.subtle.decrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data); - - return new Uint8Array(result); + // Remove PKCS7 padding + const paddingLength = decrypted[decrypted.length - 1]; + if (paddingLength > 0 && paddingLength <= 16) { + return decrypted.slice(0, decrypted.length - paddingLength); + } + return decrypted; + } } } diff --git a/apps/client-standalone/src/lightweight/log_provider.ts b/apps/client-standalone/src/lightweight/log_provider.ts new file mode 100644 index 0000000000..7eddc54c33 --- /dev/null +++ b/apps/client-standalone/src/lightweight/log_provider.ts @@ -0,0 +1,168 @@ +import { FileBasedLogService, type LogFileInfo } from "@triliumnext/core"; + +const LOG_DIR_NAME = "logs"; +const LOG_FILE_PATTERN = /^trilium-\d{4}-\d{2}-\d{2}\.log$/; +const DEFAULT_RETENTION_DAYS = 7; + +/** + * Standalone log service using OPFS (Origin Private File System). + * Uses synchronous access handles available in service worker context. + */ +export default class StandaloneLogService extends FileBasedLogService { + private logDir: FileSystemDirectoryHandle | null = null; + private currentFile: FileSystemSyncAccessHandle | null = null; + private currentFileName: string = ""; + private textEncoder = new TextEncoder(); + private textDecoder = new TextDecoder(); + + constructor() { + super(); + } + + // ==================== Abstract Method Implementations ==================== + + protected override get eol(): string { + return "\n"; + } + + protected override async ensureLogDirectory(): Promise { + const root = await navigator.storage.getDirectory(); + this.logDir = await root.getDirectoryHandle(LOG_DIR_NAME, { create: true }); + } + + protected override async openLogFile(fileName: string): Promise { + if (!this.logDir) { + await this.ensureLogDirectory(); + } + + // Close existing file if open + if (this.currentFile) { + this.currentFile.close(); + this.currentFile = null; + } + + const fileHandle = await this.logDir!.getFileHandle(fileName, { create: true }); + + // Try to create sync access handle with retry logic for worker restarts + // Previous worker may have left handle open before being terminated + const maxRetries = 3; + const retryDelay = 100; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + this.currentFile = await fileHandle.createSyncAccessHandle(); + break; + } catch (error) { + if (attempt === maxRetries - 1) { + // Last attempt failed - fall back to console-only logging + console.warn("[LogService] Could not open log file, using console-only logging:", error); + this.currentFile = null; + this.currentFileName = ""; + return; + } + // Wait before retrying - previous handle may be released + await new Promise(resolve => setTimeout(resolve, retryDelay * (attempt + 1))); + } + } + + this.currentFileName = fileName; + + // Seek to end for appending + if (this.currentFile) { + const size = this.currentFile.getSize(); + this.currentFile.truncate(size); // No-op, but ensures we're at the right position + } + } + + protected override closeLogFile(): void { + if (this.currentFile) { + this.currentFile.close(); + this.currentFile = null; + this.currentFileName = ""; + } + } + + protected override writeEntry(entry: string): void { + if (!this.currentFile) { + console.log(entry); // Fallback to console if file not ready + return; + } + + const data = this.textEncoder.encode(entry); + const currentSize = this.currentFile.getSize(); + this.currentFile.write(data, { at: currentSize }); + this.currentFile.flush(); + } + + protected override readLogFile(fileName: string): string | null { + if (!this.logDir) { + return null; + } + + try { + // For the current file, we need to read from the sync handle + if (fileName === this.currentFileName && this.currentFile) { + const size = this.currentFile.getSize(); + const buffer = new ArrayBuffer(size); + const view = new DataView(buffer); + this.currentFile.read(view, { at: 0 }); + return this.textDecoder.decode(buffer); + } + + // For other files, we'd need async access - return null for now + // The current file is what's most commonly needed + return null; + } catch { + return null; + } + } + + protected override async listLogFiles(): Promise { + if (!this.logDir) { + return []; + } + + const logFiles: LogFileInfo[] = []; + + for await (const [name, handle] of this.logDir.entries()) { + if (handle.kind !== "file" || !LOG_FILE_PATTERN.test(name)) { + continue; + } + + // OPFS doesn't provide mtime directly, so we parse from filename + const match = name.match(/trilium-(\d{4})-(\d{2})-(\d{2})\.log/); + if (match) { + const mtime = new Date( + parseInt(match[1]), + parseInt(match[2]) - 1, + parseInt(match[3]) + ); + logFiles.push({ name, mtime }); + } + } + + return logFiles; + } + + protected override async deleteLogFile(fileName: string): Promise { + if (!this.logDir) { + return; + } + + // Don't delete the current file + if (fileName === this.currentFileName) { + return; + } + + try { + await this.logDir.removeEntry(fileName); + } catch { + // File might not exist or be locked + } + } + + protected override getRetentionDays(): number { + // Standalone doesn't have config system, use default + return DEFAULT_RETENTION_DAYS; + } +} diff --git a/apps/client-standalone/src/local-server-worker.ts b/apps/client-standalone/src/local-server-worker.ts index 7e6253a641..283889d7be 100644 --- a/apps/client-standalone/src/local-server-worker.ts +++ b/apps/client-standalone/src/local-server-worker.ts @@ -58,6 +58,8 @@ let BrowserCryptoProvider: typeof import('./lightweight/crypto_provider').defaul let BrowserZipProvider: typeof import('./lightweight/zip_provider').default; let FetchRequestProvider: typeof import('./lightweight/request_provider').default; let StandalonePlatformProvider: typeof import('./lightweight/platform_provider').default; +let StandaloneLogService: typeof import('./lightweight/log_provider').default; +let StandaloneBackupService: typeof import('./lightweight/backup_provider').default; let translationProvider: typeof import('./lightweight/translation_provider').default; let createConfiguredRouter: typeof import('./lightweight/browser_routes').createConfiguredRouter; @@ -86,6 +88,8 @@ async function loadModules(): Promise { zipModule, requestModule, platformModule, + logModule, + backupModule, translationModule, routesModule ] = await Promise.all([ @@ -96,6 +100,8 @@ async function loadModules(): Promise { import('./lightweight/zip_provider.js'), import('./lightweight/request_provider.js'), import('./lightweight/platform_provider.js'), + import('./lightweight/log_provider.js'), + import('./lightweight/backup_provider.js'), import('./lightweight/translation_provider.js'), import('./lightweight/browser_routes.js') ]); @@ -107,6 +113,8 @@ async function loadModules(): Promise { BrowserZipProvider = zipModule.default; FetchRequestProvider = requestModule.default; StandalonePlatformProvider = platformModule.default; + StandaloneLogService = logModule.default; + StandaloneBackupService = backupModule.default; translationProvider = translationModule.default; createConfiguredRouter = routesModule.createConfiguredRouter; @@ -153,6 +161,12 @@ async function initialize(): Promise { console.log("[Worker] Loading @triliumnext/core..."); const schemaModule = await import("@triliumnext/core/src/assets/schema.sql?raw"); coreModule = await import("@triliumnext/core"); + + // Initialize log service with OPFS persistence + const logService = new StandaloneLogService(); + await logService.initialize(); + console.log("[Worker] Log service initialized with OPFS"); + await coreModule.initializeCore({ executionContext: new BrowserExecutionContext(), crypto: new BrowserCryptoProvider(), @@ -161,6 +175,8 @@ async function initialize(): Promise { messaging: messagingProvider!, request: new FetchRequestProvider(), platform: new StandalonePlatformProvider(queryString), + log: logService, + backup: new StandaloneBackupService(coreModule!.options), translations: translationProvider, schema: schemaModule.default, getDemoArchive: async () => { @@ -168,6 +184,7 @@ async function initialize(): Promise { if (!response.ok) return null; return new Uint8Array(await response.arrayBuffer()); }, + image: (await import("./services/image_provider.js")).standaloneImageProvider, dbConfig: { provider: sqlProvider!, isReadOnly: false, diff --git a/apps/client-standalone/src/services/data_encryption.spec.ts b/apps/client-standalone/src/services/data_encryption.spec.ts new file mode 100644 index 0000000000..c4b9e07d96 --- /dev/null +++ b/apps/client-standalone/src/services/data_encryption.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { data_encryption } from "@triliumnext/core"; + +// Note: BrowserCryptoProvider is already initialized via test_setup.ts + +describe("data_encryption with BrowserCryptoProvider", () => { + it("should encrypt and decrypt ASCII text correctly", () => { + const key = new Uint8Array(16).fill(42); + const plainText = "Hello, World!"; + + const encrypted = data_encryption.encrypt(key, plainText); + expect(typeof encrypted).toBe("string"); + expect(encrypted.length).toBeGreaterThan(0); + + const decrypted = data_encryption.decryptString(key, encrypted); + expect(decrypted).toBe(plainText); + }); + + it("should encrypt and decrypt UTF-8 text correctly", () => { + const key = new Uint8Array(16).fill(42); + const plainText = "Привет мир! 你好世界! 🎉"; + + const encrypted = data_encryption.encrypt(key, plainText); + const decrypted = data_encryption.decryptString(key, encrypted); + expect(decrypted).toBe(plainText); + }); + + it("should encrypt and decrypt empty string", () => { + const key = new Uint8Array(16).fill(42); + const plainText = ""; + + const encrypted = data_encryption.encrypt(key, plainText); + const decrypted = data_encryption.decryptString(key, encrypted); + expect(decrypted).toBe(plainText); + }); + + it("should encrypt and decrypt binary data", () => { + const key = new Uint8Array(16).fill(42); + const plainData = new Uint8Array([0, 1, 2, 255, 128, 64]); + + const encrypted = data_encryption.encrypt(key, plainData); + const decrypted = data_encryption.decrypt(key, encrypted); + expect(decrypted).toBeInstanceOf(Uint8Array); + expect(Array.from(decrypted as Uint8Array)).toEqual(Array.from(plainData)); + }); + + it("should fail decryption with wrong key", () => { + const key1 = new Uint8Array(16).fill(42); + const key2 = new Uint8Array(16).fill(43); + const plainText = "Secret message"; + + const encrypted = data_encryption.encrypt(key1, plainText); + + // decrypt returns false when digest doesn't match + const result = data_encryption.decrypt(key2, encrypted); + expect(result).toBe(false); + }); + + it("should handle large content", () => { + const key = new Uint8Array(16).fill(42); + const plainText = "x".repeat(100000); + + const encrypted = data_encryption.encrypt(key, plainText); + const decrypted = data_encryption.decryptString(key, encrypted); + expect(decrypted).toBe(plainText); + }); +}); diff --git a/apps/client-standalone/src/services/image_provider.ts b/apps/client-standalone/src/services/image_provider.ts new file mode 100644 index 0000000000..d5c2ca949e --- /dev/null +++ b/apps/client-standalone/src/services/image_provider.ts @@ -0,0 +1,96 @@ +/** + * Standalone image provider implementation. + * Uses pure JavaScript for format detection without compression. + * Images are saved as-is without resizing. + */ + +import type { ImageProvider, ImageFormat, ProcessedImage } from "@triliumnext/core"; + +/** + * Detect image type from buffer using magic bytes. + */ +function getImageTypeFromBuffer(buffer: Uint8Array): ImageFormat | null { + if (buffer.length < 12) { + return null; + } + + // Check for SVG (text-based) + if (isSvg(buffer)) { + return { ext: "svg", mime: "image/svg+xml" }; + } + + // JPEG: FF D8 FF + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return { ext: "jpg", mime: "image/jpeg" }; + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if ( + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a + ) { + return { ext: "png", mime: "image/png" }; + } + + // GIF: "GIF" + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) { + return { ext: "gif", mime: "image/gif" }; + } + + // WebP: RIFF....WEBP + if ( + buffer[0] === 0x52 && + buffer[1] === 0x49 && + buffer[2] === 0x46 && + buffer[3] === 0x46 && + buffer[8] === 0x57 && + buffer[9] === 0x45 && + buffer[10] === 0x42 && + buffer[11] === 0x50 + ) { + return { ext: "webp", mime: "image/webp" }; + } + + // BMP: "BM" + if (buffer[0] === 0x42 && buffer[1] === 0x4d) { + return { ext: "bmp", mime: "image/bmp" }; + } + + return null; +} + +/** + * Check if buffer contains SVG content. + */ +function isSvg(buffer: Uint8Array): boolean { + const maxBytes = Math.min(buffer.length, 1000); + let str = ""; + for (let i = 0; i < maxBytes; i++) { + str += String.fromCharCode(buffer[i]); + } + + const trimmed = str.trim().toLowerCase(); + return trimmed.startsWith(" { + // Standalone doesn't do compression - just detect format and return original + const format = getImageTypeFromBuffer(buffer) || { ext: "dat", mime: "application/octet-stream" }; + + return { + buffer, + format + }; + } +}; diff --git a/apps/client-standalone/src/sw.ts b/apps/client-standalone/src/sw.ts index 630d09acd9..4e5b5831f7 100644 --- a/apps/client-standalone/src/sw.ts +++ b/apps/client-standalone/src/sw.ts @@ -85,12 +85,22 @@ async function networkFirst(request) { } } -async function forwardToClientLocalServer(request, clientId) { - // Find a client to handle the request (prefer the initiating client if available) - let client = clientId ? await self.clients.get(clientId) : null; +async function forwardToClientLocalServer(request, _clientId) { + // Find the main app window to handle the request + // We must route to the main app (which has the local bridge), not iframes like PDF.js viewer + // @ts-expect-error - self.clients is valid in service worker context + const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + // Find the main app window - it's the one NOT serving pdfjs or other embedded content + // The main app has the local bridge handler for LOCAL_FETCH messages + let client = all.find((c: { url: string }) => { + const url = new URL(c.url); + // Main app is at root or index.html, not in /pdfjs/ or other iframe paths + return !url.pathname.startsWith("/pdfjs/"); + }) || null; + + // If no main app window found, fall back to any available client if (!client) { - const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); client = all[0] || null; } diff --git a/apps/client-standalone/src/test_setup.ts b/apps/client-standalone/src/test_setup.ts index cd77c0a416..08f26d0278 100644 --- a/apps/client-standalone/src/test_setup.ts +++ b/apps/client-standalone/src/test_setup.ts @@ -2,17 +2,19 @@ import { createRequire } from "node:module"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { initializeCore } from "@triliumnext/core"; +import { initializeCore, options } from "@triliumnext/core"; import schemaSql from "@triliumnext/core/src/assets/schema.sql?raw"; import HappyDomHtmlParser from "happy-dom/lib/html-parser/HTMLParser.js"; import serverEnTranslations from "../../server/src/assets/translations/en/server.json"; import { beforeAll } from "vitest"; +import StandaloneBackupService from "./lightweight/backup_provider.js"; import BrowserExecutionContext from "./lightweight/cls_provider.js"; import BrowserCryptoProvider from "./lightweight/crypto_provider.js"; import StandalonePlatformProvider from "./lightweight/platform_provider.js"; import BrowserSqlProvider from "./lightweight/sql_provider.js"; import BrowserZipProvider from "./lightweight/zip_provider.js"; +import { standaloneImageProvider } from "./services/image_provider.js"; // ============================================================================= // SQLite WASM compatibility shims @@ -129,6 +131,8 @@ beforeAll(async () => { }); }, platform: new StandalonePlatformProvider(""), + backup: new StandaloneBackupService(options), + image: standaloneImageProvider, schema: schemaSql, dbConfig: { provider: sqlProvider, diff --git a/apps/client-standalone/src/vite-env.d.ts b/apps/client-standalone/src/vite-env.d.ts deleted file mode 100644 index 3e6ffaec19..0000000000 --- a/apps/client-standalone/src/vite-env.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -/// - -interface ImportMetaEnv { - readonly VITE_APP_TITLE: string -} - -interface ImportMeta { - readonly env: ImportMetaEnv -} - -interface Window { - glob: { - assetPath: string; - themeCssUrl?: string; - themeUseNextAsBase?: string; - iconPackCss: string; - device: string; - headingStyle: string; - layoutOrientation: string; - platform: string; - isElectron: boolean; - hasNativeTitleBar: boolean; - hasBackgroundEffects: boolean; - currentLocale: { - id: string; - rtl: boolean; - }; - activeDialog: any; - }; - global: typeof globalThis; -} diff --git a/apps/client-standalone/vite.config.mts b/apps/client-standalone/vite.config.mts index ea92dd7855..73b1ff09c6 100644 --- a/apps/client-standalone/vite.config.mts +++ b/apps/client-standalone/vite.config.mts @@ -1,7 +1,9 @@ -import prefresh from '@prefresh/vite'; -import { join } from 'path'; -import { defineConfig } from 'vite'; -import { viteStaticCopy } from 'vite-plugin-static-copy'; +import fs from "fs"; +import { join, resolve, sep } from "path"; + +import prefresh from "@prefresh/vite"; +import { defineConfig, type Plugin } from "vite"; +import { viteStaticCopy } from "vite-plugin-static-copy"; const clientAssets = ["assets", "stylesheets", "fonts", "translations"]; @@ -9,15 +11,15 @@ const isDev = process.env.NODE_ENV === "development"; // Watch client files and trigger reload in development const clientWatchPlugin = () => ({ - name: 'client-watch', + name: "client-watch", configureServer(server: any) { if (isDev) { // Watch client source files (adjusted for new root) - server.watcher.add('../../client/src/**/*'); - server.watcher.on('change', (file: string) => { - if (file.includes('../../client/src/')) { + server.watcher.add("../../client/src/**/*"); + server.watcher.on("change", (file: string) => { + if (file.includes("../../client/src/")) { server.ws.send({ - type: 'full-reload' + type: "full-reload" }); } }); @@ -25,6 +27,56 @@ const clientWatchPlugin = () => ({ } }); +// Serve PDF.js files directly in dev mode to bypass SPA fallback +const pdfjsServePlugin = (): Plugin => ({ + name: "pdfjs-serve", + configureServer(server) { + const pdfjsRoot = join(__dirname, "../../packages/pdfjs-viewer/dist"); + + server.middlewares.use((req, res, next) => { + if (!req.url?.startsWith("/pdfjs/")) { + return next(); + } + + // Map /pdfjs/web/... to dist/web/... + // Map /pdfjs/build/... to dist/build/... + // Strip query string (e.g., ?v=0.102.2) before resolving path + const urlWithoutQuery = req.url.split("?")[0]; + const relativePath = urlWithoutQuery.replace(/^\/pdfjs\//, ""); + const filePath = join(pdfjsRoot, relativePath); + + // Security: resolve both paths to prevent prefix-collision attacks + // (e.g. pdfjsRoot="/foo/bar" matching "/foo/bar2/evil.js") + const resolvedRoot = resolve(pdfjsRoot); + const resolvedFilePath = resolve(filePath); + if (!resolvedFilePath.startsWith(resolvedRoot + sep)) { + return next(); + } + + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + const ext = filePath.split(".").pop() || ""; + const mimeTypes: Record = { + html: "text/html", + css: "text/css", + js: "application/javascript", + mjs: "application/javascript", + wasm: "application/wasm", + png: "image/png", + svg: "image/svg+xml", + json: "application/json" + }; + res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream"); + // Match isolation headers from main page for iframe compatibility + res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); + fs.createReadStream(filePath).pipe(res); + } else { + next(); + } + }); + } +}); + // Always copy SQLite WASM files so they're available to the module const sqliteWasmPlugin = viteStaticCopy({ targets: [ @@ -65,10 +117,27 @@ let plugins: any = [ } ] }), + // PDF.js viewer for PDF preview support + // stripBase: 4 removes packages/pdfjs-viewer/dist/web (or /build) + viteStaticCopy({ + targets: [ + { + src: "../../../packages/pdfjs-viewer/dist/web/**/*", + dest: "pdfjs/web", + rename: { stripBase: 4 } + }, + { + src: "../../../packages/pdfjs-viewer/dist/build/**/*", + dest: "pdfjs/build", + rename: { stripBase: 4 } + } + ] + }), // Watch client files for changes in development ...(isDev ? [ prefresh(), - clientWatchPlugin() + clientWatchPlugin(), + pdfjsServePlugin() ] : []) ]; diff --git a/apps/client/package.json b/apps/client/package.json index 3aea46be67..11a0e5cc3d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -42,7 +42,7 @@ "@univerjs/preset-sheets-note": "0.20.0", "@univerjs/preset-sheets-sort": "0.20.0", "@univerjs/presets": "0.20.0", - "@zumer/snapdom": "2.7.0", + "@zumer/snapdom": "2.8.0", "autocomplete.js": "0.38.1", "bootstrap": "5.3.8", "boxicons": "2.1.4", @@ -52,7 +52,7 @@ "dompurify": "3.3.3", "draggabilly": "3.0.0", "force-graph": "1.51.2", - "i18next": "26.0.3", + "i18next": "26.0.4", "i18next-http-backend": "3.0.4", "jquery": "4.0.0", "jquery.fancytree": "2.38.5", diff --git a/apps/client/src/services/experimental_features.ts b/apps/client/src/services/experimental_features.ts index d56836ef6b..22e868d3af 100644 --- a/apps/client/src/services/experimental_features.ts +++ b/apps/client/src/services/experimental_features.ts @@ -1,6 +1,6 @@ import { t } from "./i18n"; import options from "./options"; -import { isMobile } from "./utils"; +import { isMobile, isStandalone } from "./utils"; export interface ExperimentalFeature { id: string; @@ -23,6 +23,11 @@ export const experimentalFeatures = [ export type ExperimentalFeatureId = typeof experimentalFeatures[number]["id"]; +/** Returns experimental features available for the current platform (excludes LLM in standalone mode). */ +export function getAvailableExperimentalFeatures() { + return experimentalFeatures.filter(f => !(f.id === "llm" && isStandalone)); +} + let enabledFeatures: Set | null = null; export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId): boolean { @@ -30,14 +35,24 @@ export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId): return (isMobile() || options.is("newLayout")); } + // LLM features require server-side API calls that don't work in standalone mode + // due to CORS restrictions from LLM providers (OpenAI, Google don't allow browser requests) + if (featureId === "llm" && isStandalone) { + return false; + } + return getEnabledFeatures().has(featureId); } export function getEnabledExperimentalFeatureIds() { - const values = [ ...getEnabledFeatures().values() ]; + let values = [ ...getEnabledFeatures().values() ]; if (isMobile() || options.is("newLayout")) { values.push("new-layout"); } + // LLM is not available in standalone mode + if (isStandalone) { + values = values.filter(v => v !== "llm"); + } return values; } diff --git a/apps/client/src/widgets/buttons/global_menu.tsx b/apps/client/src/widgets/buttons/global_menu.tsx index d5d3422197..a6f58ced1e 100644 --- a/apps/client/src/widgets/buttons/global_menu.tsx +++ b/apps/client/src/widgets/buttons/global_menu.tsx @@ -6,7 +6,7 @@ import { useContext, useEffect, useRef, useState } from "preact/hooks"; import { CommandNames } from "../../components/app_context"; import Component from "../../components/component"; -import { ExperimentalFeature, ExperimentalFeatureId, experimentalFeatures, isExperimentalFeatureEnabled, toggleExperimentalFeature } from "../../services/experimental_features"; +import { ExperimentalFeature, ExperimentalFeatureId, getAvailableExperimentalFeatures, isExperimentalFeatureEnabled, toggleExperimentalFeature } from "../../services/experimental_features"; import { t } from "../../services/i18n"; import utils, { dynamicRequire, isElectron, isMobile, isStandalone, reloadFrontendApp } from "../../services/utils"; import Dropdown from "../react/Dropdown"; @@ -112,7 +112,7 @@ function DevelopmentOptions({ dropStart }: { dropStart: boolean }) { return <> - {experimentalFeatures.map((feature) => ( + {getAvailableExperimentalFeatures().map((feature) => ( ))} diff --git a/apps/client/src/widgets/react/Button.tsx b/apps/client/src/widgets/react/Button.tsx index 6269000563..3114b9dfe8 100644 --- a/apps/client/src/widgets/react/Button.tsx +++ b/apps/client/src/widgets/react/Button.tsx @@ -1,5 +1,4 @@ import type { ComponentChildren, CSSProperties, RefObject } from "preact"; -import { memo } from "preact/compat"; import { useMemo } from "preact/hooks"; import { CommandNames } from "../../components/app_context"; @@ -27,7 +26,7 @@ export interface ButtonProps { title?: string; } -const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortcut, icon, kind, disabled, size, style, triggerCommand, ...restProps }: ButtonProps) => { +function Button({ name, buttonRef, className, text, onClick, keyboardShortcut, icon, kind, disabled, size, style, triggerCommand, ...restProps }: ButtonProps) { // Memoize classes array to prevent recreation const classes = useMemo(() => { const classList: string[] = ["btn"]; @@ -83,7 +82,7 @@ const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortc {text} {shortcutElements} ); -}); +} export function ButtonGroup({ children }: { children: ComponentChildren }) { return ( diff --git a/apps/client/src/widgets/react/Modal.tsx b/apps/client/src/widgets/react/Modal.tsx index e7a7c721f8..bd1c052ab7 100644 --- a/apps/client/src/widgets/react/Modal.tsx +++ b/apps/client/src/widgets/react/Modal.tsx @@ -1,7 +1,6 @@ import { Modal as BootstrapModal } from "bootstrap"; import clsx from "clsx"; import { ComponentChildren, CSSProperties, RefObject } from "preact"; -import { memo } from "preact/compat"; import { useEffect, useMemo, useRef } from "preact/hooks"; import { openDialog } from "../../services/dialog"; @@ -186,7 +185,7 @@ export default function Modal({ children, className, size, title, customTitleBar ); } -const ModalInner = memo(({ children, footer, footerAlignment, bodyStyle, footerStyle: _footerStyle }: Pick) => { +function ModalInner({ children, footer, footerAlignment, bodyStyle, footerStyle: _footerStyle }: Pick) { // Memoize footer style const footerStyle = useMemo(() => { const style: CSSProperties = _footerStyle ?? {}; @@ -209,4 +208,4 @@ const ModalInner = memo(({ children, footer, footerAlignment, bodyStyle, footerS )} ); -}); +} diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 251463fa9a..6c70703385 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -1447,24 +1447,29 @@ export function useColorScheme() { export function useMathRendering(containerRef: RefObject, deps: unknown[]) { useEffect(() => { if (!containerRef.current) return; - // Support both read-only (.math-tex) and CKEditor editing view (.ck-math-tex) classes - const mathElements = containerRef.current.querySelectorAll(".math-tex, .ck-math-tex"); + const mathElements = containerRef.current.querySelectorAll(".math-tex"); for (const mathEl of mathElements) { // Skip if already rendered by KaTeX if (mathEl.querySelector(".katex")) continue; try { - let equation = mathEl.textContent || ""; + // CKEditor's data format wraps the equation with \(...\) or \[...\] + // delimiters. katex.render() expects raw LaTeX without them. + const raw = mathEl.textContent?.trim() ?? ""; + let equation: string; + let displayMode = false; - // CKEditor widgets store equation without delimiters, add them for KaTeX - if (mathEl.classList.contains("ck-math-tex")) { - // Check if it's display mode or inline - const isDisplay = mathEl.classList.contains("ck-math-tex-display"); - equation = isDisplay ? `\\[${equation}\\]` : `\\(${equation}\\)`; + if (raw.startsWith("\\(") && raw.endsWith("\\)")) { + equation = raw.slice(2, -2); + } else if (raw.startsWith("\\[") && raw.endsWith("\\]")) { + equation = raw.slice(2, -2); + displayMode = true; + } else { + equation = raw; } - math.render(equation, mathEl as HTMLElement); + math.render(equation, mathEl as HTMLElement, { displayMode }); } catch (e) { console.warn("Failed to render math:", e); } diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index aca1c11c5b..4442af884c 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -200,17 +200,34 @@ function extractTocFromTextEditor(editor: CKTextEditor) { const level = Number(item.name.replace( 'heading', '' )); - // Convert model element to view, then to DOM to get HTML + // Convert model element to view, then to DOM to get HTML. + // Math UIElements render their KaTeX content asynchronously, so + // ck-math-tex spans may be empty at read time. Replace them with + // math-tex spans (the data format) using the equation from the model, + // so useMathRendering can render them synchronously in the sidebar. const viewEl = editor.editing.mapper.toViewElement(item); let text = ''; if (viewEl) { const domEl = editor.editing.view.domConverter.mapViewToDom(viewEl); if (domEl instanceof HTMLElement) { - text = domEl.innerHTML; + const clone = domEl.cloneNode(true) as HTMLElement; + const ckMathSpans = clone.querySelectorAll('.ck-math-tex'); + let mathIdx = 0; + for (const child of item.getChildren()) { + if (!child.is('element', 'mathtex-inline')) continue; + if (mathIdx >= ckMathSpans.length) break; + const equation = String(child.getAttribute('equation') ?? ''); + const span = document.createElement('span'); + span.className = 'math-tex'; + span.textContent = `\\(${equation}\\)`; + ckMathSpans[mathIdx].replaceWith(span); + mathIdx++; + } + text = clone.innerHTML; } } - // Fallback to plain text if conversion fails + // Fallback to plain text if DOM conversion fails if (!text) { text = Array.from( item.getChildren() ) .map( c => c.is( '$text' ) ? c.data : '' ) diff --git a/apps/client/src/widgets/type_widgets/options/advanced.tsx b/apps/client/src/widgets/type_widgets/options/advanced.tsx index 619a1fb514..e426e2b5eb 100644 --- a/apps/client/src/widgets/type_widgets/options/advanced.tsx +++ b/apps/client/src/widgets/type_widgets/options/advanced.tsx @@ -1,7 +1,7 @@ import { AnonymizedDbResponse, DatabaseAnonymizeResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons"; import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; -import { experimentalFeatures, type ExperimentalFeatureId } from "../../../services/experimental_features"; +import { getAvailableExperimentalFeatures, type ExperimentalFeatureId } from "../../../services/experimental_features"; import { t } from "../../../services/i18n"; import server from "../../../services/server"; import toast from "../../../services/toast"; @@ -182,7 +182,7 @@ function VacuumDatabaseOptions() { function ExperimentalOptions() { const [enabledFeatures, setEnabledFeatures] = useTriliumOptionJson("experimentalFeatures", true); - const filteredFeatures = useMemo(() => experimentalFeatures.filter(e => e.id !== "new-layout"), []); + const filteredFeatures = useMemo(() => getAvailableExperimentalFeatures().filter(e => e.id !== "new-layout"), []); const toggleFeature = useCallback((featureId: ExperimentalFeatureId, enabled: boolean) => { if (enabled) { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0ef67c9106..7473aac31f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -45,7 +45,7 @@ "@triliumnext/server": "workspace:*", "@types/electron-squirrel-startup": "1.0.2", "copy-webpack-plugin": "14.0.0", - "electron": "41.1.1", + "electron": "41.2.0", "prebuild-install": "7.1.3" } } \ No newline at end of file diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 497ce0cc7e..9045aa1dcb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -4,13 +4,15 @@ import NodejsCryptoProvider from "@triliumnext/server/src/crypto_provider.js"; import { loadCoreSchema } from "@triliumnext/server/src/core_assets.js"; import NodejsInAppHelpProvider from "@triliumnext/server/src/in_app_help_provider.js"; import dataDirs from "@triliumnext/server/src/services/data_dir.js"; -import options from "@triliumnext/server/src/services/options.js"; +import { options } from "@triliumnext/core"; import port from "@triliumnext/server/src/services/port.js"; import NodeRequestProvider from "@triliumnext/server/src/services/request.js"; import { RESOURCE_DIR } from "@triliumnext/server/src/services/resource_dir.js"; import tray from "@triliumnext/server/src/services/tray.js"; import windowService from "@triliumnext/server/src/services/window.js"; import WebSocketMessagingProvider from "@triliumnext/server/src/services/ws_messaging_provider.js"; +import ServerBackupService from "@triliumnext/server/src/backup_provider.js"; +import ServerLogService from "@triliumnext/server/src/log_provider.js"; import BetterSqlite3Provider from "@triliumnext/server/src/sql_provider.js"; import NodejsZipProvider from "@triliumnext/server/src/zip_provider.js"; import { app, BrowserWindow,globalShortcut } from "electron"; @@ -150,6 +152,9 @@ async function main() { // both source and bundled-production modes. getDemoArchive: async () => fs.readFileSync(path.join(RESOURCE_DIR, "db", "demo.zip")), inAppHelp: new NodejsInAppHelpProvider(), + log: new ServerLogService(), + backup: new ServerBackupService(options), + image: (await import("@triliumnext/server/src/services/image_provider.js")).serverImageProvider, extraAppInfo: { nodeVersion: process.version, dataDirectory: path.resolve(dataDirs.TRILIUM_DATA_DIR) diff --git a/apps/desktop/tsconfig.app.json b/apps/desktop/tsconfig.app.json index ee86e59582..bd3c2fa5f1 100644 --- a/apps/desktop/tsconfig.app.json +++ b/apps/desktop/tsconfig.app.json @@ -27,6 +27,9 @@ }, { "path": "../../packages/commons/tsconfig.lib.json" + }, + { + "path": "../../packages/trilium-core/tsconfig.lib.json" } ] } diff --git a/apps/edit-docs/package.json b/apps/edit-docs/package.json index d663ce56a5..d4cd68e140 100644 --- a/apps/edit-docs/package.json +++ b/apps/edit-docs/package.json @@ -13,7 +13,7 @@ "@triliumnext/desktop": "workspace:*", "@types/fs-extra": "11.0.4", "copy-webpack-plugin": "14.0.0", - "electron": "41.1.1", + "electron": "41.2.0", "fs-extra": "11.3.4" }, "scripts": { diff --git a/apps/server/package.json b/apps/server/package.json index 91d9a3b6bc..e909296494 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,11 +34,11 @@ "@ai-sdk/google": "3.0.60", "@ai-sdk/openai": "3.0.52", "@modelcontextprotocol/sdk": "^1.12.1", - "ai": "6.0.153", + "ai": "6.0.154", "better-sqlite3": "12.8.0", "html-to-text": "9.0.5", "i18next-fs-backend": "2.6.3", - "i18next": "26.0.3", + "i18next": "26.0.4", "js-yaml": "4.1.1", "unpdf": "1.4.0" }, @@ -82,7 +82,7 @@ "debounce": "3.0.0", "debug": "4.4.3", "ejs": "5.0.1", - "electron": "41.1.1", + "electron": "41.2.0", "electron-window-state": "5.0.3", "express": "5.2.1", "express-http-proxy": "2.1.2", diff --git a/apps/server/spec/setup.ts b/apps/server/spec/setup.ts index 10ee99569b..e22d00f14b 100644 --- a/apps/server/spec/setup.ts +++ b/apps/server/spec/setup.ts @@ -1,8 +1,9 @@ import { beforeAll } from "vitest"; import { readFileSync } from "fs"; import { join } from "path"; -import { initializeCore } from "@triliumnext/core"; +import { initializeCore, options } from "@triliumnext/core"; import { serverZipExportProviderFactory } from "../src/services/export/zip/factory.js"; +import ServerBackupService from "../src/backup_provider.js"; import ClsHookedExecutionContext from "../src/cls_provider.js"; import NodejsCryptoProvider from "../src/crypto_provider.js"; import NodejsZipProvider from "../src/zip_provider.js"; @@ -10,6 +11,8 @@ import ServerPlatformProvider from "../src/platform_provider.js"; import BetterSqlite3Provider from "../src/sql_provider.js"; import NodejsInAppHelpProvider from "../src/in_app_help_provider.js"; import { initializeTranslationsWithParams } from "../src/services/i18n.js"; +import ServerLogService from "../src/log_provider.js"; +import { serverImageProvider } from "../src/services/image_provider.js"; // Initialize environment variables. process.env.TRILIUM_DATA_DIR = join(__dirname, "db"); @@ -42,6 +45,9 @@ beforeAll(async () => { schema: readFileSync(require.resolve("@triliumnext/core/src/assets/schema.sql"), "utf-8"), platform: new ServerPlatformProvider(), translations: initializeTranslationsWithParams, - inAppHelp: new NodejsInAppHelpProvider() + inAppHelp: new NodejsInAppHelpProvider(), + backup: new ServerBackupService(options), + log: new ServerLogService(), + image: serverImageProvider }); }); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index b85402d1bd..3074e6ed01 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -122,8 +122,6 @@ export default async function buildApp() { const { sync, consistency_checks, scheduler } = await import("@triliumnext/core"); sync.startSyncTimer(); - await import("./services/backup.js"); - consistency_checks.startConsistencyChecks(); scheduler.startScheduler(); diff --git a/apps/server/src/backup_provider.ts b/apps/server/src/backup_provider.ts new file mode 100644 index 0000000000..71f9221f6c --- /dev/null +++ b/apps/server/src/backup_provider.ts @@ -0,0 +1,65 @@ +import type { DatabaseBackup } from "@triliumnext/commons"; +import { BackupOptionsService, BackupService, sync_mutex as syncMutexService } from "@triliumnext/core"; +import fs from "fs"; +import path from "path"; + +import dataDir from "./services/data_dir.js"; +import log from "./services/log.js"; +import sql from "./services/sql.js"; + +export default class ServerBackupService extends BackupService { + constructor(options: BackupOptionsService) { + super(options); + } + + override async getExistingBackups(): Promise { + if (!fs.existsSync(dataDir.BACKUP_DIR)) { + return []; + } + + return fs + .readdirSync(dataDir.BACKUP_DIR) + .filter((fileName) => fileName.includes("backup")) + .map((fileName) => { + const filePath = path.resolve(dataDir.BACKUP_DIR, fileName); + const stat = fs.statSync(filePath); + + return { fileName, filePath, mtime: stat.mtime }; + }); + } + + // regularBackup() inherited from BackupService - uses getContext().init() + + override async backupNow(name: string): Promise { + // 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-${name}.db`); + + if (!fs.existsSync(dataDir.BACKUP_DIR)) { + fs.mkdirSync(dataDir.BACKUP_DIR, 0o700); + } + + log.info("Creating backup..."); + await sql.copyDatabase(backupFile); + log.info(`Created backup at ${backupFile}`); + + return backupFile; + }); + } + + override async getBackupContent(filePath: string): Promise { + const resolvedPath = path.resolve(filePath); + const backupDir = path.resolve(dataDir.BACKUP_DIR); + + // Security check: ensure the path is within the backup directory + if (!resolvedPath.startsWith(backupDir + path.sep)) { + return null; + } + + if (!fs.existsSync(resolvedPath)) { + return null; + } + + return fs.readFileSync(resolvedPath); + } +} diff --git a/apps/server/src/crypto_provider.ts b/apps/server/src/crypto_provider.ts index 5643b9ecf4..b2030babc9 100644 --- a/apps/server/src/crypto_provider.ts +++ b/apps/server/src/crypto_provider.ts @@ -1,4 +1,5 @@ -import { CryptoProvider } from "@triliumnext/core"; +import type { CryptoProvider, ScryptOptions } from "@triliumnext/core"; +import { binary_utils } from "@triliumnext/core"; import crypto from "crypto"; import { generator } from "rand-token"; @@ -32,4 +33,28 @@ export default class NodejsCryptoProvider implements CryptoProvider { return hmac.digest("base64"); } + async scrypt( + password: Uint8Array | string, + salt: Uint8Array | string, + keyLength: number, + options: ScryptOptions = {} + ): Promise { + const { N = 16384, r = 8, p = 1 } = options; + const passwordBytes = binary_utils.wrapStringOrBuffer(password); + const saltBytes = binary_utils.wrapStringOrBuffer(salt); + return crypto.scryptSync(passwordBytes, saltBytes, keyLength, { N, r, p }); + } + + constantTimeCompare(a: Uint8Array, b: Uint8Array): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + + if (bufA.length !== bufB.length) { + // Compare bufA against itself to maintain constant time behavior + crypto.timingSafeEqual(bufA, bufA); + return false; + } + + return crypto.timingSafeEqual(bufA, bufB); + } } diff --git a/apps/server/src/etapi/backup.ts b/apps/server/src/etapi/backup.ts index 73ab3e5c48..fc9755e3b6 100644 --- a/apps/server/src/etapi/backup.ts +++ b/apps/server/src/etapi/backup.ts @@ -1,11 +1,11 @@ +import { getBackup } from "@triliumnext/core"; import type { Router } from "express"; -import backupService from "../services/backup.js"; import eu from "./etapi_utils.js"; function register(router: Router) { eu.route<{ backupName: string }>(router, "put", "/etapi/backup/:backupName", (req, res, next) => { - backupService.backupNow(req.params.backupName) + getBackup().backupNow(req.params.backupName) .then(() => res.sendStatus(204)) .catch(() => res.sendStatus(500)); }); diff --git a/apps/server/src/log_provider.ts b/apps/server/src/log_provider.ts new file mode 100644 index 0000000000..d149153c9b --- /dev/null +++ b/apps/server/src/log_provider.ts @@ -0,0 +1,125 @@ +import { FileBasedLogService, type LogFileInfo } from "@triliumnext/core"; +import type { Request, Response } from "express"; +import fs from "fs"; +import { EOL } from "os"; +import path from "path"; + +import config, { LOGGING_DEFAULT_RETENTION_DAYS } from "./services/config.js"; +import dataDir from "./services/data_dir.js"; + +const LOG_FILE_PATTERN = /^trilium-\d{4}-\d{2}-\d{2}\.log$/; + +const requestBlacklist = ["/app", "/images", "/stylesheets", "/api/recent-notes"]; + +export default class ServerLogService extends FileBasedLogService { + private logFile: fs.WriteStream | undefined; + + constructor() { + super(); + // Server uses sync initialization since Node.js fs operations are sync + this.ensureLogDirectory(); + this.todaysMidnight = this.getTodaysMidnight(); + this.openLogFile(this.getLogFileName()); + } + + // ==================== Abstract Method Implementations ==================== + + protected override get eol(): string { + return EOL; + } + + protected override ensureLogDirectory(): void { + fs.mkdirSync(dataDir.LOG_DIR, { recursive: true, mode: 0o700 }); + } + + protected override openLogFile(fileName: string): void { + const logPath = path.join(dataDir.LOG_DIR, fileName); + this.logFile = fs.createWriteStream(logPath, { flags: "a" }); + } + + protected override closeLogFile(): void { + if (this.logFile) { + this.logFile.end(); + this.logFile = undefined; + } + } + + protected override writeEntry(entry: string): void { + this.logFile?.write(entry); + } + + protected override readLogFile(fileName: string): string | null { + const filePath = path.join(dataDir.LOG_DIR, fileName); + try { + return fs.readFileSync(filePath, "utf8"); + } catch { + return null; + } + } + + protected override async listLogFiles(): Promise { + const files = await fs.promises.readdir(dataDir.LOG_DIR); + const logFiles: LogFileInfo[] = []; + + for (const file of files) { + if (!LOG_FILE_PATTERN.test(file)) { + continue; + } + + const filePath = path.join(dataDir.LOG_DIR, file); + + // Security: Verify path stays within LOG_DIR + const resolvedPath = path.resolve(filePath); + const resolvedLogDir = path.resolve(dataDir.LOG_DIR); + if (!resolvedPath.startsWith(resolvedLogDir + path.sep)) { + continue; + } + + try { + const stats = await fs.promises.stat(filePath); + logFiles.push({ name: file, mtime: stats.mtime }); + } catch { + // Skip files we can't stat + } + } + + return logFiles; + } + + protected override async deleteLogFile(fileName: string): Promise { + const filePath = path.join(dataDir.LOG_DIR, fileName); + + // Security: Verify path stays within LOG_DIR + const resolvedPath = path.resolve(filePath); + const resolvedLogDir = path.resolve(dataDir.LOG_DIR); + if (!resolvedPath.startsWith(resolvedLogDir + path.sep)) { + return; + } + + await fs.promises.unlink(filePath); + } + + protected override getRetentionDays(): number { + const customRetentionDays = config.Logging.retentionDays; + if (customRetentionDays !== undefined && customRetentionDays !== 0) { + return customRetentionDays; + } + return LOGGING_DEFAULT_RETENTION_DAYS; + } + + // ==================== Server-Specific Methods ==================== + + request(req: Request, res: Response, timeMs: number, responseLength: number | string = "?"): void { + for (const bl of requestBlacklist) { + if (req.url.startsWith(bl)) { + return; + } + } + + if (req.url.includes(".js.map") || req.url.includes(".css.map")) { + return; + } + + this.info(`${timeMs >= 10 ? "Slow " : ""}${res.statusCode} ${req.method} ${req.url} with ${responseLength} bytes took ${timeMs}ms`); + } +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1905ea7102..00418c9234 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -3,15 +3,17 @@ * are loaded later and will result in an empty string. */ -import { getLog, initializeCore, sql_init } from "@triliumnext/core"; +import { getLog, initializeCore, options, sql_init } from "@triliumnext/core"; import fs from "fs"; import { t } from "i18next"; import path from "path"; +import ServerBackupService from "./backup_provider.js"; import ClsHookedExecutionContext from "./cls_provider.js"; import { getIntegrationTestDbPath, loadCoreSchema } from "./core_assets.js"; import NodejsCryptoProvider from "./crypto_provider.js"; import NodejsInAppHelpProvider from "./in_app_help_provider.js"; +import ServerLogService from "./log_provider.js"; import ServerPlatformProvider from "./platform_provider.js"; import dataDirs from "./services/data_dir.js"; import port from "./services/port.js"; @@ -37,6 +39,8 @@ async function startApplication() { dbProvider.loadFromFile(DOCUMENT_PATH, config.General.readOnly); } + const logService = new ServerLogService(); + await initializeCore({ dbConfig: { provider: dbProvider, @@ -49,12 +53,11 @@ async function startApplication() { const cls = (await import("./services/cls.js")).default; const becca_loader = (await import("@triliumnext/core")).becca_loader; const entity_changes = (await import("./services/entity_changes.js")).default; - const log = (await import("./services/log")).default; const entityChangeIds = cls.getAndClearEntityChangeIds(); if (entityChangeIds.length > 0) { - log.info("Transaction rollback dirtied the becca, forcing reload."); + logService.info("Transaction rollback dirtied the becca, forcing reload."); becca_loader.load(); } @@ -71,12 +74,15 @@ async function startApplication() { messaging: new WebSocketMessagingProvider(), schema: loadCoreSchema(), platform: new ServerPlatformProvider(), + log: logService, translations: (await import("./services/i18n.js")).initializeTranslationsWithParams, // demo.zip is a server-owned asset; src/assets is copied to dist/assets // by the build script, so the same RESOURCE_DIR-relative path works in // both source and bundled-production modes. getDemoArchive: async () => fs.readFileSync(path.join(RESOURCE_DIR, "db", "demo.zip")), inAppHelp: new NodejsInAppHelpProvider(), + backup: new ServerBackupService(options), + image: (await import("./services/image_provider.js")).serverImageProvider, extraAppInfo: { nodeVersion: process.version, dataDirectory: path.resolve(dataDirs.TRILIUM_DATA_DIR) diff --git a/apps/server/src/routes/api/backend_log.ts b/apps/server/src/routes/api/backend_log.ts deleted file mode 100644 index 2f056158de..0000000000 --- a/apps/server/src/routes/api/backend_log.ts +++ /dev/null @@ -1,33 +0,0 @@ -"use strict"; - -import { readFile } from "fs/promises"; -import { join } from "path"; -import dateUtils from "../../services/date_utils.js"; -import dataDir from "../../services/data_dir.js"; -import log from "../../services/log.js"; -import { t } from "i18next"; - -const { LOG_DIR } = dataDir; - -async function getBackendLog() { - const fileName = `trilium-${dateUtils.localNowDate()}.log`; - try { - const file = join(LOG_DIR, fileName); - return await readFile(file, "utf8"); - } catch (e) { - const isErrorInstance = e instanceof Error; - - // most probably the log file does not exist yet - https://github.com/zadam/trilium/issues/1977 - if (isErrorInstance && "code" in e && e.code === "ENOENT") { - log.error(e); - return t("backend_log.log-does-not-exist", { fileName }); - } - - log.error(isErrorInstance ? e : `Reading the backend log '${fileName}' failed with an unknown error: '${e}'.`); - return t("backend_log.reading-log-failed", { fileName }); - } -} - -export default { - getBackendLog -}; diff --git a/apps/server/src/routes/api/database.ts b/apps/server/src/routes/api/database.ts index ef73983bde..58457a1ebf 100644 --- a/apps/server/src/routes/api/database.ts +++ b/apps/server/src/routes/api/database.ts @@ -1,12 +1,11 @@ import { BackupDatabaseNowResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons"; -import { becca_loader, ValidationError } from "@triliumnext/core"; +import { becca_loader, getBackup, ValidationError } from "@triliumnext/core"; import type { Request, Response } from "express"; import fs, { readFileSync } from "fs"; import path from "path"; import { getIntegrationTestDbPath } from "../../core_assets.js"; import anonymizationService from "../../services/anonymization.js"; -import backupService from "../../services/backup.js"; import consistencyChecksService from "../../services/consistency_checks.js"; import dataDir from "../../services/data_dir.js"; import log from "../../services/log.js"; @@ -14,12 +13,12 @@ import sql from "../../services/sql.js"; import sql_init from "../../services/sql_init.js"; function getExistingBackups() { - return backupService.getExistingBackups(); + return getBackup().getExistingBackups(); } async function backupDatabase() { return { - backupFile: await backupService.backupNow("now") + backupFile: await getBackup().backupNow("now") } satisfies BackupDatabaseNowResponse; } diff --git a/apps/server/src/routes/api/login.ts b/apps/server/src/routes/api/login.ts index 6106fc52d2..fc46c19617 100644 --- a/apps/server/src/routes/api/login.ts +++ b/apps/server/src/routes/api/login.ts @@ -1,4 +1,10 @@ -import { events as eventService, getInstanceId } from "@triliumnext/core"; +/** + * Server-only login routes. + * + * Protected session routes (loginToProtectedSession, logoutFromProtectedSession, + * touchProtectedSession) are now in core and registered via buildSharedApiRoutes. + */ +import { getInstanceId } from "@triliumnext/core"; import type { Request } from "express"; import appInfo from "../../services/app_info.js"; @@ -7,12 +13,10 @@ import passwordEncryptionService from "../../services/encryption/password_encryp import recoveryCodeService from "../../services/encryption/recovery_codes"; import etapiTokenService from "../../services/etapi_tokens.js"; import options from "../../services/options.js"; -import protectedSessionService from "../../services/protected_session.js"; import sql from "../../services/sql.js"; import sqlInit from "../../services/sql_init.js"; import totp from "../../services/totp"; import utils from "../../services/utils.js"; -import ws from "../../services/ws.js"; /** * @swagger @@ -118,48 +122,7 @@ function loginSync(req: Request) { }; } -function loginToProtectedSession(req: Request) { - const password = req.body.password; - - if (!passwordEncryptionService.verifyPassword(password)) { - return { - success: false, - message: "Given current password doesn't match hash" - }; - } - - const decryptedDataKey = passwordEncryptionService.getDataKey(password); - if (!decryptedDataKey) { - return { - success: false, - message: "Unable to obtain data key." - }; - } - - protectedSessionService.setDataKey(decryptedDataKey); - - eventService.emit(eventService.ENTER_PROTECTED_SESSION); - - ws.sendMessageToAllClients({ type: "protectedSessionLogin" }); - - return { - success: true - }; -} - -function logoutFromProtectedSession() { - protectedSessionService.resetDataKey(); - - eventService.emit(eventService.LEAVE_PROTECTED_SESSION); - - ws.sendMessageToAllClients({ type: "protectedSessionLogout" }); -} - -function touchProtectedSession() { - protectedSessionService.touchProtectedSession(); -} - -function token(req: Request) { +async function token(req: Request) { const password = req.body.password; const submittedTotpToken = req.body.totpToken; @@ -169,7 +132,7 @@ function token(req: Request) { } } - if (!passwordEncryptionService.verifyPassword(password)) { + if (!(await passwordEncryptionService.verifyPassword(password))) { return [401, "Incorrect credential"]; } @@ -191,8 +154,5 @@ function verifyTOTP(submittedTotpToken: string) { export default { loginSync, - loginToProtectedSession, - logoutFromProtectedSession, - touchProtectedSession, token }; diff --git a/apps/server/src/routes/api/password.ts b/apps/server/src/routes/api/password.ts index 959357893a..2c2ebd8f17 100644 --- a/apps/server/src/routes/api/password.ts +++ b/apps/server/src/routes/api/password.ts @@ -4,12 +4,11 @@ import type { Request } from "express"; import passwordService from "../../services/encryption/password.js"; -function changePassword(req: Request): ChangePasswordResponse { +async function changePassword(req: Request): Promise { if (passwordService.isPasswordSet()) { - return passwordService.changePassword(req.body.current_password, req.body.new_password); + return await passwordService.changePassword(req.body.current_password, req.body.new_password); } - return passwordService.setPassword(req.body.new_password); - + return await passwordService.setPassword(req.body.new_password); } function resetPassword(req: Request) { diff --git a/apps/server/src/routes/login.ts b/apps/server/src/routes/login.ts index 06bf37948c..84644340b3 100644 --- a/apps/server/src/routes/login.ts +++ b/apps/server/src/routes/login.ts @@ -1,19 +1,15 @@ -import { ValidationError } from "@triliumnext/core"; +import { ValidationError, password_encryption } from "@triliumnext/core"; import { i18n } from "@triliumnext/core"; -import crypto from "crypto"; import type { Request, Response } from 'express'; import appPath from "../services/app_path.js"; import assetPath, { assetUrlFragment } from "../services/asset_path.js"; -import myScryptService from "../services/encryption/my_scrypt.js"; import openIDEncryption from '../services/encryption/open_id_encryption.js'; import passwordService from "../services/encryption/password.js"; import recoveryCodeService from '../services/encryption/recovery_codes.js'; import log from "../services/log.js"; import openID from '../services/open_id.js'; -import optionService from "../services/options.js"; import totp from '../services/totp.js'; -import utils from "../services/utils.js"; function loginPage(req: Request, res: Response) { // Login page is triggered twice. Once here, and another time (see sendLoginError) if the password is failed. @@ -40,7 +36,7 @@ function setPasswordPage(req: Request, res: Response) { }); } -function setPassword(req: Request, res: Response) { +async function setPassword(req: Request, res: Response) { if (passwordService.isPasswordSet()) { throw new ValidationError("Password has been already set"); } @@ -67,7 +63,7 @@ function setPassword(req: Request, res: Response) { return; } - passwordService.setPassword(password1); + await passwordService.setPassword(password1); res.redirect("login"); } @@ -102,7 +98,7 @@ function setPassword(req: Request, res: Response) { * '401': * description: Password / TOTP mismatch */ -function login(req: Request, res: Response) { +async function login(req: Request, res: Response) { if (openID.isOpenIDEnabled()) { res.oidc.login({ returnTo: '/', @@ -124,7 +120,7 @@ function login(req: Request, res: Response) { } } - if (!verifyPassword(submittedPassword)) { + if (!(await password_encryption.verifyPassword(submittedPassword))) { sendLoginError(req, res, 'password'); return; } @@ -157,18 +153,6 @@ function verifyTOTP(submittedTotpToken: string) { return recoveryCodeValidates; } -function verifyPassword(submittedPassword: string) { - const hashed_password = utils.fromBase64(optionService.getOption("passwordVerificationHash")); - - const guess_hashed = myScryptService.getVerificationHash(submittedPassword); - - // Use constant-time comparison to prevent timing attacks - if (hashed_password.length !== guess_hashed.length) { - return false; - } - return crypto.timingSafeEqual(guess_hashed, hashed_password); -} - function sendLoginError(req: Request, res: Response, errorType: 'password' | 'totp' = 'password') { // note that logged IP address is usually meaningless since the traffic should come from a reverse proxy if (totp.isTotpEnabled()) { diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index d3f1957138..b8168583aa 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -18,7 +18,6 @@ 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 backendLogRoute from "./api/backend_log.js"; import clipperRoute from "./api/clipper.js"; import databaseRoute from "./api/database.js"; import etapiTokensApiRoutes from "./api/etapi_tokens.js"; @@ -29,7 +28,6 @@ import llmSpecialNotesRoute from "./api/llm_special_notes.js"; import loginApiRoute from "./api/login.js"; import metricsRoute from "./api/metrics.js"; import ocrRoute from "./api/ocr.js"; -import passwordApiRoute from "./api/password.js"; import recoveryCodes from './api/recovery_codes.js'; import senderRoute from "./api/sender.js"; import systemInfoRoute from "./api/system_info.js"; @@ -59,9 +57,9 @@ function register(app: express.Application) { }); route(GET, "/bootstrap", [ auth.checkAuth ], indexRoute.bootstrap); - route(PST, "/login", [loginRateLimiter], loginRoute.login); + asyncRoute(PST, "/login", [loginRateLimiter], loginRoute.login, null); route(PST, "/logout", [csrfMiddleware, auth.checkAuth], loginRoute.logout); - route(PST, "/set-password", [auth.checkAppInitialized, auth.checkPasswordNotSet], loginRoute.setPassword); + asyncRoute(PST, "/set-password", [auth.checkAppInitialized, auth.checkPasswordNotSet], loginRoute.setPassword, null); route(GET, "/setup", [], setupRoute.setupPage); @@ -107,8 +105,6 @@ function register(app: express.Application) { apiRoute(PST, "/api/notes/:noteId/save-to-tmp-dir", filesRoute.saveNoteToTmpDir); apiRoute(PST, "/api/notes/:noteId/upload-modified-file", filesRoute.uploadModifiedFileToNote); - // TODO: Bring back attachment uploading - // route(PST, "/api/notes/:noteId/attachments/upload", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], attachmentsApiRoute.uploadAttachment, apiResultHandler); asyncRoute( GET, "/api/attachments/:attachmentId/open-partial", @@ -124,15 +120,9 @@ function register(app: express.Application) { apiRoute(PST, "/api/attachments/:attachmentId/upload-modified-file", filesRoute.uploadModifiedFileToAttachment); route(PUT, "/api/attachments/:attachmentId/file", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], filesRoute.updateAttachment, apiResultHandler); - // TODO: Re-enable once ported to core. - // route(PUT, "/api/images/:noteId", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], imageRoute.updateImage, apiResultHandler); - // TODO: Re-enable once we support route() // route(GET, "/api/revisions/:revisionId/download", [auth.checkApiAuthOrElectron], revisionsApiRoute.downloadRevision); - apiRoute(PST, "/api/password/change", passwordApiRoute.changePassword); - apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword); - apiRoute(GET, "/api/metrics", metricsRoute.getMetrics); apiRoute(GET, "/api/system-checks", systemInfoRoute.systemChecks); @@ -140,12 +130,7 @@ function register(app: express.Application) { route(GET, "/api/health-check", [], () => ({ status: "ok" }), 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); - apiRoute(PST, "/api/logout/protected", loginApiRoute.logoutFromProtectedSession); - - route(PST, "/api/login/token", [loginRateLimiter], loginApiRoute.token, apiResultHandler); + asyncRoute(PST, "/api/login/token", [loginRateLimiter], loginApiRoute.token, apiResultHandler); apiRoute(GET, "/api/etapi-tokens", etapiTokensApiRoutes.getTokens); apiRoute(PST, "/api/etapi-tokens", etapiTokensApiRoutes.createToken); @@ -173,10 +158,7 @@ function register(app: express.Application) { asyncRoute(PST, "/api/database/rebuild/", [auth.checkApiAuthOrElectron], databaseRoute.rebuildIntegrationTestDatabase, apiResultHandler); } - // backup requires execution outside of transaction - asyncRoute(PST, "/api/database/backup-database", [auth.checkApiAuthOrElectron, csrfMiddleware], databaseRoute.backupDatabase, apiResultHandler); - apiRoute(GET, "/api/database/backups", databaseRoute.getExistingBackups); - route(GET, "/api/database/backup/download", [auth.checkApiAuthOrElectron], databaseRoute.downloadBackup); + // backup routes (backups, backup-database, backup/download) are in core // VACUUM requires execution outside of transaction asyncRoute(PST, "/api/database/vacuum-database", [auth.checkApiAuthOrElectron, csrfMiddleware], databaseRoute.vacuumDatabase, apiResultHandler); @@ -189,11 +171,10 @@ function register(app: express.Application) { apiRoute(GET, "/api/llm-chat/models", llmChatRoute.getModels); // no CSRF since this is called from android app - route(PST, "/api/sender/login", [loginRateLimiter], loginApiRoute.token, apiResultHandler); + asyncRoute(PST, "/api/sender/login", [loginRateLimiter], loginApiRoute.token, apiResultHandler); asyncRoute(PST, "/api/sender/image", [auth.checkEtapiToken, uploadMiddlewareWithErrorHandling], senderRoute.uploadImage, apiResultHandler); asyncRoute(PST, "/api/sender/note", [auth.checkEtapiToken], senderRoute.saveNote, apiResultHandler); - asyncApiRoute(GET, "/api/backend-log", backendLogRoute.getBackendLog); route(GET, "/api/fonts", [auth.checkApiAuthOrElectron], fontsRoute.getFontCss); shareRoutes.register(router); diff --git a/apps/server/src/services/backup.ts b/apps/server/src/services/backup.ts deleted file mode 100644 index 23599d291e..0000000000 --- a/apps/server/src/services/backup.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { DatabaseBackup, OptionNames } from "@triliumnext/commons"; -import { sync_mutex as syncMutexService } from "@triliumnext/core"; -import fs from "fs"; -import path from "path"; - -import cls from "./cls.js"; -import dataDir from "./data_dir.js"; -import dateUtils from "./date_utils.js"; -import log from "./log.js"; -import optionService from "./options.js"; -import sql from "./sql.js"; - -type BackupType = "daily" | "weekly" | "monthly"; - -function getExistingBackups(): DatabaseBackup[] { - if (!fs.existsSync(dataDir.BACKUP_DIR)) { - return []; - } - - return fs - .readdirSync(dataDir.BACKUP_DIR) - .filter((fileName) => fileName.includes("backup")) - .map((fileName) => { - const filePath = path.resolve(dataDir.BACKUP_DIR, fileName); - const stat = fs.statSync(filePath); - - return { fileName, filePath, mtime: stat.mtime }; - }); -} - -function regularBackup() { - cls.init(() => { - periodBackup("lastDailyBackupDate", "daily", 24 * 3600); - - periodBackup("lastWeeklyBackupDate", "weekly", 7 * 24 * 3600); - - periodBackup("lastMonthlyBackupDate", "monthly", 30 * 24 * 3600); - }); -} - -function isBackupEnabled(backupType: BackupType) { - let optionName: OptionNames; - switch (backupType) { - case "daily": - optionName = "dailyBackupEnabled"; - break; - case "weekly": - optionName = "weeklyBackupEnabled"; - break; - case "monthly": - optionName = "monthlyBackupEnabled"; - break; - } - - return optionService.getOptionBool(optionName); -} - -function periodBackup(optionName: "lastDailyBackupDate" | "lastWeeklyBackupDate" | "lastMonthlyBackupDate", backupType: BackupType, periodInSeconds: number) { - if (!isBackupEnabled(backupType)) { - return; - } - - const now = new Date(); - const lastBackupDate = dateUtils.parseDateTime(optionService.getOption(optionName)); - - if (now.getTime() - lastBackupDate.getTime() > periodInSeconds * 1000) { - backupNow(backupType); - - optionService.setOption(optionName, dateUtils.utcNowDateTime()); - } -} - -async function backupNow(name: string) { - // 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-${name}.db`); - - if (!fs.existsSync(dataDir.BACKUP_DIR)) { - fs.mkdirSync(dataDir.BACKUP_DIR, 0o700); - } - - log.info("Creating backup..."); - await sql.copyDatabase(backupFile); - log.info(`Created backup at ${backupFile}`); - - return backupFile; - }); -} -export default { - getExistingBackups, - backupNow, - regularBackup -}; diff --git a/apps/server/src/services/data_dir.spec.ts b/apps/server/src/services/data_dir.spec.ts index db9d47e991..223e17693d 100644 --- a/apps/server/src/services/data_dir.spec.ts +++ b/apps/server/src/services/data_dir.spec.ts @@ -16,6 +16,11 @@ describe("data_dir.ts unit tests", async () => { pathJoinMock: vi.fn() }; + // Reset the module cache so the dynamic imports below get a fresh instance + // of data_dir.ts with the mocked dependencies rather than the cached copy + // loaded by spec/setup.ts. + vi.resetModules(); + // using doMock, to avoid hoisting, so that we can use the mockFn object // to collect all mocked Fns vi.doMock("node:fs", () => { @@ -37,7 +42,7 @@ describe("data_dir.ts unit tests", async () => { }; }); - vi.doMock("path", () => { + vi.doMock("node:path", () => { return { join: mockFn.pathJoinMock }; diff --git a/apps/server/src/services/data_dir.ts b/apps/server/src/services/data_dir.ts index 066bc89335..a3ed9f2bae 100644 --- a/apps/server/src/services/data_dir.ts +++ b/apps/server/src/services/data_dir.ts @@ -6,9 +6,9 @@ * - case D) as a fallback if the previous step fails, we'll use home dir */ -import fs from "fs"; -import os from "os"; -import { join as pathJoin } from "path"; +import fs from "node:fs"; +import os from "node:os"; +import { join as pathJoin } from "node:path"; const DIR_NAME = "trilium-data"; const FOLDER_PERMISSIONS = 0o700; diff --git a/apps/server/src/services/encryption/my_scrypt.ts b/apps/server/src/services/encryption/my_scrypt.ts index d1bd9a5361..8db65d2810 100644 --- a/apps/server/src/services/encryption/my_scrypt.ts +++ b/apps/server/src/services/encryption/my_scrypt.ts @@ -1,63 +1,74 @@ -import optionService from "../options.js"; +/** + * Server-side scrypt service. + * + * Password-related functions (getVerificationHash, getPasswordDerivedKey, getScryptHash) + * have been moved to @triliumnext/core. Import them from there: + * + * import { scrypt } from "@triliumnext/core"; + * await scrypt.getVerificationHash(password); + * + * This file only contains OpenID-specific functions that use synchronous crypto + * and access the user_data table directly. + */ import crypto from "crypto"; import sql from "../sql.js"; -function getVerificationHash(password: crypto.BinaryLike) { - const salt = optionService.getOption("passwordVerificationSalt"); +const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1 }; - return getScryptHash(password, salt); -} - -function getPasswordDerivedKey(password: crypto.BinaryLike) { - const salt = optionService.getOption("passwordDerivedKeySalt"); - - return getScryptHash(password, salt); -} - -function getScryptHash(password: crypto.BinaryLike, salt: crypto.BinaryLike) { - const hashed = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); - - return hashed; +/** + * Sync scrypt hash for OpenID functions (server-only). + */ +function getScryptHashSync(password: crypto.BinaryLike, salt: crypto.BinaryLike): Buffer { + return crypto.scryptSync(password, salt, 32, SCRYPT_OPTIONS); } +/** + * Gets the verification hash for an OpenID subject identifier. + * Uses the salt from user_data table if not provided. + */ function getSubjectIdentifierVerificationHash( guessedUserId: string | crypto.BinaryLike, salt?: string -) { - if (salt != null) return getScryptHash(guessedUserId, salt); +): Buffer | undefined { + if (salt != null) return getScryptHashSync(guessedUserId, salt); const savedSalt = sql.getValue("SELECT salt FROM user_data;"); if (!savedSalt) { console.error("User salt undefined!"); return undefined; } - return getScryptHash(guessedUserId, savedSalt.toString()); + return getScryptHashSync(guessedUserId, savedSalt.toString()); } +/** + * Gets the derived key for an OpenID subject identifier. + * Uses the salt from user_data table if not provided. + */ function getSubjectIdentifierDerivedKey( subjectIdentifer: crypto.BinaryLike, givenSalt?: string -) { +): Buffer | undefined { if (givenSalt !== undefined) { - return getScryptHash(subjectIdentifer, givenSalt.toString()); + return getScryptHashSync(subjectIdentifer, givenSalt.toString()); } const salt = sql.getValue("SELECT salt FROM user_data;"); if (!salt) return undefined; - return getScryptHash(subjectIdentifer, salt.toString()); + return getScryptHashSync(subjectIdentifer, salt.toString()); } +/** + * Creates a derived key for an OpenID subject identifier with the given salt. + */ function createSubjectIdentifierDerivedKey( subjectIdentifer: string | crypto.BinaryLike, salt: string | crypto.BinaryLike -) { - return getScryptHash(subjectIdentifer, salt); +): Buffer { + return getScryptHashSync(subjectIdentifer, salt); } export default { - getVerificationHash, - getPasswordDerivedKey, getSubjectIdentifierVerificationHash, getSubjectIdentifierDerivedKey, createSubjectIdentifierDerivedKey diff --git a/apps/server/src/services/encryption/password.ts b/apps/server/src/services/encryption/password.ts index 7885a2a2b6..26f1b08e09 100644 --- a/apps/server/src/services/encryption/password.ts +++ b/apps/server/src/services/encryption/password.ts @@ -1,85 +1,5 @@ -import sql from "../sql.js"; -import optionService from "../options.js"; -import myScryptService from "./my_scrypt.js"; -import { randomSecureToken, toBase64 } from "../utils.js"; -import passwordEncryptionService from "./password_encryption.js"; -import { ChangePasswordResponse } from "@triliumnext/commons"; - -function isPasswordSet() { - return !!sql.getValue("SELECT value FROM options WHERE name = 'passwordVerificationHash'"); -} - -function changePassword(currentPassword: string, newPassword: string): ChangePasswordResponse { - if (!isPasswordSet()) { - throw new Error("Password has not been set yet, so it cannot be changed. Use 'setPassword' instead."); - } - - if (!passwordEncryptionService.verifyPassword(currentPassword)) { - return { - success: false, - message: "Given current password doesn't match hash" - }; - } - - sql.transactional(() => { - const decryptedDataKey = passwordEncryptionService.getDataKey(currentPassword); - - optionService.setOption("passwordVerificationSalt", randomSecureToken(32)); - optionService.setOption("passwordDerivedKeySalt", randomSecureToken(32)); - - const newPasswordVerificationKey = toBase64(myScryptService.getVerificationHash(newPassword)); - - if (decryptedDataKey) { - // TODO: what should happen if the decrypted data key is null? - passwordEncryptionService.setDataKey(newPassword, decryptedDataKey); - } - - optionService.setOption("passwordVerificationHash", newPasswordVerificationKey); - }); - - return { - success: true - }; -} - -function setPassword(password: string): ChangePasswordResponse { - if (isPasswordSet()) { - throw new Error("Password is set already. Either change it or perform 'reset password' first."); - } - - optionService.createOption("passwordVerificationSalt", randomSecureToken(32), true); - optionService.createOption("passwordDerivedKeySalt", randomSecureToken(32), true); - - const passwordVerificationKey = toBase64(myScryptService.getVerificationHash(password)); - optionService.createOption("passwordVerificationHash", passwordVerificationKey, true); - - // passwordEncryptionService expects these options to already exist - optionService.createOption("encryptedDataKey", "", true); - - passwordEncryptionService.setDataKey(password, randomSecureToken(16)); - - return { - success: true - }; -} - -function resetPassword() { - // user forgot the password, - sql.transactional(() => { - optionService.setOption("passwordVerificationSalt", ""); - optionService.setOption("passwordDerivedKeySalt", ""); - optionService.setOption("encryptedDataKey", ""); - optionService.setOption("passwordVerificationHash", ""); - }); - - return { - success: true - }; -} - -export default { - isPasswordSet, - changePassword, - setPassword, - resetPassword -}; +/** + * Re-exports the password service from core. + * changePassword and setPassword are now async - callers must use await. + */ +export { default } from "@triliumnext/core/src/services/encryption/password.js"; diff --git a/apps/server/src/services/encryption/password_encryption.ts b/apps/server/src/services/encryption/password_encryption.ts index 7c126f3260..3c2a1312bc 100644 --- a/apps/server/src/services/encryption/password_encryption.ts +++ b/apps/server/src/services/encryption/password_encryption.ts @@ -1,41 +1,5 @@ -import { data_encryption } from "@triliumnext/core"; - -import optionService from "../options.js"; -import { constantTimeCompare,toBase64 } from "../utils.js"; -import myScryptService from "./my_scrypt.js"; - -function verifyPassword(password: string) { - const givenPasswordHash = toBase64(myScryptService.getVerificationHash(password)); - - const dbPasswordHash = optionService.getOptionOrNull("passwordVerificationHash"); - - if (!dbPasswordHash) { - return false; - } - - return constantTimeCompare(givenPasswordHash, dbPasswordHash); -} - -function setDataKey(password: string, plainTextDataKey: string | Buffer | Uint8Array) { - const passwordDerivedKey = myScryptService.getPasswordDerivedKey(password); - - const newEncryptedDataKey = data_encryption.encrypt(passwordDerivedKey, plainTextDataKey); - - optionService.setOption("encryptedDataKey", newEncryptedDataKey); -} - -function getDataKey(password: string) { - const passwordDerivedKey = myScryptService.getPasswordDerivedKey(password); - - const encryptedDataKey = optionService.getOption("encryptedDataKey"); - - const decryptedDataKey = data_encryption.decrypt(passwordDerivedKey, encryptedDataKey); - - return decryptedDataKey; -} - -export default { - verifyPassword, - getDataKey, - setDataKey -}; +/** + * Re-exports the password encryption service from core. + * All functions are now async - callers must use await. + */ +export { default } from "@triliumnext/core/src/services/encryption/password_encryption.js"; diff --git a/apps/server/src/services/encryption/totp_encryption.ts b/apps/server/src/services/encryption/totp_encryption.ts index 1d1297dc52..dd78d98c59 100644 --- a/apps/server/src/services/encryption/totp_encryption.ts +++ b/apps/server/src/services/encryption/totp_encryption.ts @@ -1,9 +1,20 @@ +/** + * Server-side TOTP (Time-based One-Time Password) encryption service. + * + * This service handles encryption/decryption of TOTP secrets and remains + * server-only because: + * - TOTP/2FA is not supported in standalone mode + * - Uses synchronous Node.js crypto.scryptSync for performance + * + * The TOTP secret is encrypted using AES and stored in options. + * Verification uses scrypt-based hashing with constant-time comparison. + */ import type { OptionNames } from "@triliumnext/commons"; import { data_encryption } from "@triliumnext/core"; +import crypto from "crypto"; import optionService from "../options.js"; -import { constantTimeCompare,randomSecureToken, toBase64 } from "../utils.js"; -import myScryptService from "./my_scrypt.js"; +import { constantTimeCompare, randomSecureToken, toBase64 } from "../utils.js"; const TOTP_OPTIONS: Record = { SALT: "totpEncryptionSalt", @@ -11,8 +22,19 @@ const TOTP_OPTIONS: Record = { VERIFICATION_HASH: "totpVerificationHash" }; +const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1 }; + +/** + * Gets verification hash for TOTP secret using the password verification salt. + * This is server-only and uses sync scrypt. + */ +function getTotpVerificationHash(secret: string): Buffer { + const salt = optionService.getOption("passwordVerificationSalt"); + return crypto.scryptSync(secret, salt, 32, SCRYPT_OPTIONS); +} + function verifyTotpSecret(secret: string): boolean { - const givenSecretHash = toBase64(myScryptService.getVerificationHash(secret)); + const givenSecretHash = toBase64(getTotpVerificationHash(secret)); const dbSecretHash = optionService.getOptionOrNull(TOTP_OPTIONS.VERIFICATION_HASH); if (!dbSecretHash) { @@ -30,7 +52,7 @@ function setTotpSecret(secret: string) { const encryptionSalt = randomSecureToken(32); optionService.setOption(TOTP_OPTIONS.SALT, encryptionSalt); - const verificationHash = toBase64(myScryptService.getVerificationHash(secret)); + const verificationHash = toBase64(getTotpVerificationHash(secret)); optionService.setOption(TOTP_OPTIONS.VERIFICATION_HASH, verificationHash); const encryptedSecret = data_encryption.encrypt( diff --git a/apps/server/src/services/image.ts b/apps/server/src/services/image.ts index 743d40acdc..717f88fcab 100644 --- a/apps/server/src/services/image.ts +++ b/apps/server/src/services/image.ts @@ -1,191 +1,12 @@ -import { sanitize } from "@triliumnext/core"; -import imageType from "image-type"; -import isAnimated from "is-animated"; -import isSvg from "is-svg"; -import { Jimp } from "jimp"; -import sanitizeFilename from "sanitize-filename"; +/** + * Server-side image service. + * Re-exports core image service and adds OCR scheduling. + */ -import becca from "../becca/becca.js"; +import { imageService } from "@triliumnext/core"; import log from "./log.js"; -import noteService from "./notes.js"; import ocrService from "./ocr/ocr_service.js"; import optionService from "./options.js"; -import protectedSessionService from "./protected_session.js"; -import sql from "./sql.js"; - -async function processImage(uploadBuffer: Buffer, originalName: string, shrinkImageSwitch: boolean) { - const compressImages = optionService.getOptionBool("compressImages"); - const origImageFormat = await getImageType(uploadBuffer); - - if (!origImageFormat || !["jpg", "png"].includes(origImageFormat.ext)) { - shrinkImageSwitch = false; - } else if (isAnimated(uploadBuffer)) { - // recompression of animated images will make them static - shrinkImageSwitch = false; - } - - let finalImageBuffer; - let imageFormat; - - if (compressImages && shrinkImageSwitch) { - finalImageBuffer = await shrinkImage(uploadBuffer, originalName); - imageFormat = await getImageType(finalImageBuffer); - } else { - finalImageBuffer = uploadBuffer; - imageFormat = origImageFormat || { - ext: "dat" - }; - } - - return { - buffer: finalImageBuffer, - imageFormat - }; -} - -async function getImageType(buffer: Buffer) { - if (isSvg(buffer.toString())) { - return { ext: "svg" }; - } - return (await imageType(buffer)) || { ext: "jpg" }; // optimistic JPG default -} - -function getImageMimeFromExtension(ext: string) { - ext = ext.toLowerCase(); - - return `image/${ext === "svg" ? "svg+xml" : ext}`; -} - -function updateImage(noteId: string, uploadBuffer: Buffer, originalName: string) { - log.info(`Updating image ${noteId}: ${originalName}`); - - originalName = sanitize.sanitizeHtml(originalName); - - const note = becca.getNote(noteId); - if (!note) { - throw new Error("Unable to find note."); - } - - note.saveRevision(); - - note.setLabel("originalFileName", originalName); - - // resizing images asynchronously since JIMP does not support sync operation - processImage(uploadBuffer, originalName, true).then(({ buffer, imageFormat }) => { - sql.transactional(() => { - note.mime = getImageMimeFromExtension(imageFormat.ext); - note.save(); - - note.setContent(buffer); - }); - - scheduleOcrForNote(noteId); - }); -} - -function saveImage(parentNoteId: string, uploadBuffer: Buffer, originalName: string, shrinkImageSwitch: boolean, trimFilename = false) { - log.info(`Saving image ${originalName} into parent ${parentNoteId}`); - - if (trimFilename && originalName.length > 40) { - // https://github.com/zadam/trilium/issues/2307 - originalName = "image"; - } - - const fileName = sanitizeFilename(originalName); - const parentNote = becca.getNote(parentNoteId); - if (!parentNote) { - throw new Error("Unable to find parent note."); - } - - const { note } = noteService.createNewNote({ - parentNoteId, - title: fileName, - type: "image", - mime: "unknown", - content: "", - isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable() - }); - - note.addLabel("originalFileName", originalName); - - // resizing images asynchronously since JIMP does not support sync operation - processImage(uploadBuffer, originalName, shrinkImageSwitch).then(({ buffer, imageFormat }) => { - sql.transactional(() => { - note.mime = getImageMimeFromExtension(imageFormat.ext); - - if (!originalName.includes(".")) { - originalName += `.${imageFormat.ext}`; - - note.setLabel("originalFileName", originalName); - note.title = sanitizeFilename(originalName); - } - - note.setContent(buffer, { forceSave: true }); - }); - - scheduleOcrForNote(note.noteId); - }); - - return { - fileName, - note, - noteId: note.noteId, - url: `api/images/${note.noteId}/${encodeURIComponent(fileName)}` - }; -} - -function saveImageToAttachment(noteId: string, uploadBuffer: Buffer, originalName: string, shrinkImageSwitch?: boolean, trimFilename = false) { - log.info(`Saving image '${originalName}' as attachment into note '${noteId}'`); - - if (trimFilename && originalName.length > 40) { - // https://github.com/zadam/trilium/issues/2307 - originalName = "image"; - } - - const fileName = sanitizeFilename(originalName); - const note = becca.getNoteOrThrow(noteId); - - let attachment = note.saveAttachment({ - role: "image", - mime: "unknown", - title: fileName - }); - - // TODO: this is a quick-fix solution of a recursive bug - this is called from asyncPostProcessContent() - // find some async way to do this - perhaps some global timeout with a Set of noteIds needing one more - // run of asyncPostProcessContent - setTimeout(() => { - sql.transactional(() => { - const note = becca.getNoteOrThrow(noteId); - noteService.asyncPostProcessContent(note, note.getContent()); // to mark an unused attachment for deletion - }); - }, 5000); - - // resizing images asynchronously since JIMP does not support sync operation - const attachmentId = attachment.attachmentId; - processImage(uploadBuffer, originalName, !!shrinkImageSwitch).then(({ buffer, imageFormat }) => { - sql.transactional(() => { - // re-read, might be changed in the meantime - if (!attachmentId) { - throw new Error("Missing attachment ID."); - } - attachment = becca.getAttachmentOrThrow(attachmentId); - - attachment.mime = getImageMimeFromExtension(imageFormat.ext); - - if (!originalName.includes(".")) { - originalName += `.${imageFormat.ext}`; - attachment.title = sanitizeFilename(originalName); - } - - attachment.setContent(buffer, { forceSave: true }); - }); - - scheduleOcrForAttachment(attachmentId); - }); - - return attachment; -} function scheduleOcrForNote(noteId: string) { if (optionService.getOptionBool("ocrAutoProcessImages")) { @@ -211,52 +32,34 @@ function scheduleOcrForAttachment(attachmentId: string | undefined) { } } -async function shrinkImage(buffer: Buffer, originalName: string) { - let jpegQuality = optionService.getOptionInt("imageJpegQuality", 0); - - if (jpegQuality < 10 || jpegQuality > 100) { - jpegQuality = 75; - } - - let finalImageBuffer; - try { - finalImageBuffer = await resize(buffer, jpegQuality); - } catch (e: any) { - log.error(`Failed to resize image '${originalName}', stack: ${e.stack}`); - - finalImageBuffer = buffer; - } - - // if resizing did not help with size, then save the original - // (can happen when e.g., resizing PNG into JPEG) - if (finalImageBuffer.byteLength >= buffer.byteLength) { - finalImageBuffer = buffer; - } - - return finalImageBuffer; +// Re-export core functions with OCR scheduling wrappers +function saveImage( + parentNoteId: string, + uploadBuffer: Uint8Array, + originalName: string, + shrinkImageSwitch: boolean, + trimFilename = false +) { + const result = imageService.saveImage(parentNoteId, uploadBuffer, originalName, shrinkImageSwitch, trimFilename); + scheduleOcrForNote(result.noteId); + return result; } -async function resize(buffer: Buffer, quality: number) { - const imageMaxWidthHeight = optionService.getOptionInt("imageMaxWidthHeight"); +function saveImageToAttachment( + noteId: string, + uploadBuffer: Uint8Array, + originalName: string, + shrinkImageSwitch?: boolean, + trimFilename = false +) { + const result = imageService.saveImageToAttachment(noteId, uploadBuffer, originalName, shrinkImageSwitch, trimFilename); + scheduleOcrForAttachment(result.attachmentId); + return result; +} - const start = Date.now(); - - const image = await Jimp.read(buffer); - - if (image.bitmap.width > image.bitmap.height && image.bitmap.width > imageMaxWidthHeight) { - image.resize({ w: imageMaxWidthHeight }); - } else if (image.bitmap.height > imageMaxWidthHeight) { - image.resize({ h: imageMaxWidthHeight }); - } - - // when converting PNG to JPG, we lose the alpha channel, this is replaced by white to match Trilium white background - image.background = 0xffffffff; - - const resultBuffer = await image.getBuffer("image/jpeg", { quality }); - - log.info(`Resizing image of ${resultBuffer.byteLength} took ${Date.now() - start}ms`); - - return resultBuffer; +function updateImage(noteId: string, uploadBuffer: Uint8Array, originalName: string) { + imageService.updateImage(noteId, uploadBuffer, originalName); + scheduleOcrForNote(noteId); } export default { diff --git a/apps/server/src/services/image_provider.ts b/apps/server/src/services/image_provider.ts new file mode 100644 index 0000000000..3caf5fb4f1 --- /dev/null +++ b/apps/server/src/services/image_provider.ts @@ -0,0 +1,114 @@ +/** + * Server-side image provider implementation. + * Uses JIMP for image processing with full compression support. + */ + +import imageType from "image-type"; +import isAnimated from "is-animated"; +import isSvg from "is-svg"; +import { Jimp } from "jimp"; + +import type { ImageProvider, ImageFormat, ProcessedImage } from "@triliumnext/core/src/services/image_provider.js"; +import log from "./log.js"; +import optionService from "./options.js"; + +async function getImageTypeFromBuffer(buffer: Uint8Array): Promise { + // Check for SVG first (text-based) + if (isSvg(Buffer.from(buffer).toString())) { + return { ext: "svg", mime: "image/svg+xml" }; + } + + const detected = await imageType(buffer); + if (detected) { + return { ext: detected.ext, mime: detected.mime }; + } + + return null; +} + +async function shrinkImage(buffer: Uint8Array, originalName: string): Promise { + let jpegQuality = optionService.getOptionInt("imageJpegQuality", 0); + + if (jpegQuality < 10 || jpegQuality > 100) { + jpegQuality = 75; + } + + let finalImageBuffer: Uint8Array; + try { + finalImageBuffer = await resize(buffer, jpegQuality); + } catch (e: unknown) { + const error = e as Error; + log.error(`Failed to resize image '${originalName}', stack: ${error.stack}`); + finalImageBuffer = buffer; + } + + // If resizing did not help with size, then save the original + if (finalImageBuffer.byteLength >= buffer.byteLength) { + finalImageBuffer = buffer; + } + + return finalImageBuffer; +} + +async function resize(buffer: Uint8Array, quality: number): Promise { + const imageMaxWidthHeight = optionService.getOptionInt("imageMaxWidthHeight"); + + const start = Date.now(); + + const image = await Jimp.read(Buffer.from(buffer)); + + if (image.bitmap.width > image.bitmap.height && image.bitmap.width > imageMaxWidthHeight) { + image.resize({ w: imageMaxWidthHeight }); + } else if (image.bitmap.height > imageMaxWidthHeight) { + image.resize({ h: imageMaxWidthHeight }); + } + + // When converting PNG to JPG, we lose the alpha channel - replace with white + image.background = 0xffffffff; + + const resultBuffer = await image.getBuffer("image/jpeg", { quality }); + + log.info(`Resizing image of ${resultBuffer.byteLength} took ${Date.now() - start}ms`); + + return resultBuffer; +} + +export const serverImageProvider: ImageProvider = { + getImageType(buffer: Uint8Array): ImageFormat | null { + // Synchronous check for SVG + if (isSvg(Buffer.from(buffer).toString())) { + return { ext: "svg", mime: "image/svg+xml" }; + } + + // For other formats, we need async detection but interface is sync + // Return null and let processImage handle the async detection + return null; + }, + + async processImage(buffer: Uint8Array, originalName: string, shrink: boolean): Promise { + const compressImages = optionService.getOptionBool("compressImages"); + const origImageFormat = await getImageTypeFromBuffer(buffer); + + let shouldShrink = shrink; + + if (!origImageFormat || !["jpg", "png"].includes(origImageFormat.ext)) { + shouldShrink = false; + } else if (isAnimated(Buffer.from(buffer))) { + // Recompression of animated images will make them static + shouldShrink = false; + } + + let finalBuffer: Uint8Array; + let format: ImageFormat; + + if (compressImages && shouldShrink) { + finalBuffer = await shrinkImage(buffer, originalName); + format = (await getImageTypeFromBuffer(finalBuffer)) || { ext: "jpg", mime: "image/jpeg" }; + } else { + finalBuffer = buffer; + format = origImageFormat || { ext: "dat", mime: "application/octet-stream" }; + } + + return { buffer: finalBuffer, format }; + } +}; diff --git a/apps/server/src/services/log.ts b/apps/server/src/services/log.ts index 9abc79a500..570da8b7f2 100644 --- a/apps/server/src/services/log.ts +++ b/apps/server/src/services/log.ts @@ -1,150 +1,10 @@ -import { getLog } from "@triliumnext/core/src/services/log.js"; +import { getLog } from "@triliumnext/core"; import type { Request, Response } from "express"; -import fs from "fs"; -import { EOL } from "os"; -import path from "path"; +import ServerLogService from "../log_provider.js"; -import cls from "./cls.js"; -import config, { LOGGING_DEFAULT_RETENTION_DAYS } from "./config.js"; -import dataDir from "./data_dir.js"; - -fs.mkdirSync(dataDir.LOG_DIR, { recursive: true, mode: 0o700 }); - -let logFile: fs.WriteStream | undefined; - -const SECOND = 1000; -const MINUTE = 60 * SECOND; -const HOUR = 60 * MINUTE; -const DAY = 24 * HOUR; - -const MINIMUM_FILES_TO_KEEP = 7; - -let todaysMidnight!: Date; - -initLogFile(); - -function getTodaysMidnight() { - const now = new Date(); - - return new Date(now.getFullYear(), now.getMonth(), now.getDate()); -} - -async function cleanupOldLogFiles() { - try { - // Get retention days from environment or options - let retentionDays = LOGGING_DEFAULT_RETENTION_DAYS; - const customRetentionDays = config.Logging.retentionDays; - if (customRetentionDays > 0) { - retentionDays = customRetentionDays; - } else if (customRetentionDays <= -1){ - info(`Log cleanup: keeping all log files, as specified by configuration.`); - return; - } - - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - retentionDays); - - // Read all log files - const files = await fs.promises.readdir(dataDir.LOG_DIR); - const logFiles: Array<{name: string, mtime: Date, path: string}> = []; - - for (const file of files) { - // Security: Only process files matching our log pattern - if (!/^trilium-\d{4}-\d{2}-\d{2}\.log$/.test(file)) { - continue; - } - - const filePath = path.join(dataDir.LOG_DIR, file); - - // Security: Verify path stays within LOG_DIR - const resolvedPath = path.resolve(filePath); - const resolvedLogDir = path.resolve(dataDir.LOG_DIR); - if (!resolvedPath.startsWith(resolvedLogDir + path.sep)) { - continue; - } - - try { - const stats = await fs.promises.stat(filePath); - logFiles.push({ name: file, mtime: stats.mtime, path: filePath }); - } catch (err) { - // Skip files we can't stat - } - } - - // Sort by modification time (oldest first) - logFiles.sort((a, b) => a.mtime.getTime() - b.mtime.getTime()); - - // Keep minimum number of files - if (logFiles.length <= MINIMUM_FILES_TO_KEEP) { - return; - } - - // Delete old files, keeping minimum - let deletedCount = 0; - for (let i = 0; i < logFiles.length - MINIMUM_FILES_TO_KEEP; i++) { - const file = logFiles[i]; - if (file.mtime < cutoffDate) { - try { - await fs.promises.unlink(file.path); - deletedCount++; - } catch (err) { - // Log deletion failed, but continue with others - } - } - } - - if (deletedCount > 0) { - info(`Log cleanup: deleted ${deletedCount} old log files`); - } - } catch (err) { - // Cleanup failed, but don't crash the log rotation - } -} - -function initLogFile() { - todaysMidnight = getTodaysMidnight(); - - const logPath = `${dataDir.LOG_DIR}/trilium-${formatDate()}.log`; - const isRotating = !!logFile; - - if (isRotating) { - logFile!.end(); - } - - logFile = fs.createWriteStream(logPath, { flags: "a" }); - - // Clean up old log files when rotating to a new file - if (isRotating) { - cleanupOldLogFiles().catch(() => { - // Ignore cleanup errors - }); - } -} - -function checkDate(millisSinceMidnight: number) { - if (millisSinceMidnight >= DAY) { - initLogFile(); - - millisSinceMidnight -= DAY; - } - - return millisSinceMidnight; -} - -function log(str: string | Error) { - const bundleNoteId = cls.get("bundleNoteId"); - - if (bundleNoteId) { - str = `[Script ${bundleNoteId}] ${str}`; - } - - let millisSinceMidnight = Date.now() - todaysMidnight.getTime(); - - millisSinceMidnight = checkDate(millisSinceMidnight); - - logFile!.write(`${formatTime(millisSinceMidnight)} ${str}${EOL}`); - - console.log(str); +function getServerLog(): ServerLogService | undefined { + const log = getLog(); + return log instanceof ServerLogService ? log : undefined; } function info(message: string | Error) { @@ -155,44 +15,8 @@ function error(message: string | Error | unknown) { getLog().error(message); } -const requestBlacklist = ["/app", "/images", "/stylesheets", "/api/recent-notes"]; - function request(req: Request, res: Response, timeMs: number, responseLength: number | string = "?") { - for (const bl of requestBlacklist) { - if (req.url.startsWith(bl)) { - return; - } - } - - if (req.url.includes(".js.map") || req.url.includes(".css.map")) { - return; - } - - info(`${timeMs >= 10 ? "Slow " : "" }${res.statusCode} ${req.method} ${req.url} with ${responseLength} bytes took ${timeMs}ms`); -} - -function pad(num: number) { - num = Math.floor(num); - - return num < 10 ? `0${num}` : num.toString(); -} - -function padMilli(num: number) { - if (num < 10) { - return `00${num}`; - } else if (num < 100) { - return `0${num}`; - } - return num.toString(); - -} - -function formatTime(millisSinceMidnight: number) { - return `${pad(millisSinceMidnight / HOUR)}:${pad((millisSinceMidnight % HOUR) / MINUTE)}:${pad((millisSinceMidnight % MINUTE) / SECOND)}.${padMilli(millisSinceMidnight % SECOND)}`; -} - -function formatDate() { - return `${pad(todaysMidnight.getFullYear())}-${pad(todaysMidnight.getMonth() + 1)}-${pad(todaysMidnight.getDate())}`; + getServerLog()?.request(req, res, timeMs, responseLength); } export default { diff --git a/apps/server/src/services/sync_options.spec.ts b/apps/server/src/services/sync_options.spec.ts deleted file mode 100644 index 16d048b683..0000000000 --- a/apps/server/src/services/sync_options.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -// Mock the dependencies before importing the module -vi.mock("./config.js", () => ({ default: { Sync: {} } })); -vi.mock("./options.js", () => ({ default: { getOption: vi.fn() } })); - -import config from "./config.js"; -import optionService from "./options.js"; -import syncOptions from "./sync_options.js"; - -describe("syncOptions.getSyncTimeout", () => { - beforeEach(() => { - (config as any).Sync = {}; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("converts database value from seconds to milliseconds", () => { - // TimeSelector stores value in seconds (displayed value × scale) - // Scale is UI-only, not used in backend calculation - vi.mocked(optionService.getOption).mockReturnValue("120"); // 120 seconds = 2 minutes - expect(syncOptions.getSyncTimeout()).toBe(120000); - - vi.mocked(optionService.getOption).mockReturnValue("30"); // 30 seconds - expect(syncOptions.getSyncTimeout()).toBe(30000); - - vi.mocked(optionService.getOption).mockReturnValue("3600"); // 3600 seconds = 1 hour - expect(syncOptions.getSyncTimeout()).toBe(3600000); - }); - - it("treats config override as raw milliseconds for backward compatibility", () => { - (config as any).Sync = { syncServerTimeout: "60000" }; // 60 seconds in ms - - // Config value takes precedence, db value is ignored - vi.mocked(optionService.getOption).mockReturnValue("9999"); - expect(syncOptions.getSyncTimeout()).toBe(60000); - }); - - it("uses safe defaults for invalid values", () => { - vi.mocked(optionService.getOption).mockReturnValue(""); - expect(syncOptions.getSyncTimeout()).toBe(120000); // default 120 seconds - - (config as any).Sync = { syncServerTimeout: "invalid" }; - expect(syncOptions.getSyncTimeout()).toBe(120000); // fallback for invalid config - }); -}); diff --git a/apps/website/package.json b/apps/website/package.json index d7e31961e7..0f603e930e 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -9,7 +9,7 @@ "preview": "pnpm build && vite preview" }, "dependencies": { - "i18next": "26.0.3", + "i18next": "26.0.4", "preact": "10.29.1", "preact-iso": "2.11.1", "preact-render-to-string": "6.6.7", diff --git a/package.json b/package.json index ac25daf5b5..fdf87efdf5 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "overrides": { "@codemirror/language": "6.12.3", "@lezer/highlight": "1.2.3", - "@lezer/common": "1.5.1", + "@lezer/common": "1.5.2", "mermaid": "11.14.0", "preact": "10.29.1", "roughjs": "4.6.6", @@ -159,7 +159,7 @@ "handlebars@<4.7.9": ">=4.7.9", "qs@<6.14.2": ">=6.14.2", "minimatch@<3.1.4": "^3.1.4", - "minimatch@3>brace-expansion": "^5.0.0", + "minimatch@3>brace-expansion": "^1.1.13", "serialize-javascript@<7.0.5": ">=7.0.5", "webpack@<5.104.1": ">=5.104.1", "file-type@>=13.0.0 <21.3.1": ">=21.3.1", diff --git a/packages/trilium-core/package.json b/packages/trilium-core/package.json index d18d4ea165..b2e702af0f 100644 --- a/packages/trilium-core/package.json +++ b/packages/trilium-core/package.json @@ -13,7 +13,7 @@ "async-mutex": "0.5.0", "chardet": "2.1.1", "escape-html": "1.0.3", - "i18next": "26.0.3", + "i18next": "26.0.4", "mime-types": "3.0.2", "node-html-parser": "7.1.0", "sanitize-filename": "1.6.4", diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index d0551be776..e33f0484ec 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -1,6 +1,7 @@ import { ExecutionContext, initContext } from "./services/context"; import { CryptoProvider, initCrypto } from "./services/encryption/crypto"; -import { getLog, initLog } from "./services/log"; +import LogService, { getLog, initLog } from "./services/log"; +import BackupService, { initBackup, type BackupOptionsService } from "./services/backup"; import { initSql } from "./services/sql/index"; import { SqlService, SqlServiceParams } from "./services/sql/sql"; import { initMessaging, MessagingProvider } from "./services/messaging/index"; @@ -12,13 +13,19 @@ import { type PlatformProvider, initPlatform } from "./services/platform"; import { type ZipProvider, initZipProvider } from "./services/zip_provider"; import { type ZipExportProviderFactory, initZipExportProviderFactory } from "./services/export/zip_export_provider_factory"; import { type InAppHelpProvider, initInAppHelp } from "./services/in_app_help"; +import { type ImageProvider, initImageProvider } from "./services/image_provider"; -export { getLog } from "./services/log"; +export { default as LogService, getLog } from "./services/log"; +export { default as FileBasedLogService, type LogFileInfo } from "./services/file_based_log"; +export { default as BackupService, getBackup, initBackup, type BackupOptionsService } from "./services/backup"; export type * from "./services/sql/types"; export * from "./services/sql/index"; export { default as sql_init } from "./services/sql_init"; export * as protected_session from "./services/protected_session"; -export { default as data_encryption } from "./services/encryption/data_encryption" +export { default as data_encryption } from "./services/encryption/data_encryption"; +export { default as scrypt } from "./services/encryption/scrypt"; +export { default as password_encryption } from "./services/encryption/password_encryption"; +export { default as password } from "./services/encryption/password"; export * as binary_utils from "./services/utils/binary"; export * as utils from "./services/utils/index"; export * from "./services/build"; @@ -37,7 +44,7 @@ export * as cls from "./services/context"; export * as i18n from "./services/i18n"; export * from "./errors"; export { default as getInstanceId } from "./services/instance_id"; -export type { CryptoProvider } from "./services/encryption/crypto"; +export type { CryptoProvider, ScryptOptions, Cipher } from "./services/encryption/crypto"; export { default as note_types } from "./services/note_types"; export { default as tree } from "./services/tree"; export { default as cloning } from "./services/cloning"; @@ -98,6 +105,8 @@ export { default as sync_mutex } from "./services/sync_mutex"; export { default as setup } from "./services/setup"; export { getPlatform, type PlatformProvider } from "./services/platform"; export type { InAppHelpProvider } from "./services/in_app_help"; +export { type ImageProvider, type ImageFormat, type ProcessedImage, getImageProvider } from "./services/image_provider"; +export { default as imageService } from "./services/image"; export { t } from "i18next"; export type { RequestProvider, ExecOpts, CookieJar } from "./services/request"; export type * from "./meta"; @@ -121,7 +130,7 @@ export { default as scriptService } from "./services/script"; export { default as BackendScriptApi, type Api as BackendScriptApiInterface } from "./services/backend_script_api"; export * as scheduler from "./services/scheduler"; -export async function initializeCore({ dbConfig, executionContext, crypto, zip, zipExportProviderFactory, translations, messaging, request, schema, extraAppInfo, platform, getDemoArchive, inAppHelp }: { +export async function initializeCore({ dbConfig, executionContext, crypto, zip, zipExportProviderFactory, translations, messaging, request, schema, extraAppInfo, platform, getDemoArchive, inAppHelp, log, backup, image }: { dbConfig: SqlServiceParams, executionContext: ExecutionContext, crypto: CryptoProvider, @@ -138,9 +147,13 @@ export async function initializeCore({ dbConfig, executionContext, crypto, zip, nodeVersion: string; dataDirectory: string; }; + log?: LogService; + backup: BackupService; + image: ImageProvider; }) { initPlatform(platform); - initLog(); + initLog(log); + initBackup(backup); await initTranslations(translations); initCrypto(crypto); initZipProvider(zip); @@ -148,6 +161,7 @@ export async function initializeCore({ dbConfig, executionContext, crypto, zip, initContext(executionContext); initSql(new SqlService(dbConfig, getLog())); initSchema(schema); + initImageProvider(image); if (getDemoArchive) { initDemoArchive(getDemoArchive); } diff --git a/packages/trilium-core/src/routes/api/attachments.ts b/packages/trilium-core/src/routes/api/attachments.ts index c74dcce1f0..1bd926ed88 100644 --- a/packages/trilium-core/src/routes/api/attachments.ts +++ b/packages/trilium-core/src/routes/api/attachments.ts @@ -1,11 +1,14 @@ import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons"; import { ValidationError } from "../../errors"; import type { Request } from "express"; +import type { File } from "../../services/import/common.js"; +type FileRequest

= Omit, "file"> & { file?: File }; import becca from "../../becca/becca.js"; import blobService from "../../services/blob.js"; import imageService from "../../services/image.js"; +import { wrapStringOrBuffer } from "../../services/utils/binary.js"; function getAttachmentBlob(req: Request<{ attachmentId: string }>) { const preview = req.query.preview === "true"; @@ -44,9 +47,9 @@ function saveAttachment(req: Request<{ noteId: string }>) { note.saveAttachment({ attachmentId, role, mime, title, content }, matchBy); } -function uploadAttachment(req: Request<{ noteId: string }>) { +function uploadAttachment(req: FileRequest<{ noteId: string }>) { const { noteId } = req.params; - const { file } = req as any; // TODO: Add support for file upload in type definitions and remove 'as any' cast + const { file } = req; if (!file) { return { @@ -58,8 +61,11 @@ function uploadAttachment(req: Request<{ noteId: string }>) { const note = becca.getNoteOrThrow(noteId); let url; + // Convert buffer to Uint8Array (Buffer extends Uint8Array, string needs encoding) + const buffer = wrapStringOrBuffer(file.buffer as string | Uint8Array); + if (["image/png", "image/jpg", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"].includes(file.mimetype)) { - const attachment = imageService.saveImageToAttachment(noteId, file.buffer, file.originalname, true, true); + const attachment = imageService.saveImageToAttachment(noteId, buffer, file.originalname, true, true); url = `api/attachments/${attachment.attachmentId}/image/${encodeURIComponent(attachment.title)}`; } else { const attachment = note.saveAttachment({ diff --git a/packages/trilium-core/src/routes/api/backend_log.ts b/packages/trilium-core/src/routes/api/backend_log.ts new file mode 100644 index 0000000000..11bb07efd1 --- /dev/null +++ b/packages/trilium-core/src/routes/api/backend_log.ts @@ -0,0 +1,16 @@ +"use strict"; + +import { getLog } from "../../services/log.js"; +import { t } from "i18next"; + +function getBackendLog() { + const contents = getLog().getLogContents(); + if (contents === null) { + return t("backend_log.log-does-not-exist", { fileName: "current log" }); + } + return contents; +} + +export default { + getBackendLog +}; diff --git a/packages/trilium-core/src/routes/api/backup.ts b/packages/trilium-core/src/routes/api/backup.ts new file mode 100644 index 0000000000..b9839c380a --- /dev/null +++ b/packages/trilium-core/src/routes/api/backup.ts @@ -0,0 +1,38 @@ +import type { BackupDatabaseNowResponse, DatabaseBackup } from "@triliumnext/commons"; +import { getBackup } from "../../services/backup.js"; +import { Request, Response } from "express"; + +async function getExistingBackups(): Promise { + return getBackup().getExistingBackups(); +} + +async function backupDatabase(): Promise { + return { + backupFile: await getBackup().backupNow("now") + }; +} + +async function downloadBackup(req: Request, res: Response): Promise { + const filePath = req.query.filePath; + if (!filePath || typeof filePath !== "string") { + res.status(400).send("Missing or invalid filePath"); + return; + } + + const content = await getBackup().getBackupContent(filePath); + if (!content) { + res.status(404).send("Backup not found"); + return; + } + + const fileName = filePath.split("/").pop() || "backup.db"; + res.set("Content-Type", "application/x-sqlite3"); + res.set("Content-Disposition", `attachment; filename="${fileName}"`); + res.send(content); +} + +export default { + getExistingBackups, + backupDatabase, + downloadBackup +}; diff --git a/packages/trilium-core/src/routes/api/image.ts b/packages/trilium-core/src/routes/api/image.ts index 71c96c7ed8..48db824d7f 100644 --- a/packages/trilium-core/src/routes/api/image.ts +++ b/packages/trilium-core/src/routes/api/image.ts @@ -1,4 +1,7 @@ import type { Request, Response } from "express"; +import type { File } from "../../services/import/common.js"; + +type FileRequest

= Omit, "file"> & { file?: File }; import becca from "../../becca/becca.js"; import type BNote from "../../becca/entities/bnote.js"; @@ -103,9 +106,9 @@ function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Respon } } -function updateImage(req: Request<{ noteId: string }>) { +function updateImage(req: FileRequest<{ noteId: string }>) { const { noteId } = req.params; - const { file } = req as any; // TODO: Add support for file upload in type definitions and remove 'as any' cast + const { file } = req; const _note = becca.getNoteOrThrow(noteId); diff --git a/packages/trilium-core/src/routes/api/login.ts b/packages/trilium-core/src/routes/api/login.ts new file mode 100644 index 0000000000..e7fe0b95fb --- /dev/null +++ b/packages/trilium-core/src/routes/api/login.ts @@ -0,0 +1,52 @@ +import type { Request } from "express"; +import events from "../../services/events.js"; +import passwordEncryptionService from "../../services/encryption/password_encryption.js"; +import protectedSession from "../../services/protected_session.js"; +import ws from "../../services/ws.js"; + +async function loginToProtectedSession(req: Request) { + const password = req.body.password; + + if (!(await passwordEncryptionService.verifyPassword(password))) { + return { + success: false, + message: "Given current password doesn't match hash" + }; + } + + const decryptedDataKey = await passwordEncryptionService.getDataKey(password); + if (!decryptedDataKey) { + return { + success: false, + message: "Unable to obtain data key." + }; + } + + protectedSession.setDataKey(decryptedDataKey); + + events.emit(events.ENTER_PROTECTED_SESSION); + + ws.sendMessageToAllClients({ type: "protectedSessionLogin" }); + + return { + success: true + }; +} + +function logoutFromProtectedSession() { + protectedSession.resetDataKey(); + + events.emit(events.LEAVE_PROTECTED_SESSION); + + ws.sendMessageToAllClients({ type: "protectedSessionLogout" }); +} + +function touchProtectedSession() { + protectedSession.touchProtectedSession(); +} + +export default { + loginToProtectedSession, + logoutFromProtectedSession, + touchProtectedSession +}; diff --git a/packages/trilium-core/src/routes/api/password.ts b/packages/trilium-core/src/routes/api/password.ts new file mode 100644 index 0000000000..9ce57a66ca --- /dev/null +++ b/packages/trilium-core/src/routes/api/password.ts @@ -0,0 +1,25 @@ +import type { ChangePasswordResponse } from "@triliumnext/commons"; +import type { Request } from "express"; +import passwordService from "../../services/encryption/password.js"; +import { ValidationError } from "../../errors.js"; + +async function changePassword(req: Request): Promise { + if (passwordService.isPasswordSet()) { + return await passwordService.changePassword(req.body.current_password, req.body.new_password); + } + return await passwordService.setPassword(req.body.new_password); +} + +function resetPassword(req: Request) { + // protection against accidental call (not a security measure) + if (req.query.really !== "yesIReallyWantToResetPasswordAndLoseAccessToMyProtectedNotes") { + throw new ValidationError("Incorrect password reset confirmation"); + } + + return passwordService.resetPassword(); +} + +export default { + changePassword, + resetPassword +}; diff --git a/packages/trilium-core/src/routes/index.ts b/packages/trilium-core/src/routes/index.ts index 2c42f3d1cd..107add4acd 100644 --- a/packages/trilium-core/src/routes/index.ts +++ b/packages/trilium-core/src/routes/index.ts @@ -28,6 +28,10 @@ import filesRoute from "./api/files"; import importRoute from "./api/import"; import exportRoute from "./api/export"; import scriptRoute from "./api/script"; +import backendLogRoute from "./api/backend_log"; +import backupRoute from "./api/backup"; +import passwordApiRoute from "./api/password"; +import loginApiRoute from "./api/login"; // TODO: Deduplicate with routes.ts const GET = "get", @@ -121,6 +125,8 @@ export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRout route(GET, "/api/revisions/:revisionId/image/:filename", [checkApiAuthOrElectron], imageRoute.returnImageFromRevision); route(GET, "/api/attachments/:attachmentId/image/:filename", [checkApiAuthOrElectron], imageRoute.returnAttachedImage); route(GET, "/api/images/:noteId/:filename", [checkApiAuthOrElectron], imageRoute.returnImageFromNote); + route(PUT, "/api/images/:noteId", [checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], imageRoute.updateImage, apiResultHandler); + route(PST, "/api/notes/:noteId/attachments/upload", [checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], attachmentsApiRoute.uploadAttachment, apiResultHandler); // group of the services below are meant to be executed from the outside route(GET, "/api/setup/status", [], setupApiRoute.getStatus, apiResultHandler); @@ -198,6 +204,12 @@ export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRout apiRoute(PST, "/api/bulk-action/affected-notes", bulkActionRoute.getAffectedNoteCount); apiRoute(GET, "/api/app-info", appInfoRoute.getAppInfo); + asyncApiRoute(GET, "/api/backend-log", backendLogRoute.getBackendLog); + + // Backup routes + asyncApiRoute(GET, "/api/database/backups", backupRoute.getExistingBackups); + asyncApiRoute(PST, "/api/database/backup-database", backupRoute.backupDatabase); + asyncRoute(GET, "/api/database/backup/download", [checkApiAuthOrElectron], backupRoute.downloadBackup); apiRoute(GET, "/api/other/icon-usage", otherRoute.getIconUsage); apiRoute(PST, "/api/other/render-markdown", otherRoute.renderMarkdown); @@ -227,6 +239,15 @@ export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRout apiRoute(PST, "/api/script/bundle/:noteId", scriptRoute.getBundle); apiRoute(GET, "/api/script/relation/:noteId/:relationName", scriptRoute.getRelationBundles); //#endregion + + //#region Password and protected session + asyncApiRoute(PST, "/api/password/change", passwordApiRoute.changePassword); + apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword); + + asyncApiRoute(PST, "/api/login/protected", loginApiRoute.loginToProtectedSession); + apiRoute(PST, "/api/login/protected/touch", loginApiRoute.touchProtectedSession); + apiRoute(PST, "/api/logout/protected", loginApiRoute.logoutFromProtectedSession); + //#endregion } /** Handling common patterns. If entity is not caught, serialization to JSON will fail */ diff --git a/packages/trilium-core/src/services/backend_script_api.ts b/packages/trilium-core/src/services/backend_script_api.ts index e1c0a7a494..e233cb2c99 100644 --- a/packages/trilium-core/src/services/backend_script_api.ts +++ b/packages/trilium-core/src/services/backend_script_api.ts @@ -20,7 +20,7 @@ import type BRevision from "../becca/entities/brevision.js"; import appInfo from "./app_info.js"; import attributeService from "./attributes.js"; import type { ApiParams } from "./backend_script_api_interface.js"; -import backupService from "./backup.js"; +import { getBackup } from "./backup.js"; import cloningService from "./cloning.js"; import config from "./config.js"; import dateNoteService from "./date_notes.js"; @@ -717,7 +717,7 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) { }; this.runOutsideOfSync = syncMutex.doExclusively; - this.backupNow = backupService.backupNow; + this.backupNow = (name: string) => getBackup().backupNow(name); this.duplicateSubtree = noteService.duplicateSubtree; this.__private = { diff --git a/packages/trilium-core/src/services/backup.ts b/packages/trilium-core/src/services/backup.ts index 7726a9418c..d1f8dea223 100644 --- a/packages/trilium-core/src/services/backup.ts +++ b/packages/trilium-core/src/services/backup.ts @@ -1,6 +1,110 @@ -export default { - async backupNow(name: string) { - console.warn("Backup not yet available."); - return "backup-" + name + "-" + new Date().toISOString() + ".zip"; +import type { DatabaseBackup, FilterOptionsByType, OptionNames } from "@triliumnext/commons"; +import { getContext } from "./context.js"; +import dateUtils from "./utils/date.js"; + +type BackupType = "daily" | "weekly" | "monthly"; + +export interface BackupOptionsService { + getOption(name: OptionNames): string; + getOptionBool(name: FilterOptionsByType): boolean; + setOption(name: OptionNames, value: string): void; +} + +/** + * Abstract backup service class. + * Platform-specific implementations must extend this class. + */ +export default abstract class BackupService { + constructor(protected readonly options: BackupOptionsService) {} + + /** + * Create a backup with the given name. + * Returns the backup file path/name. + */ + abstract backupNow(name: string): Promise; + + /** + * Perform regular scheduled backups (daily, weekly, monthly). + * Called periodically by the scheduler. + * Default implementation runs inside an execution context. + */ + regularBackup(): void { + getContext().init(() => { + this.runScheduledBackups().catch(err => { + console.error("[Backup] Error running scheduled backups:", err); + }); + }); + } + + /** + * Get list of existing backups. + */ + abstract getExistingBackups(): Promise; + + /** + * Get the content of a backup file. + * Returns null if the backup doesn't exist or access is denied. + */ + abstract getBackupContent(filePath: string): Promise; + + /** + * Run the scheduled backup checks for daily, weekly, and monthly backups. + */ + protected async runScheduledBackups(): Promise { + await this.periodBackup("lastDailyBackupDate", "daily", 24 * 3600); + await this.periodBackup("lastWeeklyBackupDate", "weekly", 7 * 24 * 3600); + await this.periodBackup("lastMonthlyBackupDate", "monthly", 30 * 24 * 3600); + } + + /** + * Check if a specific backup type is enabled via options. + */ + protected isBackupEnabled(backupType: BackupType): boolean { + const optionName: FilterOptionsByType = + backupType === "daily" ? "dailyBackupEnabled" : + backupType === "weekly" ? "weeklyBackupEnabled" : + "monthlyBackupEnabled"; + + return this.options.getOptionBool(optionName); + } + + /** + * Check if a periodic backup is due and create it if so. + */ + protected async periodBackup( + optionName: "lastDailyBackupDate" | "lastWeeklyBackupDate" | "lastMonthlyBackupDate", + backupType: BackupType, + periodInSeconds: number + ): Promise { + if (!this.isBackupEnabled(backupType)) { + return; + } + + const now = new Date(); + const lastBackupDate = dateUtils.parseDateTime(this.options.getOption(optionName)); + + if (now.getTime() - lastBackupDate.getTime() > periodInSeconds * 1000) { + await this.backupNow(backupType); + this.options.setOption(optionName, dateUtils.utcNowDateTime()); + } } } + +let backupService: BackupService | undefined; + +/** + * Get the current backup service instance. + */ +export function getBackup(): BackupService { + if (!backupService) { + throw new Error("Backup service not initialized. Call initBackup() first."); + } + return backupService; +} + +/** + * Initialize the backup service with a platform-specific provider. + */ +export function initBackup(provider: BackupService): void { + backupService = provider; +} diff --git a/packages/trilium-core/src/services/encryption/crypto.ts b/packages/trilium-core/src/services/encryption/crypto.ts index 0f3c54dddb..809c131acf 100644 --- a/packages/trilium-core/src/services/encryption/crypto.ts +++ b/packages/trilium-core/src/services/encryption/crypto.ts @@ -1,10 +1,18 @@ -interface Cipher { +export interface Cipher { update(data: Uint8Array): Uint8Array; final(): Uint8Array; } -export interface CryptoProvider { +export interface ScryptOptions { + /** CPU/memory cost parameter (default: 16384) */ + N?: number; + /** Block size (default: 8) */ + r?: number; + /** Parallelization (default: 1) */ + p?: number; +} +export interface CryptoProvider { createHash(algorithm: "md5" | "sha1" | "sha512", content: string | Uint8Array): Uint8Array; randomBytes(size: number): Uint8Array; randomString(length: number): string; @@ -12,6 +20,25 @@ export interface CryptoProvider { createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher; hmac(secret: string | Uint8Array, value: string | Uint8Array): string; + /** + * Derives a key from a password using the scrypt algorithm. + * @param password - The password to derive from + * @param salt - The salt to use + * @param keyLength - The length of the derived key in bytes + * @param options - Scrypt parameters (N, r, p) + */ + scrypt( + password: Uint8Array | string, + salt: Uint8Array | string, + keyLength: number, + options?: ScryptOptions + ): Promise; + + /** + * Constant-time comparison of two byte arrays to prevent timing attacks. + * @returns true if arrays are equal, false otherwise + */ + constantTimeCompare(a: Uint8Array, b: Uint8Array): boolean; } let crypto: CryptoProvider | null = null; diff --git a/packages/trilium-core/src/services/encryption/data_encryption.ts b/packages/trilium-core/src/services/encryption/data_encryption.ts index ffae2c1e9d..bacaa2c420 100644 --- a/packages/trilium-core/src/services/encryption/data_encryption.ts +++ b/packages/trilium-core/src/services/encryption/data_encryption.ts @@ -33,7 +33,7 @@ function encrypt(key: Uint8Array, plainText: Uint8Array | string) { throw new Error("No data key!"); } - const plainTextUint8Array = ArrayBuffer.isView(plainText) ? plainText : Uint8Array.from(plainText); + const plainTextUint8Array = ArrayBuffer.isView(plainText) ? plainText : encodeUtf8(plainText); const iv = getCrypto().randomBytes(16); const cipher = getCrypto().createCipheriv("aes-128-cbc", pad(key), pad(iv)); @@ -88,7 +88,7 @@ function decrypt(key: Uint8Array, cipherText: string | Uint8Array): Uint8Array | if (e.message?.includes("WRONG_FINAL_BLOCK_LENGTH") || e.message?.includes("wrong final block length")) { getLog().info("Caught WRONG_FINAL_BLOCK_LENGTH, returning cipherText instead"); - return (ArrayBuffer.isView(cipherText) ? cipherText : Uint8Array.from(cipherText)); + return (ArrayBuffer.isView(cipherText) ? cipherText : encodeUtf8(cipherText)); } throw e; } diff --git a/packages/trilium-core/src/services/encryption/password.ts b/packages/trilium-core/src/services/encryption/password.ts new file mode 100644 index 0000000000..97d19518f7 --- /dev/null +++ b/packages/trilium-core/src/services/encryption/password.ts @@ -0,0 +1,120 @@ +import type { ChangePasswordResponse } from "@triliumnext/commons"; +import options from "../options.js"; +import { getSql } from "../sql/index.js"; +import scryptService from "./scrypt.js"; +import passwordEncryptionService from "./password_encryption.js"; +import { encodeBase64 } from "../utils/binary.js"; +import { getCrypto } from "./crypto.js"; + +/** + * Generates a random secure token encoded as base64. + * @param bytes - Number of random bytes to generate + */ +function randomSecureToken(bytes: number): string { + return encodeBase64(getCrypto().randomBytes(bytes)); +} + +/** + * Checks if a password has been set. + */ +export function isPasswordSet(): boolean { + const sql = getSql(); + return !!sql.getValue("SELECT value FROM options WHERE name = 'passwordVerificationHash'"); +} + +/** + * Changes the password from currentPassword to newPassword. + * Re-encrypts the data key with the new password. + */ +export async function changePassword( + currentPassword: string, + newPassword: string +): Promise { + if (!isPasswordSet()) { + throw new Error("Password has not been set yet, so it cannot be changed. Use 'setPassword' instead."); + } + + if (!(await passwordEncryptionService.verifyPassword(currentPassword))) { + return { + success: false, + message: "Given current password doesn't match hash" + }; + } + + const sql = getSql(); + const decryptedDataKey = await passwordEncryptionService.getDataKey(currentPassword); + + sql.transactional(() => { + options.setOption("passwordVerificationSalt", randomSecureToken(32)); + options.setOption("passwordDerivedKeySalt", randomSecureToken(32)); + }); + + const newPasswordVerificationKey = encodeBase64( + await scryptService.getVerificationHash(newPassword) + ); + + if (decryptedDataKey) { + await passwordEncryptionService.setDataKey(newPassword, decryptedDataKey); + } + + options.setOption("passwordVerificationHash", newPasswordVerificationKey); + + return { + success: true + }; +} + +/** + * Sets the initial password for a new installation. + * Creates all necessary password-related options. + */ +export async function setPassword(password: string): Promise { + if (isPasswordSet()) { + throw new Error("Password is set already. Either change it or perform 'reset password' first."); + } + + options.createOption("passwordVerificationSalt", randomSecureToken(32), true); + options.createOption("passwordDerivedKeySalt", randomSecureToken(32), true); + + const passwordVerificationKey = encodeBase64( + await scryptService.getVerificationHash(password) + ); + options.createOption("passwordVerificationHash", passwordVerificationKey, true); + + // passwordEncryptionService expects these options to already exist + options.createOption("encryptedDataKey", "", true); + + // Generate a random 16-byte data key and encrypt it with the password + const randomDataKey = getCrypto().randomBytes(16); + await passwordEncryptionService.setDataKey(password, randomDataKey); + + return { + success: true + }; +} + +/** + * Resets the password by clearing all password-related options. + * This should be used when the user has forgotten their password. + * WARNING: This will make all protected notes inaccessible. + */ +export function resetPassword(): ChangePasswordResponse { + const sql = getSql(); + sql.transactional(() => { + options.setOption("passwordVerificationSalt", ""); + options.setOption("passwordDerivedKeySalt", ""); + options.setOption("encryptedDataKey", ""); + options.setOption("passwordVerificationHash", ""); + }); + + return { + success: true + }; +} + +export default { + isPasswordSet, + changePassword, + setPassword, + resetPassword +}; diff --git a/packages/trilium-core/src/services/encryption/password_encryption.ts b/packages/trilium-core/src/services/encryption/password_encryption.ts new file mode 100644 index 0000000000..9b9062e8d8 --- /dev/null +++ b/packages/trilium-core/src/services/encryption/password_encryption.ts @@ -0,0 +1,51 @@ +import data_encryption from "./data_encryption.js"; +import scryptService from "./scrypt.js"; +import options from "../options.js"; +import { getCrypto } from "./crypto.js"; +import { encodeBase64 } from "../utils/binary.js"; + +/** + * Verifies a password against the stored hash. + * Uses constant-time comparison to prevent timing attacks. + */ +export async function verifyPassword(password: string): Promise { + const givenPasswordHash = encodeBase64(await scryptService.getVerificationHash(password)); + const dbPasswordHash = options.getOptionOrNull("passwordVerificationHash"); + + if (!dbPasswordHash) { + return false; + } + + // Use constant-time comparison to prevent timing attacks + const givenBytes = new TextEncoder().encode(givenPasswordHash); + const dbBytes = new TextEncoder().encode(dbPasswordHash); + + return getCrypto().constantTimeCompare(givenBytes, dbBytes); +} + +/** + * Encrypts and stores the data key using the password-derived key. + */ +export async function setDataKey( + password: string, + plainTextDataKey: string | Uint8Array +): Promise { + const passwordDerivedKey = await scryptService.getPasswordDerivedKey(password); + const newEncryptedDataKey = data_encryption.encrypt(passwordDerivedKey, plainTextDataKey); + options.setOption("encryptedDataKey", newEncryptedDataKey); +} + +/** + * Decrypts and returns the data key using the password-derived key. + */ +export async function getDataKey(password: string): Promise { + const passwordDerivedKey = await scryptService.getPasswordDerivedKey(password); + const encryptedDataKey = options.getOption("encryptedDataKey"); + return data_encryption.decrypt(passwordDerivedKey, encryptedDataKey); +} + +export default { + verifyPassword, + getDataKey, + setDataKey +}; diff --git a/packages/trilium-core/src/services/encryption/scrypt.ts b/packages/trilium-core/src/services/encryption/scrypt.ts new file mode 100644 index 0000000000..127840bb11 --- /dev/null +++ b/packages/trilium-core/src/services/encryption/scrypt.ts @@ -0,0 +1,41 @@ +import options from "../options.js"; +import { getCrypto } from "./crypto.js"; + +const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1 }; + +/** + * Gets the password verification hash using scrypt. + * Uses the passwordVerificationSalt option as salt. + */ +export async function getVerificationHash(password: string): Promise { + const salt = options.getOption("passwordVerificationSalt"); + return getScryptHash(password, salt); +} + +/** + * Gets the password-derived encryption key using scrypt. + * Uses the passwordDerivedKeySalt option as salt. + */ +export async function getPasswordDerivedKey(password: string): Promise { + const salt = options.getOption("passwordDerivedKeySalt"); + return getScryptHash(password, salt); +} + +/** + * Computes a scrypt hash with standard parameters. + * @param password - The password to hash + * @param salt - The salt to use + * @returns 32-byte derived key + */ +export async function getScryptHash( + password: string, + salt: string +): Promise { + return getCrypto().scrypt(password, salt, 32, SCRYPT_OPTIONS); +} + +export default { + getVerificationHash, + getPasswordDerivedKey, + getScryptHash +}; diff --git a/packages/trilium-core/src/services/file_based_log.ts b/packages/trilium-core/src/services/file_based_log.ts new file mode 100644 index 0000000000..8440a27836 --- /dev/null +++ b/packages/trilium-core/src/services/file_based_log.ts @@ -0,0 +1,212 @@ +import LogService from "./log.js"; +import { getContext } from "./context.js"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +const MINIMUM_FILES_TO_KEEP = 7; +const DEFAULT_RETENTION_DAYS = 7; + +export interface LogFileInfo { + name: string; + mtime: Date; +} + +/** + * Abstract base class for file-based logging implementations. + * Provides shared logic for log rotation, cleanup, and formatting. + * Platform-specific implementations (Node.js fs, OPFS) extend this class. + */ +export default abstract class FileBasedLogService extends LogService { + protected todaysMidnight!: Date; + private isInitialized = false; + + constructor() { + super(); + } + + /** + * Initialize the log service. Must be called before logging. + * Separated from constructor to allow async initialization in some platforms. + * For sync platforms (Node.js), call the methods directly in the constructor. + */ + async initialize(): Promise { + if (this.isInitialized) return; + await this.ensureLogDirectory(); + this.todaysMidnight = this.getTodaysMidnight(); + await this.openLogFile(this.getLogFileName()); + this.isInitialized = true; + } + + // ==================== Abstract Methods ==================== + + /** Line ending character(s) for this platform */ + protected abstract get eol(): string; + + /** Ensure the log directory exists */ + protected abstract ensureLogDirectory(): Promise | void; + + /** Open a log file for appending */ + protected abstract openLogFile(fileName: string): Promise | void; + + /** Close the current log file */ + protected abstract closeLogFile(): Promise | void; + + /** Write an entry to the current log file */ + protected abstract writeEntry(entry: string): void; + + /** Read the contents of a log file */ + protected abstract readLogFile(fileName: string): string | null; + + /** List all log files with their modification times */ + protected abstract listLogFiles(): Promise; + + /** Delete a log file by name */ + protected abstract deleteLogFile(fileName: string): Promise; + + /** Get the configured retention days (-1 = keep all, 0 = use default) */ + protected abstract getRetentionDays(): number; + + // ==================== Shared Implementation ==================== + + protected getTodaysMidnight(): Date { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate()); + } + + protected getLogFileName(): string { + return `trilium-${this.formatDate()}.log`; + } + + protected async rotateLogFile(): Promise { + await this.closeLogFile(); + this.todaysMidnight = this.getTodaysMidnight(); + await this.openLogFile(this.getLogFileName()); + + // Trigger cleanup asynchronously + this.cleanupOldLogFiles().catch(() => { + // Ignore cleanup errors + }); + } + + protected checkDateAndRotate(millisSinceMidnight: number): number { + if (millisSinceMidnight >= DAY) { + // Trigger rotation asynchronously to avoid blocking + this.rotateLogFile().catch(() => {}); + return millisSinceMidnight - DAY; + } + return millisSinceMidnight; + } + + protected async cleanupOldLogFiles(): Promise { + try { + let retentionDays = this.getRetentionDays(); + + if (retentionDays <= -1) { + this.info("Log cleanup: keeping all log files, as specified by configuration."); + return; + } + + if (retentionDays === 0) { + retentionDays = DEFAULT_RETENTION_DAYS; + } + + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + + const logFiles = await this.listLogFiles(); + + // Sort by modification time (oldest first) + logFiles.sort((a, b) => a.mtime.getTime() - b.mtime.getTime()); + + if (logFiles.length <= MINIMUM_FILES_TO_KEEP) { + return; + } + + let deletedCount = 0; + for (let i = 0; i < logFiles.length - MINIMUM_FILES_TO_KEEP; i++) { + const file = logFiles[i]; + if (file.mtime < cutoffDate) { + try { + await this.deleteLogFile(file.name); + deletedCount++; + } catch { + // Log deletion failed, but continue with others + } + } + } + + if (deletedCount > 0) { + this.info(`Log cleanup: deleted ${deletedCount} old log files`); + } + } catch { + // Cleanup failed, but don't crash + } + } + + protected getScriptContext(): string | undefined { + try { + return getContext().get("bundleNoteId"); + } catch { + // Context not initialized yet + return undefined; + } + } + + override log(message: string | Error): void { + const bundleNoteId = this.getScriptContext(); + let str = String(message); + + if (bundleNoteId) { + str = `[Script ${bundleNoteId}] ${str}`; + } + + let millisSinceMidnight = Date.now() - this.todaysMidnight.getTime(); + millisSinceMidnight = this.checkDateAndRotate(millisSinceMidnight); + + const entry = `${this.formatTime(millisSinceMidnight)} ${str}${this.eol}`; + this.writeEntry(entry); + console.log(str); + } + + override info(message: string | Error): void { + this.log(message); + } + + override error(message: string | Error | unknown): void { + const str = message instanceof Error + ? message.stack || message.message + : String(message); + this.log(`ERROR: ${str}`); + } + + override getLogContents(): string | null { + return this.readLogFile(this.getLogFileName()); + } + + // ==================== Formatting Helpers ==================== + + protected pad(num: number): string { + num = Math.floor(num); + return num < 10 ? `0${num}` : num.toString(); + } + + protected padMilli(num: number): string { + if (num < 10) { + return `00${num}`; + } else if (num < 100) { + return `0${num}`; + } + return num.toString(); + } + + protected formatTime(millisSinceMidnight: number): string { + return `${this.pad(millisSinceMidnight / HOUR)}:${this.pad((millisSinceMidnight % HOUR) / MINUTE)}:${this.pad((millisSinceMidnight % MINUTE) / SECOND)}.${this.padMilli(millisSinceMidnight % SECOND)}`; + } + + protected formatDate(): string { + return `${this.pad(this.todaysMidnight.getFullYear())}-${this.pad(this.todaysMidnight.getMonth() + 1)}-${this.pad(this.todaysMidnight.getDate())}`; + } +} diff --git a/packages/trilium-core/src/services/image.ts b/packages/trilium-core/src/services/image.ts index 7ccd1ff5ad..e513c44598 100644 --- a/packages/trilium-core/src/services/image.ts +++ b/packages/trilium-core/src/services/image.ts @@ -1,22 +1,163 @@ -export default { - saveImageToAttachment(noteId: string, imageBuffer: Uint8Array, title: string, b1?: boolean, b2?: boolean) { - console.warn("Image save ignored", noteId, title); +/** + * Image service for saving and updating images. + * Uses ImageProvider for platform-specific processing (compression, format detection). + */ - return { - attachmentId: null, - title: "" - }; - }, +import sanitizeFilename from "sanitize-filename"; - updateImage(noteId: string, imageBuffer: Uint8Array, title: string) { - console.warn("Image update ignored", noteId, title); - }, +import becca from "../becca/becca.js"; +import { getContext } from "./context.js"; +import { getLog } from "./log.js"; +import { getImageProvider } from "./image_provider.js"; +import noteService from "./notes.js"; +import protectedSessionService from "./protected_session.js"; +import { getSql } from "./sql/index.js"; +import { sanitizeHtml } from "./sanitizer.js"; - saveImage(noteId: string, imageBuffer: Uint8Array, title: string, b1?: boolean, b2?: boolean) { - console.warn("Image save ignored", noteId, title); - - return { - note: null - }; - } +function getImageMimeFromExtension(ext: string): string { + ext = ext.toLowerCase(); + return `image/${ext === "svg" ? "svg+xml" : ext}`; } + +function updateImage(noteId: string, uploadBuffer: Uint8Array, originalName: string): void { + getLog().info(`Updating image ${noteId}: ${originalName}`); + + originalName = sanitizeHtml(originalName); + + const note = becca.getNote(noteId); + if (!note) { + throw new Error("Unable to find note."); + } + + note.saveRevision(); + note.setLabel("originalFileName", originalName); + + // Process image asynchronously + getImageProvider().processImage(uploadBuffer, originalName, true).then(({ buffer, format }) => { + getContext().init(() => { + getSql().transactional(() => { + note.mime = getImageMimeFromExtension(format.ext); + note.save(); + note.setContent(buffer); + }); + }); + }); +} + +function saveImage( + parentNoteId: string, + uploadBuffer: Uint8Array, + originalName: string, + shrinkImageSwitch: boolean, + trimFilename = false +): { fileName: string; note: ReturnType["note"]; noteId: string; url: string } { + getLog().info(`Saving image ${originalName} into parent ${parentNoteId}`); + + if (trimFilename && originalName.length > 40) { + originalName = "image"; + } + + const fileName = sanitizeFilename(originalName); + const parentNote = becca.getNote(parentNoteId); + if (!parentNote) { + throw new Error("Unable to find parent note."); + } + + const { note } = noteService.createNewNote({ + parentNoteId, + title: fileName, + type: "image", + mime: "unknown", + content: "", + isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable() + }); + + note.addLabel("originalFileName", originalName); + + // Process image asynchronously + getImageProvider().processImage(uploadBuffer, originalName, shrinkImageSwitch).then(({ buffer, format }) => { + getContext().init(() => { + getSql().transactional(() => { + note.mime = getImageMimeFromExtension(format.ext); + + if (!originalName.includes(".")) { + originalName += `.${format.ext}`; + note.setLabel("originalFileName", originalName); + note.title = sanitizeFilename(originalName); + } + + note.setContent(buffer, { forceSave: true }); + }); + }); + }); + + return { + fileName, + note, + noteId: note.noteId, + url: `api/images/${note.noteId}/${encodeURIComponent(fileName)}` + }; +} + +function saveImageToAttachment( + noteId: string, + uploadBuffer: Uint8Array, + originalName: string, + shrinkImageSwitch?: boolean, + trimFilename = false +): { attachmentId: string | undefined; title: string } { + getLog().info(`Saving image '${originalName}' as attachment into note '${noteId}'`); + + if (trimFilename && originalName.length > 40) { + originalName = "image"; + } + + const fileName = sanitizeFilename(originalName); + const note = becca.getNoteOrThrow(noteId); + + let attachment = note.saveAttachment({ + role: "image", + mime: "unknown", + title: fileName + }); + + // Schedule post-processing to mark unused attachments + setTimeout(() => { + getContext().init(() => { + getSql().transactional(() => { + const note = becca.getNoteOrThrow(noteId); + noteService.asyncPostProcessContent(note, note.getContent()); + }); + }); + }, 5000); + + // Process image asynchronously + const attachmentId = attachment.attachmentId; + getImageProvider().processImage(uploadBuffer, originalName, !!shrinkImageSwitch).then(({ buffer, format }) => { + getContext().init(() => { + getSql().transactional(() => { + if (!attachmentId) { + throw new Error("Missing attachment ID."); + } + attachment = becca.getAttachmentOrThrow(attachmentId); + + attachment.mime = getImageMimeFromExtension(format.ext); + + if (!originalName.includes(".")) { + originalName += `.${format.ext}`; + attachment.title = sanitizeFilename(originalName); + } + + attachment.setContent(buffer, { forceSave: true }); + }); + }); + }); + + return attachment; +} + +export default { + saveImage, + saveImageToAttachment, + updateImage +}; diff --git a/packages/trilium-core/src/services/image_provider.ts b/packages/trilium-core/src/services/image_provider.ts new file mode 100644 index 0000000000..10649c7615 --- /dev/null +++ b/packages/trilium-core/src/services/image_provider.ts @@ -0,0 +1,44 @@ +/** + * Interface for platform-specific image processing. + * Server uses JIMP with full compression support. + * Standalone uses simple format detection without compression. + */ + +export interface ImageFormat { + ext: string; + mime: string; +} + +export interface ProcessedImage { + buffer: Uint8Array; + format: ImageFormat; +} + +export interface ImageProvider { + /** + * Detect image format from buffer. + */ + getImageType(buffer: Uint8Array): ImageFormat | null; + + /** + * Process image - may resize/compress depending on implementation. + * @param buffer - Raw image data + * @param originalName - Original filename for logging + * @param shrink - Whether to attempt shrinking the image + * @returns Processed image buffer and detected format + */ + processImage(buffer: Uint8Array, originalName: string, shrink: boolean): Promise; +} + +let imageProvider: ImageProvider | null = null; + +export function initImageProvider(provider: ImageProvider) { + imageProvider = provider; +} + +export function getImageProvider(): ImageProvider { + if (!imageProvider) { + throw new Error("Image provider not initialized"); + } + return imageProvider; +} diff --git a/packages/trilium-core/src/services/log.ts b/packages/trilium-core/src/services/log.ts index 2447811c9b..f7550c7d1c 100644 --- a/packages/trilium-core/src/services/log.ts +++ b/packages/trilium-core/src/services/log.ts @@ -38,12 +38,21 @@ export default class LogService { console.log(`\n${top}\n${mid}\n${bot}\n`); } + /** + * Returns the current log contents as a string. + * Override in platform-specific implementations to return actual log data. + * @returns The log contents, or null if not available + */ + getLogContents(): string | null { + return null; + } + } let log: LogService; -export function initLog() { - log = new LogService(); +export function initLog(provider?: LogService) { + log = provider ?? new LogService(); } export function getLog() { diff --git a/packages/trilium-core/src/services/migration.ts b/packages/trilium-core/src/services/migration.ts index a241d6e8db..8084d834ac 100644 --- a/packages/trilium-core/src/services/migration.ts +++ b/packages/trilium-core/src/services/migration.ts @@ -1,4 +1,4 @@ -import backupService from "./backup.js"; +import { getBackup } from "./backup.js"; import { getSql } from "./sql/index.js"; import { getLog } from "./log.js"; import { getPlatform } from "./platform.js"; @@ -26,7 +26,7 @@ async function migrate() { // backup before attempting migration if (!getPlatform().getEnv("TRILIUM_INTEGRATION_TEST")) { - await backupService.backupNow( + await getBackup().backupNow( // creating a special backup for version 0.60.4, the changes in 0.61 are major. currentDbVersion === 214 ? `before-migration-v060` : "before-migration" ); diff --git a/packages/trilium-core/src/services/notes.ts b/packages/trilium-core/src/services/notes.ts index 502ef1beb1..1830af3a5e 100644 --- a/packages/trilium-core/src/services/notes.ts +++ b/packages/trilium-core/src/services/notes.ts @@ -636,41 +636,43 @@ function downloadImages(noteId: string, content: string) { // are downloaded and the IMG references are not updated. For this occasion we have this code // which upon the download of all the images will update the note if the links have not been fixed before - getSql().transactional(() => { - const imageNotes = becca.getNotes(Object.values(imageUrlToAttachmentIdMapping), true); - const log = getLog(); + cls.getContext().init(() => { + getSql().transactional(() => { + const imageNotes = becca.getNotes(Object.values(imageUrlToAttachmentIdMapping), true); + const log = getLog(); - const origNote = becca.getNote(noteId); + const origNote = becca.getNote(noteId); - if (!origNote) { - log.error(`Cannot find note '${noteId}' to replace image link.`); - return; - } - - const origContent = origNote.getContent(); - let updatedContent = origContent; - - if (typeof updatedContent !== "string") { - log.error(`Note '${noteId}' has a non-string content, cannot replace image link.`); - return; - } - - for (const url in imageUrlToAttachmentIdMapping) { - const imageNote = imageNotes.find((note) => note.noteId === imageUrlToAttachmentIdMapping[url]); - - if (imageNote) { - updatedContent = replaceUrl(updatedContent, url, imageNote); + if (!origNote) { + log.error(`Cannot find note '${noteId}' to replace image link.`); + return; } - } - // update only if the links have not been already fixed. - if (updatedContent !== origContent) { - origNote.setContent(updatedContent); + const origContent = origNote.getContent(); + let updatedContent = origContent; - asyncPostProcessContent(origNote, updatedContent); + if (typeof updatedContent !== "string") { + log.error(`Note '${noteId}' has a non-string content, cannot replace image link.`); + return; + } - console.log(`Fixed the image links for note '${noteId}' to the offline saved.`); - } + for (const url in imageUrlToAttachmentIdMapping) { + const imageNote = imageNotes.find((note) => note.noteId === imageUrlToAttachmentIdMapping[url]); + + if (imageNote) { + updatedContent = replaceUrl(updatedContent, url, imageNote); + } + } + + // update only if the links have not been already fixed. + if (updatedContent !== origContent) { + origNote.setContent(updatedContent); + + asyncPostProcessContent(origNote, updatedContent); + + console.log(`Fixed the image links for note '${noteId}' to the offline saved.`); + } + }); }); }, 5000); }); diff --git a/packages/trilium-core/src/services/sql/sql.ts b/packages/trilium-core/src/services/sql/sql.ts index 5d9a655417..de7ee6e03c 100644 --- a/packages/trilium-core/src/services/sql/sql.ts +++ b/packages/trilium-core/src/services/sql/sql.ts @@ -373,6 +373,18 @@ export class SqlService { await this.dbConnection.backup(targetFilePath); } + /** + * Serialize the database to a byte array. + * Only available with browser-based providers. + * @throws Error if the provider doesn't support serialization + */ + serialize(): Uint8Array { + if (!this.dbConnection.serialize) { + throw new Error("Database provider does not support serialization"); + } + return this.dbConnection.serialize(); + } + disableSlowQueryLogging(cb: () => T) { const orig = isSlowQueryLoggingDisabled(); diff --git a/packages/trilium-core/src/services/sql/types.ts b/packages/trilium-core/src/services/sql/types.ts index edbbc1a3b6..786c7e7bbf 100644 --- a/packages/trilium-core/src/services/sql/types.ts +++ b/packages/trilium-core/src/services/sql/types.ts @@ -23,6 +23,11 @@ export interface DatabaseProvider { loadFromMemory(): void; loadFromBuffer(buffer: Uint8Array): void; backup(destinationFile: string): void; + /** + * Serialize the database to a byte array. + * Optional - only implemented by browser-based providers. + */ + serialize?(): Uint8Array; prepare(query: string): Statement; transaction(func: (statement: Statement) => T): Transaction; get inTransaction(): boolean; diff --git a/packages/trilium-core/src/services/sql_init.ts b/packages/trilium-core/src/services/sql_init.ts index 29654cf97c..b360a02747 100644 --- a/packages/trilium-core/src/services/sql_init.ts +++ b/packages/trilium-core/src/services/sql_init.ts @@ -1,6 +1,7 @@ import { deferred, OptionRow } from "@triliumnext/commons"; import { getSql } from "./sql"; import { getLog } from "./log"; +import { getBackup } from "./backup"; import optionService from "./options"; import eventService from "./events"; import { getContext } from "./context"; @@ -11,6 +12,7 @@ import hidden_subtree from "./hidden_subtree"; import TaskContext from "./task_context"; import BOption from "../becca/entities/boption"; import migrationService from "./migration"; +import passwordService from "./encryption/password"; export const dbReady = deferred(); @@ -105,22 +107,16 @@ function initializeDb() { getContext().init(initDbConnection); dbReady.then(() => { - // TODO: Re-enable backup. - // if (config.General && config.General.noBackup === true) { - // log.info("Disabling scheduled backups."); + // Run regular backups every 4 hours + setInterval(() => getBackup().regularBackup(), 4 * 60 * 60 * 1000); - // return; - // } + // Kickoff first backup soon after start up + setTimeout(() => getBackup().regularBackup(), 5 * 60 * 1000); - // setInterval(() => backup.regularBackup(), 4 * 60 * 60 * 1000); + // Optimize is usually inexpensive no-op, so running it semi-frequently is not a big deal + setTimeout(() => optimize(), 60 * 60 * 1000); - // // kickoff first backup soon after start up - // setTimeout(() => backup.regularBackup(), 5 * 60 * 1000); - - // // optimize is usually inexpensive no-op, so running it semi-frequently is not a big deal - // setTimeout(() => optimize(), 60 * 60 * 1000); - - // setInterval(() => optimize(), 10 * 60 * 60 * 1000); + setInterval(() => optimize(), 10 * 60 * 60 * 1000); }); } @@ -171,7 +167,7 @@ async function createInitialDatabase(skipDemoDb?: boolean) { initDocumentOptions(); initNotSyncedOptions(true, {}); initStartupOptions(); - // password.resetPassword(); + passwordService.resetPassword(); }); // Check hidden subtree. diff --git a/packages/trilium-core/src/services/sync_options.spec.ts b/packages/trilium-core/src/services/sync_options.spec.ts new file mode 100644 index 0000000000..ca61914d88 --- /dev/null +++ b/packages/trilium-core/src/services/sync_options.spec.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("syncOptions.getSyncTimeout", () => { + let getSyncTimeout: () => number; + let getOptionMock: ReturnType; + let mockSyncConfig: Record; + + beforeEach(async () => { + // Reset the module cache so the dynamic import below gets a fresh + // instance of sync_options.ts with the mocked dependencies rather than + // the cached copy loaded by the test runner's setupFiles. + vi.resetModules(); + mockSyncConfig = {}; + getOptionMock = vi.fn(); + + vi.doMock("./config.js", () => ({ default: { Sync: mockSyncConfig } })); + vi.doMock("./options.js", () => ({ default: { getOption: getOptionMock } })); + + const mod = await import("./sync_options.js"); + getSyncTimeout = mod.default.getSyncTimeout; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("converts database value from seconds to milliseconds", () => { + // TimeSelector stores value in seconds (displayed value × scale) + // Scale is UI-only, not used in backend calculation + getOptionMock.mockReturnValue("120"); // 120 seconds = 2 minutes + expect(getSyncTimeout()).toBe(120000); + + getOptionMock.mockReturnValue("30"); // 30 seconds + expect(getSyncTimeout()).toBe(30000); + + getOptionMock.mockReturnValue("3600"); // 3600 seconds = 1 hour + expect(getSyncTimeout()).toBe(3600000); + }); + + it("treats config override as raw milliseconds for backward compatibility", () => { + mockSyncConfig.syncServerTimeout = "60000"; // 60 seconds in ms + // Config value takes precedence, db value is ignored + getOptionMock.mockReturnValue("9999"); + expect(getSyncTimeout()).toBe(60000); + }); + + it("uses safe defaults for invalid values", () => { + getOptionMock.mockReturnValue(""); + expect(getSyncTimeout()).toBe(120000); // default 120 seconds + + mockSyncConfig.syncServerTimeout = "invalid"; + expect(getSyncTimeout()).toBe(120000); // fallback for invalid config + }); +}); diff --git a/packages/trilium-core/src/services/sync_options.ts b/packages/trilium-core/src/services/sync_options.ts index a94dbd12f1..48fac8cc1e 100644 --- a/packages/trilium-core/src/services/sync_options.ts +++ b/packages/trilium-core/src/services/sync_options.ts @@ -29,6 +29,14 @@ export default { // and we need to override it with config from config.ini return !!syncServerHost && syncServerHost !== "disabled"; }, - getSyncTimeout: () => parseInt(get("syncServerTimeout")) || 120000, + getSyncTimeout: () => { + // Config.ini values are in raw milliseconds (backward compat with old configs) + if (config["Sync"] && config["Sync"]["syncServerTimeout"]) { + return parseInt(config["Sync"]["syncServerTimeout"]) || 120000; + } + // Database values are stored in seconds — convert to milliseconds + const seconds = parseInt(optionService.getOption("syncServerTimeout")); + return (isNaN(seconds) || seconds <= 0) ? 120000 : seconds * 1000; + }, getSyncProxy: () => get("syncProxy") }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abe72e5681..0e0ae46e2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: overrides: '@codemirror/language': 6.12.3 '@lezer/highlight': 1.2.3 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 mermaid: 11.14.0 preact: 10.29.1 roughjs: 4.6.6 @@ -61,7 +61,7 @@ overrides: handlebars@<4.7.9: '>=4.7.9' qs@<6.14.2: '>=6.14.2' minimatch@<3.1.4: ^3.1.4 - minimatch@3>brace-expansion: ^5.0.0 + minimatch@3>brace-expansion: ^1.1.13 serialize-javascript@<7.0.5: '>=7.0.5' webpack@<5.104.1: '>=5.104.1' file-type@>=13.0.0 <21.3.1: '>=21.3.1' @@ -194,8 +194,8 @@ importers: version: link:../server devDependencies: '@redocly/cli': - specifier: 2.25.4 - version: 2.25.4(@opentelemetry/api@1.9.0)(bufferutil@4.0.9)(core-js@3.46.0)(encoding@0.1.13)(utf-8-validate@6.0.5) + specifier: 2.26.0 + version: 2.26.0(@opentelemetry/api@1.9.0)(bufferutil@4.0.9)(core-js@3.46.0)(encoding@0.1.13)(utf-8-validate@6.0.5) archiver: specifier: 7.0.1 version: 7.0.1 @@ -293,8 +293,8 @@ importers: specifier: 0.20.0 version: 0.20.0(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2) '@zumer/snapdom': - specifier: 2.7.0 - version: 2.7.0 + specifier: 2.8.0 + version: 2.8.0 autocomplete.js: specifier: 0.38.1 version: 0.38.1 @@ -323,8 +323,8 @@ importers: specifier: 1.51.2 version: 1.51.2 i18next: - specifier: 26.0.3 - version: 26.0.3(typescript@6.0.2) + specifier: 26.0.4 + version: 26.0.4(typescript@6.0.2) i18next-http-backend: specifier: 3.0.4 version: 3.0.4(encoding@0.1.13) @@ -366,7 +366,7 @@ importers: version: 10.29.1 react-i18next: specifier: 17.0.2 - version: 17.0.2(i18next@26.0.3(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2) + version: 17.0.2(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2) react-window: specifier: 2.2.7 version: 2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -489,8 +489,11 @@ importers: specifier: workspace:* version: link:../../packages/splitjs '@zumer/snapdom': - specifier: 2.7.0 - version: 2.7.0 + specifier: 2.8.0 + version: 2.8.0 + aes-js: + specifier: 3.1.2 + version: 3.1.2 autocomplete.js: specifier: 0.38.1 version: 0.38.1 @@ -522,8 +525,8 @@ importers: specifier: 17.4.0 version: 17.4.0 i18next: - specifier: 26.0.3 - version: 26.0.3(typescript@6.0.2) + specifier: 26.0.4 + version: 26.0.4(typescript@6.0.2) i18next-http-backend: specifier: 3.0.4 version: 3.0.4(encoding@0.1.13) @@ -583,13 +586,16 @@ importers: version: 10.29.1 react-i18next: specifier: 17.0.2 - version: 17.0.2(i18next@26.0.3(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2) + version: 17.0.2(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2) react-window: specifier: 2.2.7 version: 2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) reveal.js: specifier: 6.0.0 version: 6.0.0 + scrypt-js: + specifier: 3.0.1 + version: 3.0.1 svg-pan-zoom: specifier: 3.6.2 version: 3.6.2 @@ -606,6 +612,9 @@ importers: '@preact/preset-vite': specifier: 2.10.2 version: 2.10.2(@babel/core@7.29.0)(preact@10.29.1)(vite@8.0.7(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(less@4.1.3)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) + '@types/aes-js': + specifier: 3.1.4 + version: 3.1.4 '@types/bootstrap': specifier: 5.2.10 version: 5.2.10 @@ -662,7 +671,7 @@ importers: dependencies: '@electron/remote': specifier: 2.1.3 - version: 2.1.3(electron@41.1.1) + version: 2.1.3(electron@41.2.0) better-sqlite3: specifier: 12.8.0 version: 12.8.0 @@ -722,8 +731,8 @@ importers: specifier: 14.0.0 version: 14.0.0(webpack@5.105.4(esbuild@0.28.0)) electron: - specifier: 41.1.1 - version: 41.1.1 + specifier: 41.2.0 + version: 41.2.0 prebuild-install: specifier: 7.1.3 version: 7.1.3 @@ -781,8 +790,8 @@ importers: specifier: 14.0.0 version: 14.0.0(webpack@5.105.4(esbuild@0.28.0)) electron: - specifier: 41.1.1 - version: 41.1.1 + specifier: 41.2.0 + version: 41.2.0 fs-extra: specifier: 11.3.4 version: 11.3.4 @@ -811,8 +820,8 @@ importers: specifier: ^1.12.1 version: 1.29.0(zod@4.3.6) ai: - specifier: 6.0.153 - version: 6.0.153(zod@4.3.6) + specifier: 6.0.154 + version: 6.0.154(zod@4.3.6) better-sqlite3: specifier: 12.8.0 version: 12.8.0 @@ -820,8 +829,8 @@ importers: specifier: 9.0.5 version: 9.0.5 i18next: - specifier: 26.0.3 - version: 26.0.3(typescript@6.0.2) + specifier: 26.0.4 + version: 26.0.4(typescript@6.0.2) i18next-fs-backend: specifier: 2.6.3 version: 2.6.3 @@ -837,7 +846,7 @@ importers: version: 7.1.2 '@electron/remote': specifier: 2.1.3 - version: 2.1.3(electron@41.1.1) + version: 2.1.3(electron@41.2.0) '@triliumnext/commons': specifier: workspace:* version: link:../../packages/commons @@ -950,8 +959,8 @@ importers: specifier: 5.0.1 version: 5.0.1 electron: - specifier: 41.1.1 - version: 41.1.1 + specifier: 41.2.0 + version: 41.2.0 electron-window-state: specifier: 5.0.3 version: 5.0.3 @@ -1083,8 +1092,8 @@ importers: apps/website: dependencies: i18next: - specifier: 26.0.3 - version: 26.0.3(typescript@6.0.2) + specifier: 26.0.4 + version: 26.0.4(typescript@6.0.2) preact: specifier: 10.29.1 version: 10.29.1 @@ -1096,7 +1105,7 @@ importers: version: 6.6.7(preact@10.29.1) react-i18next: specifier: 17.0.2 - version: 17.0.2(i18next@26.0.3(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2) + version: 17.0.2(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2) devDependencies: '@preact/preset-vite': specifier: 2.10.5 @@ -1515,7 +1524,7 @@ importers: version: 6.5.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.0) '@replit/codemirror-lang-nix': specifier: 6.0.1 - version: 6.0.1(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.2) + version: 6.0.1(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.2) '@replit/codemirror-vim': specifier: 6.3.0 version: 6.3.0(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/view@6.41.0) @@ -1659,8 +1668,8 @@ importers: specifier: 1.0.3 version: 1.0.3 i18next: - specifier: 26.0.3 - version: 26.0.3(typescript@6.0.2) + specifier: 26.0.4 + version: 26.0.4(typescript@6.0.2) mime-types: specifier: 3.0.2 version: 3.0.2 @@ -1719,8 +1728,8 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@3.0.93': - resolution: {integrity: sha512-8D6C9eEvDq6IgrdlWzpbniahDkoLiieTCrpzH8p/Hw63/0iPnZJ1uZcqxHrDIVDW/+aaGhBXqmx5C7HSd2eMmQ==} + '@ai-sdk/gateway@3.0.94': + resolution: {integrity: sha512-uDDwLZhCkvC89crVS3S90D5L7AcVN8WriGuYVNYgVAaVcvy3Mthy3R9ICfzG75BObhz6pm2FWnhxDfNRK+t69Q==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -3858,8 +3867,8 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@lezer/common@1.5.1': - resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} '@lezer/css@1.1.11': resolution: {integrity: sha512-FuAnusbLBl1SEAtfN8NdShxYJiESKw9LAFysfea1T96jD3ydBn12oYjaSG1a04BQRIUd93/0D8e5CV1cUMkmQg==} @@ -5046,27 +5055,27 @@ packages: '@redocly/cli-otel@0.1.2': resolution: {integrity: sha512-Bg7BoO5t1x3lVK+KhA5aGPmeXpQmdf6WtTYHhelKJCsQ+tRMiJoFAQoKHoBHAoNxXrhlS3K9lKFLHGmtxsFQfA==} - '@redocly/cli@2.25.4': - resolution: {integrity: sha512-ypBv8ZhckTzcOfsFH2VILsLqk00bJ1tI0POtlaEf8z0rDsnmD8auUETkMzw8wlUB+aQM7+VSzpSsmcmqeSgzWQ==} + '@redocly/cli@2.26.0': + resolution: {integrity: sha512-24S1ls0qvu3uaPiW4OImy06CpImAkUOd3h7OG+Hq9By5pPavjOE34KtdQTaaFso3e1qgzXYdQh6HPqEY1nTZgA==} engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'} hasBin: true '@redocly/config@0.22.2': resolution: {integrity: sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==} - '@redocly/config@0.46.0': - resolution: {integrity: sha512-FZEprNEkmLITKKdv5blIai1qiCcc4dn5+96AjWnmFQmH/oz/OyBiXBSi752/M+Wmype7aH2uRywSCuYlu4CgVA==} + '@redocly/config@0.46.1': + resolution: {integrity: sha512-dSdkB2wRLtvl3f7ayRu9vqVhUMjjRaxZlHgRbgOtPPXxn4uI/ciDO87h4CJb7Iet+OVpevpAU6gU8bo5qVbQxg==} '@redocly/openapi-core@1.34.5': resolution: {integrity: sha512-0EbE8LRbkogtcCXU7liAyC00n9uNG9hJ+eMyHFdUsy9lB/WGqnEBgwjA9q2cyzAVcdTkQqTBBU1XePNnN3OijA==} engines: {node: '>=18.17.0', npm: '>=9.5.0'} - '@redocly/openapi-core@2.25.4': - resolution: {integrity: sha512-zYdKQEsowPNtkTixrfbn5DySWBLQpTsISthVBBEPAa3OZC75UI76CbHXEamJ8Kmlead9IkD5RbgeJvxqJ5/H6Q==} + '@redocly/openapi-core@2.26.0': + resolution: {integrity: sha512-BjTPzSV1Gv430W9S/7i5T/dEZDK00GFk6ILCNTI+31pA9lEFJOXc0XRJT+V3v+m3nXIgGoo6GgqeLdAiM10rNg==} engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'} - '@redocly/respect-core@2.25.4': - resolution: {integrity: sha512-0xMbcSft+9Q2sO1wSJMxo510Aqc/kGF/AmUK3OaLQvGvKUgOqq2Op/0aorNQJk6s8WBEH4UN4eFt7fUzUeXs8g==} + '@redocly/respect-core@2.26.0': + resolution: {integrity: sha512-mejFg26XNp8pqHwnL75QvI7MO4dhgFKa+v35OgOcVMrU9tGZ/VaFbplEyvdrRgjoonguXoLDoMN4Iw1rWlZg0g==} engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'} '@replit/codemirror-indentation-markers@6.5.3': @@ -5083,7 +5092,7 @@ packages: '@codemirror/language': 6.12.3 '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': ^1.0.0 @@ -5466,6 +5475,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aes-js@3.1.4': + resolution: {integrity: sha512-v3D66IptpUqh+pHKVNRxY8yvp2ESSZXe0rTzsGdzUhEwag7ljVfgCllkWv2YgiYXDhWFBrEywll4A5JToyTNFA==} + '@types/appdmg@0.5.5': resolution: {integrity: sha512-G+n6DgZTZFOteITE30LnWj+HRVIGr7wMlAiLWOO02uJFWVEitaPU9JVXm9wJokkgshBawb2O1OykdcsmkkZfgg==} @@ -6801,8 +6813,8 @@ packages: resolution: {integrity: sha512-0fztsk/0ryJ+2PPr9EyXS5/Co7OK8q3zY/xOoozEWaUsL5x+C0cyZ4YyMuUffOO2Dx/rAdq4JMPqW0VUtm+vzA==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} - '@zumer/snapdom@2.7.0': - resolution: {integrity: sha512-ZiELKzDszeFOazPQ/ExXzgtdoW9jADVjDjInr5XDAlVdCx0RbNsFiG7RLyM48XnA7EyCA9yTvmXSc3ElDrTRqA==} + '@zumer/snapdom@2.8.0': + resolution: {integrity: sha512-NhztgFDNfOkFt8Ox9PIJ1IwggyMui5UDazysOgZD7FSGL0G7H8U+J3ft0iecxAS8daj5aC62i3blaTk7s2GcpA==} abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -6863,6 +6875,9 @@ packages: resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} engines: {node: '>=12.0'} + aes-js@3.1.2: + resolution: {integrity: sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -6883,8 +6898,8 @@ packages: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} - ai@6.0.153: - resolution: {integrity: sha512-UlgBe4k0Ja1m1Eufn6FVSsHoF0sc7qwxX35ywJPDogIvBz0pHc+NOmCqiRY904DczNYIuwpZfKBLVz8HXgu3mg==} + ai@6.0.154: + resolution: {integrity: sha512-HfKJKCTJsDZxqrIUDSVnBQ7DpQlx5WI4ExqtLd7Bl70epLmvkpc/HYMzU1hP9W+g9VEAcvZo4fbMqc3v5D+9gQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -7123,6 +7138,9 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.3: resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} engines: {node: 20 || >=22} @@ -7252,6 +7270,9 @@ packages: bplist-creator@0.0.8: resolution: {integrity: sha512-Za9JKzD6fjLC16oX2wsXfc+qBEhJBJB1YPInoAQpMLhDuj5aVOv1baGeIQSq1Fr3OCqzvsoQcSBSwGId/Ja2PA==} + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} @@ -7720,6 +7741,9 @@ packages: resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} engines: {'0': node >= 0.8} @@ -8428,8 +8452,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@41.1.1: - resolution: {integrity: sha512-8bgvDhBjli+3Z2YCKgzzoBPh6391pr7Xv2h/tTJG4ETgvPvUxZomObbZLs31mUzYb6VrlcDDd9cyWyNKtPm3tA==} + electron@41.2.0: + resolution: {integrity: sha512-0OKLiymqfV0WK68RBXqAm3Myad2TpI5wwxLCBEUcH5Nugo3YfSk7p1Js/AL9266qTz5xZioUnxt9hG8FFwax0g==} engines: {node: '>= 12.20.55'} hasBin: true @@ -9593,8 +9617,8 @@ packages: i18next-http-backend@3.0.4: resolution: {integrity: sha512-udwrBIE6cNpqn1gRAqRULq3+7MzIIuaiKRWrz++dVz5SqWW2VwXmPJtAgkI0JtMLFaADC9qNmnZAxWAhsxXx2g==} - i18next@26.0.3: - resolution: {integrity: sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==} + i18next@26.0.4: + resolution: {integrity: sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -12591,6 +12615,9 @@ packages: script-loader@0.7.2: resolution: {integrity: sha512-UMNLEvgOAQuzK8ji8qIscM3GIrRCWN6MmMXGD4SD5l6cSycgGsCo0tX5xRnfQcoghqct0tjHjcykgI1PyBE2aA==} + scrypt-js@3.0.1: + resolution: {integrity: sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==} + scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} @@ -14368,7 +14395,7 @@ snapshots: '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) zod: 4.3.6 - '@ai-sdk/gateway@3.0.93(zod@4.3.6)': + '@ai-sdk/gateway@3.0.94(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) @@ -15608,28 +15635,28 @@ snapshots: '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@codemirror/commands@6.10.3': dependencies: '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@codemirror/commands@6.8.1': dependencies: '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@codemirror/lang-css@6.3.1': dependencies: '@codemirror/autocomplete': 6.18.6 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/css': 1.1.11 '@codemirror/lang-html@6.4.11': @@ -15640,7 +15667,7 @@ snapshots: '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/css': 1.1.11 '@lezer/html': 1.3.12 @@ -15651,7 +15678,7 @@ snapshots: '@codemirror/lint': 6.8.5 '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/javascript': 1.5.1 '@codemirror/lang-json@6.0.2': @@ -15666,7 +15693,7 @@ snapshots: '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/markdown': 1.4.3 '@codemirror/lang-markdown@6.5.0': @@ -15676,7 +15703,7 @@ snapshots: '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/markdown': 1.4.3 '@codemirror/lang-php@6.0.2': @@ -15684,7 +15711,7 @@ snapshots: '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/php': 1.0.2 '@codemirror/lang-vue@0.1.3': @@ -15692,7 +15719,7 @@ snapshots: '@codemirror/lang-html': 6.4.11 '@codemirror/lang-javascript': 6.2.5 '@codemirror/language': 6.12.3 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.2 @@ -15702,14 +15729,14 @@ snapshots: '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/xml': 1.0.6 '@codemirror/language@6.12.3': dependencies: '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.2 style-mod: 4.1.2 @@ -16224,9 +16251,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/remote@2.1.3(electron@41.1.1)': + '@electron/remote@2.1.3(electron@41.2.0)': dependencies: - electron: 41.1.1 + electron: 41.2.0 '@electron/universal@2.0.2': dependencies: @@ -17433,54 +17460,54 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@lezer/common@1.5.1': {} + '@lezer/common@1.5.2': {} '@lezer/css@1.1.11': dependencies: - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.2 '@lezer/highlight@1.2.3': dependencies: - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/html@1.3.12': dependencies: - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.2 '@lezer/javascript@1.5.1': dependencies: - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.2 '@lezer/json@1.0.3': dependencies: - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.2 '@lezer/lr@1.4.2': dependencies: - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/markdown@1.4.3': dependencies: - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/php@1.0.2': dependencies: - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.2 '@lezer/xml@1.0.6': dependencies: - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.2 @@ -18678,15 +18705,15 @@ snapshots: dependencies: ulid: 2.4.0 - '@redocly/cli@2.25.4(@opentelemetry/api@1.9.0)(bufferutil@4.0.9)(core-js@3.46.0)(encoding@0.1.13)(utf-8-validate@6.0.5)': + '@redocly/cli@2.26.0(@opentelemetry/api@1.9.0)(bufferutil@4.0.9)(core-js@3.46.0)(encoding@0.1.13)(utf-8-validate@6.0.5)': dependencies: '@opentelemetry/exporter-trace-otlp-http': 0.202.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-node': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.34.0 '@redocly/cli-otel': 0.1.2 - '@redocly/openapi-core': 2.25.4 - '@redocly/respect-core': 2.25.4 + '@redocly/openapi-core': 2.26.0 + '@redocly/respect-core': 2.26.0 abort-controller: 3.0.0 ajv: '@redocly/ajv@8.18.0' ajv-formats: 3.0.1(@redocly/ajv@8.18.0) @@ -18720,7 +18747,7 @@ snapshots: '@redocly/config@0.22.2': {} - '@redocly/config@0.46.0': + '@redocly/config@0.46.1': dependencies: json-schema-to-ts: 2.7.2 @@ -18738,10 +18765,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@redocly/openapi-core@2.25.4': + '@redocly/openapi-core@2.26.0': dependencies: '@redocly/ajv': 8.18.0 - '@redocly/config': 0.46.0 + '@redocly/config': 0.46.1 ajv: '@redocly/ajv@8.18.0' ajv-formats: 3.0.1(@redocly/ajv@8.18.0) colorette: 1.4.0 @@ -18751,12 +18778,12 @@ snapshots: pluralize: 8.0.0 yaml-ast-parser: 0.0.43 - '@redocly/respect-core@2.25.4': + '@redocly/respect-core@2.26.0': dependencies: '@faker-js/faker': 7.6.0 '@noble/hashes': 1.8.0 '@redocly/ajv': 8.18.0 - '@redocly/openapi-core': 2.25.4 + '@redocly/openapi-core': 2.26.0 ajv: '@redocly/ajv@8.18.0' better-ajv-errors: 1.2.0(@redocly/ajv@8.18.0) colorette: 2.0.20 @@ -18772,13 +18799,13 @@ snapshots: '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 - '@replit/codemirror-lang-nix@6.0.1(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.2)': + '@replit/codemirror-lang-nix@6.0.1(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.2)': dependencies: '@codemirror/autocomplete': 6.18.6 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@codemirror/view': 6.41.0 - '@lezer/common': 1.5.1 + '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.2 @@ -19072,6 +19099,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aes-js@3.1.4': {} + '@types/appdmg@0.5.5': dependencies: '@types/node': 24.12.2 @@ -21538,7 +21567,7 @@ snapshots: '@zip.js/zip.js@2.8.11': {} - '@zumer/snapdom@2.7.0': {} + '@zumer/snapdom@2.8.0': {} abbrev@1.1.1: {} @@ -21582,6 +21611,8 @@ snapshots: adm-zip@0.5.16: {} + aes-js@3.1.2: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -21601,9 +21632,9 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ai@6.0.153(zod@4.3.6): + ai@6.0.154(zod@4.3.6): dependencies: - '@ai-sdk/gateway': 3.0.93(zod@4.3.6) + '@ai-sdk/gateway': 3.0.94(zod@4.3.6) '@ai-sdk/provider': 3.0.8 '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) '@opentelemetry/api': 1.9.0 @@ -21876,6 +21907,8 @@ snapshots: bail@2.0.2: {} + balanced-match@1.0.2: {} + balanced-match@4.0.3: {} bare-events@2.7.0: {} @@ -22016,6 +22049,11 @@ snapshots: stream-buffers: 2.2.0 optional: true + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.3 @@ -22653,6 +22691,8 @@ snapshots: transitivePeerDependencies: - supports-color + concat-map@0.0.1: {} + concat-stream@1.6.2: dependencies: buffer-from: 1.1.2 @@ -23440,10 +23480,10 @@ snapshots: - supports-color optional: true - electron@41.1.1: + electron@41.2.0: dependencies: '@electron/get': 2.0.3 - '@types/node': 24.12.0 + '@types/node': 24.12.2 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -25079,7 +25119,7 @@ snapshots: transitivePeerDependencies: - encoding - i18next@26.0.3(typescript@6.0.2): + i18next@26.0.4(typescript@6.0.2): dependencies: '@babel/runtime': 7.29.2 optionalDependencies: @@ -26540,7 +26580,7 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 1.1.14 minimatch@5.1.9: dependencies: @@ -27735,11 +27775,11 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-i18next@17.0.2(i18next@26.0.3(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2): + react-i18next@17.0.2(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 26.0.3(typescript@6.0.2) + i18next: 26.0.4(typescript@6.0.2) react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) optionalDependencies: @@ -28434,6 +28474,8 @@ snapshots: dependencies: raw-loader: 0.5.1 + scrypt-js@3.0.1: {} + scule@1.3.0: {} secure-compare@3.0.1: {}