From 656f87e3c6a748fbd90f308e6d92a000976d5333 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Sat, 21 Feb 2026 10:30:18 +0500 Subject: [PATCH 1/3] editor: fix crash if embed url is invalid or empty --- .../editor/src/extensions/embed/component.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/extensions/embed/component.tsx b/packages/editor/src/extensions/embed/component.tsx index 999c570bf..4dd58422f 100644 --- a/packages/editor/src/extensions/embed/component.tsx +++ b/packages/editor/src/extensions/embed/component.tsx @@ -179,15 +179,19 @@ function getSandboxFeatures(src: string) { } function isYouTubeEmbed(urlString: string) { - const url = new URL(urlString); - return ( - (url.hostname === "www.youtube.com" || - url.hostname === "youtube.com" || - url.hostname === "m.youtube.com" || - url.hostname === "www.youtube-nocookie.com" || - url.hostname === "youtube-nocookie.com") && - url.pathname.startsWith("/embed/") - ); + try { + const url = new URL(urlString); + return ( + (url.hostname === "www.youtube.com" || + url.hostname === "youtube.com" || + url.hostname === "m.youtube.com" || + url.hostname === "www.youtube-nocookie.com" || + url.hostname === "youtube-nocookie.com") && + url.pathname.startsWith("/embed/") + ); + } catch { + return false; + } } function isTwitterX(src: string) { From aece1404735bcee5881ac2cffbf03078ee2bd49c Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Sat, 21 Feb 2026 10:30:39 +0500 Subject: [PATCH 2/3] web: fix crash on rewriting error message --- apps/desktop/src/api/sqlite-kysely.ts | 11 ++++++++- apps/web/src/common/sqlite/sqlite.worker.ts | 3 ++- apps/web/src/utils/error.ts | 26 +++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/utils/error.ts diff --git a/apps/desktop/src/api/sqlite-kysely.ts b/apps/desktop/src/api/sqlite-kysely.ts index d3726ede1..c63207ab6 100644 --- a/apps/desktop/src/api/sqlite-kysely.ts +++ b/apps/desktop/src/api/sqlite-kysely.ts @@ -113,7 +113,8 @@ export class SQLite { }; } } catch (e) { - if (e instanceof Error) e.message += ` (query: ${sql})`; + if (e instanceof Error) + throw rewriteError(e, `${e.message} (query: ${sql})`); throw e; } finally { // Since SQLite 3.48.0 (SQLite3MC v2.0.2) it's not possible to load fts5 @@ -212,3 +213,11 @@ function getExtensionPath(extensionName: string, entryPoint: string) { } return loadablePath; } + +function rewriteError(e: Error, message: string) { + const error = new Error(message); + error.stack = e.stack; + error.name = e.name; + error.cause = e.cause; + return error; +} diff --git a/apps/web/src/common/sqlite/sqlite.worker.ts b/apps/web/src/common/sqlite/sqlite.worker.ts index 36929cf03..7d719d286 100644 --- a/apps/web/src/common/sqlite/sqlite.worker.ts +++ b/apps/web/src/common/sqlite/sqlite.worker.ts @@ -26,6 +26,7 @@ import { DatabaseSource } from "./sqlite-export"; import { createSharedServicePort } from "./shared-service"; import type { IDBBatchAtomicVFS } from "./IDBBatchAtomicVFS"; import type { AccessHandlePoolVFS } from "./AccessHandlePoolVFS"; +import { rewriteError } from "../../utils/error"; type PreparedStatement = { stmt: number; @@ -146,7 +147,7 @@ class _SQLiteWorker { return rows; } catch (e) { if (e instanceof Error || e instanceof SQLiteError) - e.message += ` (error exec query: ${sql})`; + throw rewriteError(e, `${e.message} (error executing query: ${sql})`); throw e; } finally { await this.sqlite diff --git a/apps/web/src/utils/error.ts b/apps/web/src/utils/error.ts new file mode 100644 index 000000000..b21e138e4 --- /dev/null +++ b/apps/web/src/utils/error.ts @@ -0,0 +1,26 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +export function rewriteError(e: Error, message: string) { + const error = new Error(message); + error.stack = e.stack; + error.name = e.name; + error.cause = e.cause; + return error; +} From da06d7e6366cb553cbd80197e95c74332901c094 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Sat, 21 Feb 2026 10:30:56 +0500 Subject: [PATCH 3/3] misc: add stack trace deobfuscation script --- scripts/deobfuscate.mjs | 530 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 530 insertions(+) create mode 100644 scripts/deobfuscate.mjs diff --git a/scripts/deobfuscate.mjs b/scripts/deobfuscate.mjs new file mode 100644 index 000000000..18b55e9d7 --- /dev/null +++ b/scripts/deobfuscate.mjs @@ -0,0 +1,530 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +/** + * Deobfuscate a minified JavaScript stack trace using source maps. + * + * Usage: + * node scripts/deobfuscate.mjs [options] [stack-trace-file] + * + * If no file is given the script reads from stdin. The stack trace may + * contain literal spaces or URL-encoded "+" characters in place of spaces. + * + * Options: + * --base-url Base URL for fetching remote .map files. + * Default: https://app.notesnook.com/assets/ + * --local Directory to resolve .map files from before fetching + * remotely. Useful when you have a local build handy. + * --cache-dir Where to cache downloaded source maps on disk. + * Default: ~/.cache/notesnook/sourcemaps + * --no-cache Skip the on-disk cache; always fetch fresh copies. + * --context Lines of original source context to show after each + * resolved frame (default: 2, 0 to disable). + * --help Print this help message. + */ + +import https from "https"; +import http from "http"; +import { readFileSync, existsSync, mkdirSync, writeFileSync } from "fs"; +import { glob } from "fs/promises"; +import path from "path"; +import os from "os"; +import { fileURLToPath } from "url"; +import { URL } from "url"; + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- + +const argv = process.argv.slice(2); + +function parseArgs(argv) { + const opts = { + baseUrl: "https://app.notesnook.com/assets/", + cacheDir: path.join(os.homedir(), ".cache", "notesnook", "sourcemaps"), + localDir: null, + noCache: false, + context: 2, + help: false, + inputFile: null + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--help" || arg === "-h") { + opts.help = true; + } else if (arg === "--no-cache") { + opts.noCache = true; + } else if (arg === "--base-url") { + opts.baseUrl = argv[++i]; + if (!opts.baseUrl.endsWith("/")) opts.baseUrl += "/"; + } else if (arg === "--local") { + opts.localDir = argv[++i]; + } else if (arg === "--cache-dir") { + opts.cacheDir = argv[++i]; + } else if (arg === "--context" || arg === "-c") { + opts.context = parseInt(argv[++i], 10); + } else if (!arg.startsWith("-")) { + opts.inputFile = arg; + } else { + die(`Unknown option: ${arg}`); + } + } + return opts; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function die(msg) { + console.error(`Error: ${msg}`); + process.exit(1); +} + +/** + * Search the repo for a file whose path ends with the given suffix. + * Tries progressively shorter trailing-path suffixes until a unique match + * is found, then returns the absolute path. + */ +async function findInRepo(repoRoot, suffix) { + // Normalise to forward slashes + const parts = suffix.replace(/\\/g, "/").split("/").filter(Boolean); + // Try from the longest suffix down to just the filename + for (let take = parts.length; take >= 1; take--) { + const pattern = "**/" + parts.slice(-take).join("/"); + const matches = []; + try { + for await (const f of glob(pattern, { + cwd: repoRoot, + exclude: (n) => n === "node_modules" || n === ".git" + })) { + matches.push(path.join(repoRoot, f)); + } + } catch { + continue; + } + if (matches.length === 1) return matches[0]; + if (matches.length > 1) { + // If there are several, prefer the one with the most suffix components in common + matches.sort( + (a, b) => + b.replace(/\\/g, "/").split("/").length - + a.replace(/\\/g, "/").split("/").length + ); + return matches[0]; + } + } + return null; +} + +// Cache to avoid re-searching the same suffix +const repoSearchCache = new Map(); + +async function resolveSourcePathAsync(source, mapUrl, repoRoot) { + if (!repoRoot) return source; + const cached = repoSearchCache.get(source); + if (cached !== undefined) return cached; + + let pathname; + try { + pathname = new URL(source, mapUrl).pathname.replace(/^\//, ""); + } catch { + repoSearchCache.set(source, source); + return source; + } + + // Try exact path first (and common dist→src substitutions) + const candidates = [ + path.join(repoRoot, pathname), + path.join(repoRoot, pathname.replace(/\/dist\/esm\//, "/src/")), + path.join(repoRoot, pathname.replace(/\/dist\//, "/src/")) + ]; + for (const c of candidates) { + if (existsSync(c)) { + repoSearchCache.set(source, c); + return c; + } + } + + // Fall back: fuzzy search in the repo + const found = await findInRepo(repoRoot, pathname); + const result = found ?? path.join(repoRoot, pathname); + repoSearchCache.set(source, result); + return result; +} + +/** + * Walk up the directory tree from `startDir` looking for a `.git` folder. + * Returns the repo root, or null if not found. + */ +function findRepoRoot(startDir) { + let dir = startDir; + while (true) { + if (existsSync(path.join(dir, ".git"))) return dir; + const parent = path.dirname(dir); + if (parent === dir) return null; // reached filesystem root + dir = parent; + } +} + +function printHelp() { + const script = path.relative(process.cwd(), fileURLToPath(import.meta.url)); + console.log( + ` +Usage: node ${script} [options] [stack-trace-file] + +If no file is given the stack trace is read from stdin. +The trace may use "+" as a space (URL-encoded form). + +Options: + --base-url Base URL for remote .map files + (default: https://app.notesnook.com/assets/) + --local Look for .map files in this local directory first + --cache-dir On-disk cache for downloaded source maps + (default: ~/.cache/notesnook/sourcemaps) + --no-cache Disable on-disk caching + --context Lines of source context per frame (default: 2, 0 to disable) + --help Show this help + `.trim() + ); +} + +// --------------------------------------------------------------------------- +// Stack frame parsing +// +// Handles frames like: +// at XB (https://example.com/assets/app-HASH.js:136:27466) +// at https://example.com/assets/app-HASH.js:12:345 (anonymous) +// at+XB+(https://example.com/assets/app-HASH.js:136:27466) (URL-encoded) +// --------------------------------------------------------------------------- + +const FRAME_RE = + /at\s+(?:async\s+)?(\S+)\s+\(?(https?:\/\/[^\s):]+):(\d+):(\d+)\)?|at\s+(?:async\s+)?(https?:\/\/[^\s):]+):(\d+):(\d+)/g; + +function parseStackTrace(raw) { + // Replace URL-encoded + with spaces so the regex works uniformly + const text = raw.replace(/\+/g, " "); + const lines = text.split(/\r?\n/); + const frames = []; + + for (const line of lines) { + FRAME_RE.lastIndex = 0; + const m = FRAME_RE.exec(line); + if (!m) { + frames.push({ raw: line, parsed: false }); + continue; + } + + if (m[2]) { + // "at symbol (url:line:col)" + frames.push({ + raw: line, + parsed: true, + symbol: m[1], + url: m[2], + line: parseInt(m[3], 10), + col: parseInt(m[4], 10) + }); + } else { + // "at url:line:col" + frames.push({ + raw: line, + parsed: true, + symbol: null, + url: m[5], + line: parseInt(m[6], 10), + col: parseInt(m[7], 10) + }); + } + } + + return frames; +} + +// --------------------------------------------------------------------------- +// Source map fetching / caching +// --------------------------------------------------------------------------- + +function fetchRemote(url) { + return new Promise((resolve, reject) => { + const client = url.startsWith("https://") ? https : http; + client + .get(url, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + return fetchRemote(res.headers.location).then(resolve).catch(reject); + } + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode} for ${url}`)); + res.resume(); + return; + } + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }) + .on("error", reject); + }); +} + +async function loadSourceMap(filename, opts) { + // 1. Try local directory first + if (opts.localDir) { + const local = path.join(opts.localDir, filename); + if (existsSync(local)) { + process.stderr.write(`Using local map: ${local}\n`); + return readFileSync(local, "utf8"); + } + } + + // 2. Try on-disk cache + if (!opts.noCache) { + mkdirSync(opts.cacheDir, { recursive: true }); + const cached = path.join(opts.cacheDir, filename); + if (existsSync(cached)) { + process.stderr.write(`Cache hit: ${filename}\n`); + return readFileSync(cached, "utf8"); + } + } + + // 3. Fetch remotely + const url = opts.baseUrl + filename; + process.stderr.write(`Fetching ${url} ...\n`); + const body = await fetchRemote(url); + + // 4. Persist to cache + if (!opts.noCache) { + const cached = path.join(opts.cacheDir, filename); + writeFileSync(cached, body); + } + + return body; +} + +// --------------------------------------------------------------------------- +// Symbol demangling helpers +// --------------------------------------------------------------------------- + +/** + * For a dotted minified symbol like "Bs.exec", walk right-to-left through + * each part and try to resolve each one via the source map by estimating the + * column offset of that identifier in the minified source. + * + * V8 reports the column of the method name (rightmost part) in the call + * expression, so we subtract (len(part) + 1) per step to back-track to each + * preceding identifier. + */ +function tryDemangleDotted(consumer, generatedLine, generatedCol, symbol) { + const parts = symbol.split("."); + if (parts.length <= 1) return null; + + const resolved = [...parts]; + let col = generatedCol; + + for (let i = parts.length - 1; i >= 0; i--) { + const pos = consumer.originalPositionFor({ line: generatedLine, column: col }); + if (pos.name) resolved[i] = pos.name; + // Step back past this identifier and the dot that precedes it + col -= parts[i].length + 1; + if (col < 0) break; + } + + const changed = resolved.some((p, i) => p !== parts[i]); + return changed ? resolved.join(".") : null; +} + +/** + * Return lines of original source context around (line, col) using the + * sourcesContent embedded in the source map. Returns null if unavailable. + */ +function getSourceContext(consumer, source, line, col, contextLines) { + if (contextLines <= 0) return null; + let content; + try { + content = consumer.sourceContentFor(source, /*returnNullOnMissing=*/ true); + } catch { + return null; + } + if (!content) return null; + + const allLines = content.split("\n"); + const first = Math.max(0, line - 1 - contextLines); + const last = Math.min(allLines.length - 1, line - 1 + contextLines); + const numWidth = String(last + 1).length; + + return allLines.slice(first, last + 1).map((text, i) => ({ + lineNum: first + i + 1, + text, + isTarget: first + i + 1 === line, + col: first + i + 1 === line ? col : null, + numWidth + })); +} + +// --------------------------------------------------------------------------- +// Lazy source-map consumer loader (avoids hard dependency; works with both +// the CommonJS "source-map" package and the WASM "source-map-js" package). +// --------------------------------------------------------------------------- + +let SourceMapConsumer; + +async function getConsumer(rawJson) { + if (!SourceMapConsumer) { + try { + const mod = await import("source-map"); + SourceMapConsumer = mod.SourceMapConsumer; + } catch { + try { + const mod = await import("source-map-js"); + SourceMapConsumer = mod.SourceMapConsumer; + } catch { + die( + 'Neither "source-map" nor "source-map-js" is installed.\n' + + "Run: npm install -g source-map or npm install -g source-map-js" + ); + } + } + } + return new SourceMapConsumer(JSON.parse(rawJson)); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const opts = parseArgs(argv); + + if (opts.help) { + printHelp(); + process.exit(0); + } + + // Read the raw stack trace + let raw; + if (opts.inputFile) { + if (!existsSync(opts.inputFile)) die(`File not found: ${opts.inputFile}`); + raw = readFileSync(opts.inputFile, "utf8"); + } else if (!process.stdin.isTTY) { + raw = readFileSync("/dev/stdin", "utf8"); + } else { + die("No input: provide a file argument or pipe a stack trace to stdin."); + } + + const frames = parseStackTrace(raw); + + // Collect unique bundle filenames that need a source map + const bundles = new Set( + frames.filter((f) => f.parsed).map((f) => path.basename(f.url) + ".map") + ); + + // Load all needed source maps in parallel + process.stderr.write("\n"); + // Determine the repo root once so we can produce clickable absolute paths. + const repoRoot = findRepoRoot(process.cwd()); + + const consumers = {}; // mapFile -> { consumer, mapUrl } + await Promise.all( + [...bundles].map(async (mapFile) => { + try { + const raw = await loadSourceMap(mapFile, opts); + const mapUrl = opts.baseUrl + mapFile; + consumers[mapFile] = { consumer: await getConsumer(raw), mapUrl }; + } catch (e) { + process.stderr.write( + `Warning: could not load ${mapFile}: ${e.message}\n` + ); + } + }) + ); + process.stderr.write("\n"); + + // Resolve and print + for (const frame of frames) { + if (!frame.parsed) { + console.log(frame.raw); + continue; + } + + const mapFile = path.basename(frame.url) + ".map"; + const entry = consumers[mapFile]; + + if (!entry) { + console.log( + ` at ${frame.symbol ?? ""} (${frame.url}:${frame.line}:${ + frame.col + }) [no source map]` + ); + continue; + } + + const { consumer, mapUrl } = entry; + const pos = consumer.originalPositionFor({ + line: frame.line, + column: frame.col + }); + + if (!pos.source) { + // Could not map — fall back to minified info + console.log( + ` at ${frame.symbol ?? ""} (${frame.url}:${frame.line}:${ + frame.col + }) [unmapped]` + ); + continue; + } + + // Resolve the best available symbol name: + // 1. name from the source map at this exact position + // 2. dotted-symbol walk (e.g. "Bs.exec" → "SqliteWorker.exec") + // 3. original minified symbol as fallback + const minifiedSymbol = frame.symbol ?? ""; + const name = + (minifiedSymbol.includes(".") + ? tryDemangleDotted(consumer, frame.line, frame.col, minifiedSymbol) + : null) ?? + pos.name ?? + minifiedSymbol; + + // Resolve to an absolute (clickable) path. + const src = await resolveSourcePathAsync(pos.source, mapUrl, repoRoot); + console.log(` at ${name} (${src}:${pos.line}:${pos.column})`); + + // Source context + const ctx = getSourceContext(consumer, pos.source, pos.line, pos.column, opts.context); + if (ctx) { + for (const l of ctx) { + const gutter = l.isTarget ? ">" : " "; + const num = String(l.lineNum).padStart(l.numWidth); + console.log(` ${gutter} ${num} | ${l.text}`); + } + console.log(); + } + } + + // Clean up WASM consumers if applicable + for (const { consumer: c } of Object.values(consumers)) { + if (typeof c.destroy === "function") c.destroy(); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +});