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;
+}
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) {
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);
+});