diff --git a/.obsidian/plugins/webinar-dash/main.js b/.obsidian/plugins/webinar-dash/main.js index 1ab45cb..02fe3bc 100644 --- a/.obsidian/plugins/webinar-dash/main.js +++ b/.obsidian/plugins/webinar-dash/main.js @@ -156,6 +156,19 @@ function reconcileConcepts(rows, conceptNames) { }; } +// `shell: true` is required on Windows to resolve `claude.cmd`, which puts the +// filename into a shell string. Reject anything cmd.exe or a POSIX shell would +// interpret. Apostrophes and cyrillic are safe inside double quotes and are +// present in real filenames, so they stay allowed. +const UNSAFE_CHARS = /["`$&|;<>%\r\n]/; + +function isSafeFilename(name) { + if (typeof name !== "string" || name.length === 0) return false; + if (UNSAFE_CHARS.test(name)) return false; + if (name.includes("..")) return false; + return true; +} + function parseConfig(source) { const cfg = Object.assign({}, DEFAULTS); for (const line of String(source).split(/\r?\n/)) { @@ -341,7 +354,7 @@ class WebinarDashPlugin extends PluginBase { const root = el.createDiv({ cls: "webinar-dash" }); try { const pipeline = await this.readPipeline(cfg); - renderLeftPane(root, pipeline, () => {}); + renderLeftPane(root, pipeline, (file, rowEl) => this.runIngest(file, rowEl)); } catch (err) { root.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` }); } @@ -390,6 +403,91 @@ class WebinarDashPlugin extends PluginBase { return derivePipeline({ rawFiles, sourcePages }); } + + vaultPath() { + const adapter = this.app.vault.adapter; + if (typeof adapter.getBasePath === "function") return adapter.getBasePath(); + return null; + } + + runIngest(file, rowEl) { + if (rowEl.dataset.wdRunning === "1") return; + + const Notice = OB ? OB.Notice : null; + const notify = (msg) => { if (Notice) new Notice(msg); }; + + if (!isSafeFilename(file.name)) { + rowEl.createDiv({ + cls: "wd-output", + text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`, + }); + return; + } + + const base = this.vaultPath(); + if (!base) { + notify("Ingest needs desktop Obsidian."); + return; + } + + let spawn; + try { + ({ spawn } = require("child_process")); + } catch (_) { + notify("child_process unavailable — ingest needs desktop Obsidian."); + return; + } + + rowEl.dataset.wdRunning = "1"; + const button = rowEl.querySelector("button"); + if (button) button.disabled = true; + + const status = rowEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" }); + const started = Date.now(); + const timer = window.setInterval(() => { + status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`); + }, 1000); + + const output = rowEl.createDiv({ cls: "wd-output", text: "" }); + let buffered = ""; + const append = (chunk) => { + buffered += chunk.toString(); + output.setText(buffered.slice(-4000)); + output.scrollTop = output.scrollHeight; + }; + + const child = spawn("claude", ["-p", `ingest "${file.name}"`], { + cwd: base, + shell: true, + }); + + child.stdout.on("data", append); + child.stderr.on("data", append); + + child.on("error", (err) => { + window.clearInterval(timer); + status.className = "wd-status wd-status-failed"; + status.setText("failed"); + append(`\nCould not start claude: ${err.message}\nIs it on PATH?`); + rowEl.dataset.wdRunning = "0"; + if (button) button.disabled = false; + }); + + child.on("close", (code) => { + window.clearInterval(timer); + const secs = Math.round((Date.now() - started) / 1000); + if (code === 0) { + status.className = "wd-status wd-status-done"; + status.setText(`done in ${secs}s`); + notify(`Ingested ${file.name}. Reopen the dashboard to refresh.`); + } else { + status.className = "wd-status wd-status-failed"; + status.setText(`failed - exit ${code}`); + } + rowEl.dataset.wdRunning = "0"; + if (button) button.disabled = false; + }); + } } module.exports = WebinarDashPlugin; @@ -397,5 +495,5 @@ module.exports.default = WebinarDashPlugin; module.exports.__test__ = { parseConfig, extractRawPath, derivePipeline, parseCoverageTable, groupByStation, reconcileConcepts, - STATIONS, DEFAULTS, + STATIONS, DEFAULTS, isSafeFilename, }; diff --git a/.obsidian/plugins/webinar-dash/styles.css b/.obsidian/plugins/webinar-dash/styles.css index 52e532d..6026fe4 100644 --- a/.obsidian/plugins/webinar-dash/styles.css +++ b/.obsidian/plugins/webinar-dash/styles.css @@ -160,3 +160,14 @@ .wd-partial { color: var(--wd-warn); } .wd-absent { color: var(--wd-danger); } .wd-pin { font-family: var(--wd-mono); font-size: 10px; color: var(--wd-fg-4); } + +.wd-status { font-family: var(--wd-mono); font-size: 11px; flex: none; } +.wd-status-running { color: var(--wd-warn); } +.wd-status-done { color: var(--wd-ok); } +.wd-status-failed { color: var(--wd-danger); } +.wd-output { + font-family: var(--wd-mono); font-size: 11px; white-space: pre-wrap; + max-height: 220px; overflow: auto; margin-top: 8px; + border: 1px solid var(--wd-border); border-radius: 4px; padding: 8px; + color: var(--wd-fg-2); +} diff --git a/.obsidian/plugins/webinar-dash/test/safety.test.js b/.obsidian/plugins/webinar-dash/test/safety.test.js new file mode 100644 index 0000000..6dc2756 --- /dev/null +++ b/.obsidian/plugins/webinar-dash/test/safety.test.js @@ -0,0 +1,39 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { isSafeFilename } = require("../main.js").__test__; + +test("isSafeFilename accepts every filename currently in the vault", () => { + const real = [ + "Agentic Engineering, explained by a 10x developer.md", + "Webinar Plan - From Chat Box to Your Own OS.md", + "Webinar script.md", + "You're reading way too much code.md", + "ИИ глупый!.md", + "Скиллы на базе git — новая память AI-агентов.md", + "sebastian interview - conclusions and insights.md", + "In 1 Year, the Gap Between AI Users and Everyone Else Will Be Irreversible.md", + ]; + for (const name of real) { + assert.equal(isSafeFilename(name), true, `should accept: ${name}`); + } +}); + +test("isSafeFilename rejects shell metacharacters", () => { + for (const bad of ['a".md', "a`b.md", "a$b.md", "a&b.md", "a|b.md", "a;b.md", + "ab.md", "a%b.md", "a\nb.md", "a\rb.md"]) { + assert.equal(isSafeFilename(bad), false, `should reject: ${JSON.stringify(bad)}`); + } +}); + +test("isSafeFilename rejects path traversal", () => { + assert.equal(isSafeFilename("../secrets.md"), false); + assert.equal(isSafeFilename("a/../../b.md"), false); +}); + +test("isSafeFilename rejects empty and non-string input", () => { + assert.equal(isSafeFilename(""), false); + assert.equal(isSafeFilename(null), false); + assert.equal(isSafeFilename(undefined), false); + assert.equal(isSafeFilename(42), false); +});