diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9ddef177..f7e91016 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -38,15 +38,32 @@ yarn start ## Architecture -| Part | Location | Runs in | +| Part | Location | Runs in | Owns | +| --- | --- | --- | --- | +| Frontend | `src/kc_angular` | Electron renderer or a browser | Navigation, presentation, editing, temporary UI state, API calls | +| Backend (storage service) | `src/kc_storage` | Separate Node.js process | Projects, sources, managed files, validation, persistence, backup and restore, browser sessions, browser UI files | +| Desktop | `src/kc_electron` | Electron main process | Windows, native file access by path, watched folders, thumbnails and file icons, embedded browser, drag-out, backend start and stop. Also the chat server (for now). | +| Contracts | `src/kc_contracts`, `src/kc_shared` | Shared | Data types, record mapping, default settings. No Angular or Electron imports. | + +The frontend reaches platform functions only through small capability +interfaces in `src/kc_angular/src/app/platform/`: + +| Interface | Desktop | Browser | | --- | --- | --- | -| UI | `src/kc_angular` | Electron renderer | -| Desktop functions, chat server | `src/kc_electron` | Electron main process | -| Storage service | `src/kc_storage` | Separate Node.js process | -| API contracts | `src/kc_contracts` | Shared. No application imports. | +| `Platform` | All features | Library features; desktop-only actions are hidden or disabled with a reason | +| `BackendService` | IPC addresses, bearer tokens | Same origin, session cookie, CSRF header | +| `SettingsStore` | Electron settings file | `localStorage` | +| `WindowControls` | Minimize, maximize, zoom | Not available | +| `NativeFiles` | Original file path, copy by path, open in default app | Not available | +| `ManagedFiles` | `blob:` view, default app | Same-origin URL with filename, new tab | -The design of the storage service is in -[docs/storage-service.md](docs/storage-service.md). +Both clients import files the same way: the user selects a `File`, the +frontend uploads its bytes (`POST /v1/assets`), and the source stores the +asset ID. IDs come from `crypto.randomUUID()`. + +Designs: [docs/storage-service.md](docs/storage-service.md), +[docs/desktop-storage-completion.md](docs/desktop-storage-completion.md), +[docs/browser-library.md](docs/browser-library.md). ### Data ownership @@ -70,7 +87,7 @@ addresses and tokens through IPC (`A2E:Backend:Info`). ### Access boundary -- Verified: a request without the correct token gets `401`. The window of +- Verified: a request without the correct token or browser session gets `401`. The window of one instance does not know the address or token of another instance, so it cannot use the other instance's servers by mistake. - Verified: the servers do not accept connections from other computers. @@ -78,6 +95,8 @@ addresses and tokens through IPC (`A2E:Backend:Info`). program can read the token (for example, from the storage service's environment, `KC_STORAGE_TOKEN`), or read the profile files directly. The real boundary is the operating-system user account. +- Verified (browser client): another local site in the same browser cannot + read the API or write to it, and its requests leave no records or files. - The browser extension server (port 9000, off by default) has no token. Only one instance can use a profile at a time. A second start with the same @@ -130,6 +149,48 @@ out of the autoscan folder. Make a backup first. Do not confuse `~/Library/Application Support/Knowledge`. macOS owns that directory (`knowledgeC.db`). Knowledge does not use it. +## Browser client (no Electron) + +```sh +yarn build-angular-dev +yarn browser +``` + +`yarn browser` starts the storage service with the built UI and prints a +one-time launch link, for example +`http://127.0.0.1:53124/#launch=...`. Open it in Chrome. Press Enter in the +terminal for a new link. Press Ctrl+C to stop. + +Options: `--data-dir
`. The page reads the code from the URL fragment (not sent to servers, not in server logs), removes it from the address bar, and exchanges it with `POST /v1/session`. |
+| What is the launch code? | 256 random bits. Single use. Expires 5 minutes after creation. Only a bearer-token client can create one (`POST /v1/session/launch`). |
+| How are requests authenticated? | Cookie `kc_session` (256 random bits, `HttpOnly`, `SameSite=Strict`, `Path=/`). The service keeps sessions in memory. Desktop clients keep using the bearer token. |
+| How are state-changing requests protected? | For cookie sessions, `POST`, `PUT`, and `DELETE` need all of: header `X-Knowledge-CSRF` equal to the session's CSRF token, and `Origin` equal to the service origin. Any cookie request with `Sec-Fetch-Site: cross-site` is refused. `SameSite=Strict` stops the browser from sending the cookie from other sites. The CSRF token lives only in page memory. After a reload, `GET /v1/session` returns it. |
+| How does a session end? | 30 minutes without requests, 12 hours after creation, `DELETE /v1/session`, or a service restart (sessions are in memory). |
+| What stays outside URLs, storage, and logs? | The bearer token. The session ID is only in an `HttpOnly` cookie. The launch code is in the terminal output and in the first URL only; it is single use and short-lived. |
+| Other checks | `Host` must be `127.0.0.1:` or `localhost:` (DNS rebinding). The service listens on `127.0.0.1` only. CORS allows only the desktop origin `null`. CORS and loopback are not authentication. |
+
+Trust boundary: the operating-system user. Programs that run as the same
+user can read process environments, the profile files, and the terminal
+output. This design does not protect against them, and it is not a design
+for a hosted service.
+
+## Managed file display
+
+- The service serves `GET /v1/assets//content/`. The last part
+ sets the name that the browser PDF viewer and downloads show.
+- Inline only for PDF, plain text, and raster images. Text is served as
+ `text/plain`. All other types are downloads (`Content-Disposition:
+ attachment`). Every response has `X-Content-Type-Options: nosniff` and a
+ sandbox `Content-Security-Policy`, except PDF. So imported files cannot
+ run scripts with the application's origin.
+- The browser shows PDFs with its own viewer.
+
+## Tests
+
+- Service: sessions (launch code single use and expiry, cookie flags, CSRF
+ header, Origin, cross-site fetch metadata, idle and absolute expiry,
+ logout), bearer clients unchanged, UI file serving, content headers.
+- Browser (Chrome through `playwright-core`, no Electron): the workflow
+ above, restart and reopen, unauthorized requests.
+- Existing service, desktop, and packaged tests.
+
+## Next extraction steps (not in this milestone)
+
+- Chat: move the chat routes and OpenAI calls from the Electron chat server
+ into the backend. Keep the API key in the backend data directory.
+- Extraction: move website fetching and PDF text extraction into the
+ backend, so the browser can save websites.
+
+## Verified (2026-10-07, macOS 26.6 arm64, Google Chrome 154)
+
+| Command | Result |
+| --- | --- |
+| `yarn build-dev` | Pass |
+| `yarn test` | 8 of 8 pass |
+| `yarn test-storage` | 28 of 28 pass (6 new session and UI-serving tests) |
+| `yarn workspace kc_storage typecheck` | Pass |
+| `yarn e2e` | 2 of 2 pass (desktop) |
+| `yarn e2e-browser` | 1 of 1 pass (Chrome, no Electron) |
+| `yarn package-local`, `yarn e2e-packaged` | Pass |
+
+Not verified: Safari, Firefox, and Chrome on other systems.
diff --git a/docs/follow-ups.md b/docs/follow-ups.md
index a544d3a2..04e679b4 100644
--- a/docs/follow-ups.md
+++ b/docs/follow-ups.md
@@ -7,12 +7,11 @@ milestones. Not started.
| Item | Reason |
| --- | --- |
-| Orphan asset cleanup policy | Deleting a source keeps its managed file. Only files without records after a rollback, crash, or failed restore are removed. |
+| Orphan asset cleanup policy | Deleting a source keeps its managed file. Files are uploaded when selected, so a file removed from the inbox also leaves a managed file. Only files without records after a rollback, crash, or failed restore are removed. |
| Restore into a library that has records (merge or replace) | Restore refuses a non-empty library now. |
| Move the extracted-text cache into the service | The chat server keeps it in `data/storage/sources/`, outside the library. |
| Thumbnails in the service | Electron makes thumbnails from a local copy of the managed file. |
| Move favicon cache, inbox, and chat history out of local storage | They stay in the renderer for this milestone. |
-| Remove `kc_shared` dependency on Angular models | `kc_shared/models/project.model.ts` imports an Angular model. Electron needs the `@shared` alias because of it. |
| Stronger local access control | The token stops requests without it. Programs that run as the same OS user can read it. |
## Packaging and release
@@ -35,9 +34,18 @@ milestones. Not started.
| Replace the fixed API key passphrase | The key file is obfuscated, not encrypted. |
| Root `postinstall` key is outside `scripts` | It never runs. Remove it or move it. |
| Fresh-profile route error (`inbox/undefined`) | Cosmetic error in the log on first start. |
-| PDF viewer title shows the blob ID | Managed PDFs open from a `blob:` URL. |
+| Desktop PDF viewer title shows the blob ID | The desktop shows managed PDFs from a `blob:` URL. The browser client shows the filename. |
| Select a project after a library restore | After restore, no project is selected. The user selects one in the tree. |
+## Browser client
+
+| Item | Reason |
+| --- | --- |
+| Save websites in the browser | Needs website fetching and extraction in the backend. |
+| Chat in the browser | Needs the chat server in the backend. |
+| Browser settings and inbox | Kept in browser local storage, separate from the desktop. |
+| Other browsers | Only Google Chrome is tested. |
+
## Features (separate tasks)
- Full-text search over extracted text.
diff --git a/e2e/browser/library.e2e.mjs b/e2e/browser/library.e2e.mjs
new file mode 100644
index 00000000..137fb471
--- /dev/null
+++ b/e2e/browser/library.e2e.mjs
@@ -0,0 +1,338 @@
+/*
+ * The library workflow in Google Chrome, without Electron.
+ *
+ * The test starts scripts/start-browser.mjs (the "yarn browser" launcher)
+ * with a scratch library, opens the printed launch link in Chrome, and runs:
+ * create a project, upload a PDF and a text file, show both, annotate,
+ * search, restart the service and reopen the browser, open the managed
+ * files, export the library, and restore it into a second, empty library.
+ * It also checks unauthorized and cross-origin requests.
+ *
+ * Build first: yarn build-angular-dev
+ * Run: yarn e2e-browser
+ */
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { spawn } from "node:child_process";
+import { chromium } from "playwright-core";
+import { FIXTURES, REPO, newRun, startFixtureSite } from "../lib.mjs";
+import * as ui from "../steps.mjs";
+
+const PROJECT = "Browser Project";
+const PDF = "recovery-fixture.pdf";
+const TXT = "recovery-note.txt";
+
+/** Start the launcher. Resolves with its first launch link and output. */
+async function startLauncher(dataDir, log) {
+ const child = spawn(
+ process.execPath,
+ [path.join(REPO, "scripts/start-browser.mjs"), "--data-dir", dataDir],
+ {
+ cwd: REPO,
+ stdio: ["pipe", "pipe", "pipe"],
+ }
+ );
+ let output = "";
+ const write = (d) => {
+ output += d;
+ fs.appendFileSync(log, d);
+ };
+ child.stdout.on("data", write);
+ child.stderr.on("data", write);
+ const url = await new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error(`No launch link:\n${output}`)),
+ 30000
+ );
+ child.stdout.on("data", () => {
+ const m = output.match(
+ /http:\/\/127\.0\.0\.1:\d+\/#launch=[A-Za-z0-9_-]+/
+ );
+ if (m) {
+ clearTimeout(timer);
+ resolve(m[0]);
+ }
+ });
+ child.on("exit", (code) =>
+ reject(new Error(`Launcher exited (${code}):\n${output}`))
+ );
+ });
+ return {
+ url,
+ origin: new URL(url).origin,
+ output: () => output,
+ async stop() {
+ if (child.exitCode !== null) return;
+ child.kill("SIGINT");
+ await new Promise((r) => child.on("exit", r));
+ },
+ };
+}
+
+/** Projects and sources through the page's own session (same origin). */
+function readLibrary(page) {
+ return page.evaluate(async () => {
+ const get = (r) => fetch(`/v1/${r}`).then((res) => res.json());
+ const { projects } = await get("projects");
+ const { sources } = await get("sources");
+ return projects.map((p) => ({
+ name: p.name,
+ sources: sources
+ .filter((s) => s.projectId === p.id)
+ .map((s) => ({
+ title: s.title,
+ type: s.ingestType,
+ assetId: s.assetId,
+ originalPath: s.data.reference?.source?.file?.path ?? null,
+ topics: s.data.topics ?? [],
+ annotations: (s.data.meta ?? [])
+ .filter((m) => m.key === "annotation")
+ .map((m) => m.value),
+ }))
+ .sort((a, b) => a.title.localeCompare(b.title)),
+ }));
+ });
+}
+
+async function openBrowser(url) {
+ const browser = await chromium.launch({ channel: "chrome", headless: true });
+ const context = await browser.newContext({
+ viewport: { width: 1600, height: 1000 },
+ acceptDownloads: true,
+ });
+ const page = await context.newPage();
+ const errors = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ page.on("dialog", (d) => {
+ errors.push(`dialog: ${d.message()}`);
+ d.dismiss();
+ });
+ await page.goto(url);
+ await page.waitForSelector("app-create button", { timeout: 30000 });
+ return { browser, context, page, errors };
+}
+
+test(
+ "library workflow in Chrome without Electron",
+ { timeout: 300000 },
+ async () => {
+ const run = newRun("browser");
+ const libraryA = path.join(run, "library-a");
+ const libraryB = path.join(run, "library-b");
+ const log = path.join(run, "launcher.log");
+ const shot = (page, name) =>
+ page.screenshot({ path: path.join(run, `${name}.png`) });
+ const pdfBytes = fs.readFileSync(path.join(FIXTURES, PDF));
+ const txtBytes = fs.readFileSync(path.join(FIXTURES, TXT));
+ const other = await startFixtureSite();
+
+ let launcher;
+ let b;
+ try {
+ // 1. Service and UI, no Electron
+ launcher = await startLauncher(libraryA, log);
+ b = await openBrowser(launcher.url);
+ assert.equal(await b.page.evaluate(() => typeof window.api), "undefined");
+ assert.ok(
+ !b.page.url().includes("launch="),
+ "the launch code stays in the address bar"
+ );
+
+ // Desktop-only actions are disabled or hidden with an explanation
+ const linkButton = b.page.locator("app-create button").nth(2);
+ assert.ok(await linkButton.isDisabled());
+ assert.match(await linkButton.getAttribute("title"), /desktop app/);
+
+ // 2-3. Create a project, upload a PDF and a text file
+ await ui.createProject(b.page, PROJECT, "Browser workflow");
+ await ui.importFilesToProject(b.page, [
+ path.join(FIXTURES, PDF),
+ path.join(FIXTURES, TXT),
+ ]);
+ await b.page.waitForTimeout(1500);
+
+ // 4. Show both sources from the managed copies
+ await ui.openTable(b.page);
+ assert.deepEqual((await ui.tableTitles(b.page)).sort(), [PDF, TXT]);
+ for (const name of [PDF, TXT]) {
+ await ui.openSource(b.page, name);
+ await ui.openDocumentTab(b.page);
+ const embed = b.page.locator("source-document embed");
+ await embed.waitFor({ timeout: 15000 });
+ const src = await embed.getAttribute("src");
+ assert.match(
+ src,
+ new RegExp(
+ `^/v1/assets/[0-9a-f-]{36}/content/${name.replace(".", "\\.")}$`
+ )
+ );
+ await b.page.waitForTimeout(1500);
+ await shot(b.page, `a-document-${name}`);
+ await ui.closeDialog(b.page);
+ }
+
+ // 5. Annotate
+ await ui.openSource(b.page, TXT);
+ await ui.addAnnotation(b.page, "annotation", "browser note");
+ await ui.addTopic(b.page, "browsertopic");
+ await b.page.waitForTimeout(1500);
+ await ui.closeDialog(b.page);
+
+ // 6. Search
+ const results = await ui.search(b.page, "recovery-note");
+ assert.ok(
+ results.some((r) => r.includes(TXT)),
+ `search results: ${results}`
+ );
+
+ const library = await readLibrary(b.page);
+ assert.equal(library.length, 1);
+ const note = library[0].sources.find((s) => s.title === TXT);
+ assert.deepEqual(note.topics, ["browsertopic"]);
+ assert.deepEqual(note.annotations, ["browser note"]);
+ for (const s of library[0].sources) {
+ assert.match(s.assetId, /^[0-9a-f-]{36}$/);
+ assert.equal(s.originalPath, "", "the browser sends no local path");
+ }
+
+ // Unauthorized: no session cookie
+ const anonymous = await b.browser.newContext();
+ assert.equal(
+ (
+ await anonymous.request.get(`${launcher.origin}/v1/projects`)
+ ).status(),
+ 401
+ );
+ assert.equal(
+ (
+ await anonymous.request.get(
+ `${launcher.origin}/v1/assets/${note.assetId}/content/${TXT}`
+ )
+ ).status(),
+ 401
+ );
+ await anonymous.close();
+
+ // Another local site in the same browser cannot read or write
+ const attacker = await b.context.newPage();
+ await attacker.goto(other.url);
+ const attack = await attacker.evaluate(async (target) => {
+ const out = {};
+ try {
+ await fetch(`${target}/v1/projects`, { credentials: "include" });
+ out.read = "readable";
+ } catch {
+ out.read = "blocked";
+ }
+ await fetch(`${target}/v1/assets`, {
+ method: "POST",
+ mode: "no-cors",
+ credentials: "include",
+ headers: { "Content-Type": "text/plain" },
+ body: "planted",
+ }).catch(() => {});
+ try {
+ await fetch(`${target}/v1/projects/planted`, {
+ method: "PUT",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "planted", parentId: null, data: {} }),
+ });
+ out.write = "sent";
+ } catch {
+ out.write = "blocked";
+ }
+ return out;
+ }, launcher.origin);
+ await attacker.close();
+ assert.equal(attack.read, "blocked");
+ assert.equal(attack.write, "blocked");
+ const status = await b.page.evaluate(() =>
+ fetch("/v1/library").then((r) => r.json())
+ );
+ assert.deepEqual(
+ status.counts,
+ { projects: 1, sources: 2, assets: 2 },
+ "no planted records or files"
+ );
+
+ // The launcher never prints the bearer token
+ assert.doesNotMatch(launcher.output(), /[0-9a-f]{64}/);
+ await b.browser.close();
+ b = undefined;
+
+ // 7. Restart the service and reopen the browser
+ await launcher.stop();
+ launcher = await startLauncher(libraryA, log);
+ b = await openBrowser(launcher.url);
+
+ // 8. The records remain
+ assert.deepEqual(await readLibrary(b.page), library);
+ await ui.selectProject(b.page, PROJECT);
+
+ // 9. Open the managed files (browser: a new tab with the filename)
+ await ui.openTable(b.page);
+ for (const [name, bytes] of [
+ [PDF, pdfBytes],
+ [TXT, txtBytes],
+ ]) {
+ const assetId = library[0].sources.find(
+ (s) => s.title === name
+ ).assetId;
+ const popup = b.context.waitForEvent("page");
+ await b.page.evaluate(
+ ({ id, file }) =>
+ window.open(
+ `/v1/assets/${id}/content/${encodeURIComponent(file)}`,
+ "_blank",
+ "noopener"
+ ),
+ { id: assetId, file: name }
+ );
+ const tab = await popup;
+ assert.ok(tab.url().endsWith(`/content/${name}`), tab.url());
+ const res = await b.context.request.get(tab.url());
+ assert.equal(res.status(), 200);
+ assert.ok((await res.body()).equals(bytes), `${name} bytes`);
+ await tab.close();
+ }
+
+ // 10. Export the library through the UI
+ await ui.openSettings(b.page, "Backup");
+ const download = b.page.waitForEvent("download");
+ await b.page.locator("button", { hasText: "Export Library" }).click();
+ const tar = path.join(run, "library.tar");
+ await (await download).saveAs(tar);
+ await b.browser.close();
+ b = undefined;
+ await launcher.stop();
+
+ // ...and restore it through the UI into a second, empty library
+ launcher = await startLauncher(libraryB, log);
+ b = await openBrowser(launcher.url);
+ const restore = await ui.restoreLibrary(b.page, tar);
+ assert.match(restore.preview, /1 project, 2 sources, 2 files/);
+ assert.match(
+ restore.result,
+ /Restored 1 project, 2 sources, and 2 files/
+ );
+ assert.deepEqual(await readLibrary(b.page), library);
+ await ui.selectProject(b.page, PROJECT);
+ await ui.openTable(b.page);
+ await ui.openSource(b.page, PDF);
+ await ui.openDocumentTab(b.page);
+ await b.page.locator("source-document embed").waitFor({ timeout: 15000 });
+ await b.page.waitForTimeout(1500);
+ await shot(b.page, "b-restored-pdf");
+
+ assert.deepEqual(b.errors, [], "page errors or dialogs");
+ } finally {
+ if (b) await b.browser.close();
+ if (launcher) await launcher.stop();
+ other.close();
+ }
+ }
+);
diff --git a/e2e/workflow.e2e.mjs b/e2e/workflow.e2e.mjs
index 67d44959..01f3dccc 100644
--- a/e2e/workflow.e2e.mjs
+++ b/e2e/workflow.e2e.mjs
@@ -94,6 +94,17 @@ test(
assert.equal(byTitle[SITE_TITLE].assetId, null);
assert.ok(byTitle["recovery-fixture.pdf"].assetId);
assert.ok(byTitle["recovery-note.txt"].assetId);
+
+ // The desktop sends the original path as metadata (from the selected File)
+ const pdfAssetMeta = await ctx.page.evaluate(async (id) => {
+ const { storage } = await window.api.invoke("A2E:Backend:Info");
+ const res = await fetch(`${storage.url}/v1/assets/${id}`, {
+ headers: { Authorization: `Bearer ${storage.token}` },
+ });
+ return (await res.json()).asset;
+ }, byTitle["recovery-fixture.pdf"].assetId);
+ assert.equal(pdfAssetMeta.originalPath, pdf);
+ assert.equal(pdfAssetMeta.filename, "recovery-fixture.pdf");
assert.deepEqual(byTitle["recovery-note.txt"].topics, ["recoverytopic"]);
assert.deepEqual(byTitle["recovery-note.txt"].annotations, [
"mentions chlorophyll",
diff --git a/package.json b/package.json
index e628fc8c..b2c99f2d 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,8 @@
"e2e": "node --test --test-concurrency=1 \"e2e/*.e2e.mjs\"",
"e2e-packaged": "node --test --test-concurrency=1 \"e2e/packaged/*.e2e.mjs\"",
"storage": "node src/kc_storage/src/main.ts",
+ "browser": "node scripts/start-browser.mjs",
+ "e2e-browser": "node --test --test-concurrency=1 \"e2e/browser/*.e2e.mjs\"",
"test-storage": "yarn workspace kc_storage test",
"watch-main": "cd src/kc_angular && yarn run watch-main",
"watch-main-dev": "cd src/kc_angular && yarn run watch-main-dev",
diff --git a/scripts/start-browser.mjs b/scripts/start-browser.mjs
new file mode 100644
index 00000000..feee5c90
--- /dev/null
+++ b/scripts/start-browser.mjs
@@ -0,0 +1,95 @@
+/*
+ * Start the storage service with the browser UI. No Electron.
+ *
+ * yarn browser [--data-dir ] [--web-root ] [--open]
+ *
+ * Defaults: --data-dir .dev-profiles/browser/library,
+ * --web-root src/kc_angular/dist/main (run yarn build-angular-dev first).
+ *
+ * The bearer token stays in this process and the service environment. It
+ * is never printed. The script prints a one-time launch link. Press Enter
+ * for a new link (for example, after a session ends). Press Ctrl+C to stop.
+ */
+
+import crypto from "node:crypto";
+import path from "node:path";
+import readline from "node:readline";
+import { spawn, execFile } from "node:child_process";
+import { parseArgs } from "node:util";
+
+const ROOT = path.resolve(import.meta.dirname, "..");
+const { values } = parseArgs({
+ options: {
+ "data-dir": {
+ type: "string",
+ default: path.join(ROOT, ".dev-profiles", "browser", "library"),
+ },
+ "web-root": {
+ type: "string",
+ default: path.join(ROOT, "src", "kc_angular", "dist", "main"),
+ },
+ open: { type: "boolean", default: false },
+ },
+});
+
+const token = crypto.randomBytes(32).toString("hex");
+const service = spawn(
+ process.execPath,
+ [
+ path.join(ROOT, "src", "kc_storage", "src", "main.ts"),
+ "serve",
+ "--data-dir",
+ path.resolve(values["data-dir"]),
+ "--web-root",
+ path.resolve(values["web-root"]),
+ "--exit-on-stdin-close",
+ ],
+ {
+ env: { ...process.env, KC_STORAGE_TOKEN: token },
+ stdio: ["pipe", "pipe", "inherit"],
+ }
+);
+
+const ready = await new Promise((resolve, reject) => {
+ let out = "";
+ service.stdout.on("data", (d) => {
+ out += d;
+ const line = out.split("\n")[0];
+ if (out.includes("\n")) resolve(JSON.parse(line));
+ });
+ service.on("exit", (code) =>
+ reject(new Error(`The storage service stopped (code ${code}).`))
+ );
+});
+
+async function printLaunchLink() {
+ const res = await fetch(`${ready.url}/v1/session/launch`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok)
+ throw new Error(`Could not create a launch link (${res.status}).`);
+ const { url, expiresAt } = await res.json();
+ console.log(
+ `\nOpen this link in your browser (one use, expires ${expiresAt}):\n\n ${url}\n`
+ );
+ console.log("Press Enter for a new link. Press Ctrl+C to stop.");
+ return url;
+}
+
+console.log(`Library: ${ready.dataDir}`);
+const first = await printLaunchLink();
+if (values.open && process.platform === "darwin") {
+ execFile("open", [first]);
+}
+
+const rl = readline.createInterface({ input: process.stdin });
+rl.on("line", () => printLaunchLink().catch((e) => console.error(e.message)));
+
+const stop = () => {
+ service.stdin.end();
+ service.kill("SIGTERM");
+};
+process.on("SIGINT", stop);
+process.on("SIGTERM", stop);
+service.on("exit", (code) => process.exit(code ?? 0));
diff --git a/src/kc_angular/src/app/app.component.html b/src/kc_angular/src/app/app.component.html
index 87affd7b..bf52f0d9 100755
--- a/src/kc_angular/src/app/app.component.html
+++ b/src/kc_angular/src/app/app.component.html
@@ -21,7 +21,7 @@
style="height: 32px; max-width: 100vw"
>
-
+
@@ -45,9 +45,9 @@
-
+
@@ -264,7 +272,9 @@ export class HomeComponent implements OnDestroy {
collapsed = false;
ksMenuItems: MenuItem[] = [];
loading = false;
- supportedTypes: string[] = ['Links', 'Files'];
+ supportedTypes: string[] = this.platform.has('saveWebsite')
+ ? ['Links', 'Files']
+ : ['Files'];
animate = true;
importAll = false;
reset = false;
@@ -284,7 +294,8 @@ export class HomeComponent implements OnDestroy {
private menu: KsContextMenuService,
private notifications: NotificationsService,
private tree: ProjectTreeFactoryService,
- private settings: SettingsService
+ private settings: SettingsService,
+ public platform: Platform
) {
this.kcProject = projects.currentProject;
diff --git a/src/kc_angular/src/app/components/settings/display-settings.component.ts b/src/kc_angular/src/app/components/settings/display-settings.component.ts
index b62972f5..7a5c4e55 100755
--- a/src/kc_angular/src/app/components/settings/display-settings.component.ts
+++ b/src/kc_angular/src/app/components/settings/display-settings.component.ts
@@ -165,8 +165,8 @@ export class DisplaySettingsComponent {
{ name: 'Debug', value: 'debug' },
];
form: FormGroup;
- private send = window.api.send;
- private receive = window.api.receive;
+ private send = window.api?.send ?? (() => undefined);
+ private receive = window.api?.receive ?? (() => undefined);
private channels = {
zoomIn: 'A2E:Window:ZoomIn',
zoomOut: 'A2E:Window:ZoomOut',
diff --git a/src/kc_angular/src/app/components/settings/settings.component.ts b/src/kc_angular/src/app/components/settings/settings.component.ts
index a88f4942..a7d12422 100755
--- a/src/kc_angular/src/app/components/settings/settings.component.ts
+++ b/src/kc_angular/src/app/components/settings/settings.component.ts
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { Platform } from '@app/platform/platform';
import { Component } from '@angular/core';
import { MenuItem } from 'primeng/api';
@@ -52,6 +53,15 @@ import { MenuItem } from 'primeng/api';
],
})
export class SettingsComponent {
+ constructor(private platform: Platform) {
+ // Import (watched folders, extensions) and Chat are desktop features
+ this.modules = this.modules.filter(
+ (m) =>
+ (m.label !== 'Import' || platform.has('importSettings')) &&
+ (m.label !== 'Chat' || platform.has('chat'))
+ );
+ }
+
modules: MenuItem[] = [
{
label: 'Display',
diff --git a/src/kc_angular/src/app/components/shared/create.component.ts b/src/kc_angular/src/app/components/shared/create.component.ts
index b825741e..fbb4bd9d 100755
--- a/src/kc_angular/src/app/components/shared/create.component.ts
+++ b/src/kc_angular/src/app/components/shared/create.component.ts
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { Platform } from '@app/platform/platform';
import { Component, SecurityContext } from '@angular/core';
import { ProjectCreationDialogComponent } from '../project-components/project-creation-dialog.component';
import { DialogService } from 'primeng/dynamicdialog';
@@ -86,6 +87,8 @@ import { ConfirmationService } from 'primeng/api';
[tipShowOnHover]="true"
icon="pi pi-link"
class="p-button-text outline-none shadow-none non-header"
+ [disabled]="!platform.has('saveWebsite')"
+ [title]="platform.unavailable('saveWebsite')"
(click)="showImportDialog = true"
>
+
@@ -158,7 +161,8 @@ export class CreateComponent {
private favicon: FaviconService,
private formBuilder: FormBuilder,
private sanitizer: DomSanitizer,
- private httpClient: HttpClient
+ private httpClient: HttpClient,
+ public platform: Platform
) {
this.linkForm = formBuilder.group({
url: ['', [Validators.required, Validators.pattern('https?://.+[.]+.+')]],
diff --git a/src/kc_angular/src/app/components/source-components/ks-preview.component.ts b/src/kc_angular/src/app/components/source-components/ks-preview.component.ts
index 2c29f091..e124c91f 100755
--- a/src/kc_angular/src/app/components/source-components/ks-preview.component.ts
+++ b/src/kc_angular/src/app/components/source-components/ks-preview.component.ts
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { ManagedFiles } from '@app/platform/managed-files';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { KnowledgeSource } from '@app/models/knowledge.source.model';
import { ElectronIpcService } from '@services/ipc-services/electron-ipc.service';
@@ -143,7 +144,8 @@ export class KsPreviewComponent implements OnInit, OnDestroy {
private command: KsCommandService,
private ingest: IngestService,
private clipboard: Clipboard,
- private notifications: NotificationsService
+ private notifications: NotificationsService,
+ private managedFiles: ManagedFiles
) {
this.ks = config.data.ks;
}
@@ -192,7 +194,10 @@ export class KsPreviewComponent implements OnInit, OnDestroy {
if (clickEvent.openClicked) {
if (this.ks.assetId) {
- this.ipc.openAsset(this.ks.assetId);
+ this.managedFiles.open(
+ this.ks.assetId,
+ this.ks.reference?.source?.file?.filename ?? this.ks.title
+ );
} else {
this.ipc.openLocalFile(this.fileViewConfig.filePath);
}
diff --git a/src/kc_angular/src/app/components/source-components/ks-thumbnail.component.ts b/src/kc_angular/src/app/components/source-components/ks-thumbnail.component.ts
index 91d1bd6c..9bdaba8d 100755
--- a/src/kc_angular/src/app/components/source-components/ks-thumbnail.component.ts
+++ b/src/kc_angular/src/app/components/source-components/ks-thumbnail.component.ts
@@ -14,6 +14,7 @@
* limitations under the License.
*/
+import { Platform } from '@app/platform/platform';
import {
Component,
Input,
@@ -80,7 +81,8 @@ export class KsThumbnailComponent implements OnDestroy, OnChanges {
constructor(
private ipcService: ElectronIpcService,
private settings: SettingsService,
- private notifications: NotificationsService
+ private notifications: NotificationsService,
+ private platform: Platform
) {
this.animate = settings.get().display.animations;
}
@@ -140,6 +142,11 @@ export class KsThumbnailComponent implements OnDestroy, OnChanges {
: this.ks.accessLink.href;
if (this.ks.ingestType === 'file') {
+ // File thumbnails come from the operating system (desktop only)
+ if (!this.platform.has('fileThumbnails')) {
+ this._thumbnail$.next(undefined);
+ return;
+ }
this.ipcService.getFileThumbnail([
{
path: link,
diff --git a/src/kc_angular/src/app/components/source-components/source.component.ts b/src/kc_angular/src/app/components/source-components/source.component.ts
index 76a79e01..0d392497 100755
--- a/src/kc_angular/src/app/components/source-components/source.component.ts
+++ b/src/kc_angular/src/app/components/source-components/source.component.ts
@@ -14,6 +14,7 @@
* limitations under the License.
*/
+import { Platform } from '@app/platform/platform';
import {
Component,
ComponentRef,
@@ -219,7 +220,7 @@ export class SourceComponent implements OnInit, OnChanges {
private componentRef?: ComponentRef;
- constructor(private chat: ChatService) {
+ constructor(private chat: ChatService, private platform: Platform) {
this.chat.loading$.subscribe((loading: boolean) => {
this.chatTab.loading = loading;
});
@@ -248,6 +249,9 @@ export class SourceComponent implements OnInit, OnChanges {
}
views(source: KnowledgeSource) {
+ // Desktop-only views
+ this.chatTab.disabled = this.chatTab.hidden = !this.platform.has('chat');
+
// If video, enable video tab and disable document tab
if (source.ingestType === 'website') {
source.accessLink = new URL(source.accessLink);
@@ -257,11 +261,13 @@ export class SourceComponent implements OnInit, OnChanges {
source.accessLink.hostname === 'www.youtube.com' &&
source.accessLink.searchParams.get('v')
) {
- this.browserTab.disabled = this.browserTab.hidden = false;
+ this.browserTab.disabled = this.browserTab.hidden =
+ !this.platform.has('embeddedBrowser');
this.videoTab.disabled = this.videoTab.hidden = false;
return;
} else {
- this.browserTab.disabled = this.browserTab.hidden = false;
+ this.browserTab.disabled = this.browserTab.hidden =
+ !this.platform.has('embeddedBrowser');
this.videoTab.disabled = this.videoTab.hidden = true;
return;
}
@@ -271,11 +277,16 @@ export class SourceComponent implements OnInit, OnChanges {
this.documentTab.disabled = this.documentTab.hidden = true;
// If document, enable document table and disable video tab
- if (
+ const type = source.reference?.source?.file?.type ?? '';
+ const viewable =
`${source.accessLink}`.endsWith('pdf') ||
`${source.accessLink}`.endsWith('gif') ||
- `${source.accessLink}`.endsWith('jpg')
- ) {
+ `${source.accessLink}`.endsWith('jpg') ||
+ (!!source.assetId &&
+ (type === 'application/pdf' ||
+ type.startsWith('text/plain') ||
+ ['image/png', 'image/jpeg', 'image/gif'].includes(type)));
+ if (viewable) {
this.documentTab.disabled = this.documentTab.hidden = false;
return;
}
diff --git a/src/kc_angular/src/app/components/source-components/source.details.component.ts b/src/kc_angular/src/app/components/source-components/source.details.component.ts
index 2d20e377..87a811ed 100755
--- a/src/kc_angular/src/app/components/source-components/source.details.component.ts
+++ b/src/kc_angular/src/app/components/source-components/source.details.component.ts
@@ -14,6 +14,7 @@
* limitations under the License.
*/
+import { Platform } from '@app/platform/platform';
import {
Component,
ElementRef,
@@ -290,7 +291,8 @@ export class SourceDetailsComponent implements OnInit {
private command: KsCommandService,
private ipc: ElectronIpcService,
private notify: NotificationsService,
- private search: SearchService
+ private search: SearchService,
+ private platform: Platform
) {
this.form = this.fb.group({
title: ['', Validators.required],
@@ -387,7 +389,7 @@ export class SourceDetailsComponent implements OnInit {
}
show(accessLink: URL | string) {
- if (typeof accessLink === 'string') {
+ if (typeof accessLink === 'string' && this.platform.has('showInFolder')) {
this.ipc.showItemInFolder(accessLink);
this.notify.debug('Source Info', 'Locating Folder', location, 'toast');
} else {
diff --git a/src/kc_angular/src/app/components/source-components/source.document.component.ts b/src/kc_angular/src/app/components/source-components/source.document.component.ts
index b36afbff..fe1fc032 100755
--- a/src/kc_angular/src/app/components/source-components/source.document.component.ts
+++ b/src/kc_angular/src/app/components/source-components/source.document.component.ts
@@ -21,9 +21,7 @@ import {
OnInit,
ViewChild,
} from '@angular/core';
-import { HttpClient } from '@angular/common/http';
-import { firstValueFrom } from 'rxjs';
-import { StorageService } from '@services/ipc-services/storage.service';
+import { ManagedFiles } from '@app/platform/managed-files';
import { KnowledgeSource } from '@app/models/knowledge.source.model';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
@@ -64,11 +62,13 @@ export class SourceDocumentComponent implements OnInit, OnDestroy {
safeUrl: SafeUrl | undefined;
- style = {
+ style: { [key: string]: string } = {
width: '100%',
height: '100%',
'max-width': '100%',
'max-height': '100%',
+ // Plain text has no background of its own
+ 'background-color': 'white',
};
styles = {
@@ -81,30 +81,29 @@ export class SourceDocumentComponent implements OnInit, OnDestroy {
@ViewChild('documentContainer', { static: true })
documentContainer!: ElementRef;
- private objectUrl?: string;
+ private viewUrl?: string;
constructor(
private sanitizer: DomSanitizer,
- private http: HttpClient,
- private storage: StorageService
+ private managedFiles: ManagedFiles
) {}
ngOnDestroy() {
- if (this.objectUrl) {
- URL.revokeObjectURL(this.objectUrl);
+ if (this.viewUrl) {
+ this.managedFiles.release(this.viewUrl);
}
}
/** Managed files come from the storage service. Others use the file path. */
private async documentUrl(): Promise {
if (this.source.assetId) {
- const blob = await firstValueFrom(
- this.http.get(this.storage.assetContentUrl(this.source.assetId), {
- responseType: 'blob',
- })
+ const filename =
+ this.source.reference?.source?.file?.filename ?? this.source.title;
+ this.viewUrl = await this.managedFiles.viewUrl(
+ this.source.assetId,
+ filename
);
- this.objectUrl = URL.createObjectURL(blob);
- return this.objectUrl;
+ return this.viewUrl;
}
return 'file://' + encodeURI(this.source.accessLink as string);
}
diff --git a/src/kc_angular/src/app/models/project.model.ts b/src/kc_angular/src/app/models/project.model.ts
index a5de4827..d5b1d349 100755
--- a/src/kc_angular/src/app/models/project.model.ts
+++ b/src/kc_angular/src/app/models/project.model.ts
@@ -19,7 +19,7 @@ import { KnowledgeSource } from './knowledge.source.model';
import { UUID } from '@shared/models/uuid.model';
import { PrimeIcons } from 'primeng/api';
-export class KcProject implements KcProjectModel {
+export class KcProject implements KcProjectModel {
readonly id: UUID;
name = '';
authors: string[] = [];
diff --git a/src/kc_angular/src/app/platform/managed-files.ts b/src/kc_angular/src/app/platform/managed-files.ts
new file mode 100644
index 00000000..148820c2
--- /dev/null
+++ b/src/kc_angular/src/app/platform/managed-files.ts
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2026 Rob Royce
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
+import { BackendService } from '@services/ipc-services/backend.service';
+import { NativeFiles } from './native-files';
+
+/** Show and open managed files (files copied into the library). */
+export abstract class ManagedFiles {
+ /**
+ * A URL that shows the file in this client. Call release() with it when
+ * the view closes.
+ */
+ abstract viewUrl(assetId: string, filename: string): Promise;
+
+ abstract release(url: string): void;
+
+ /** Open the file outside the app view. */
+ abstract open(assetId: string, filename: string): Promise;
+
+ protected contentPath(assetId: string, filename: string) {
+ return `/v1/assets/${encodeURIComponent(
+ assetId
+ )}/content/${encodeURIComponent(filename)}`;
+ }
+}
+
+/** Desktop: fetch with the bearer token into a blob: URL; open in the default app. */
+@Injectable()
+export class DesktopManagedFiles extends ManagedFiles {
+ constructor(
+ private http: HttpClient,
+ private backend: BackendService,
+ private native: NativeFiles
+ ) {
+ super();
+ }
+
+ async viewUrl(assetId: string, filename: string) {
+ const blob = await firstValueFrom(
+ this.http.get(
+ this.backend.storage.url + this.contentPath(assetId, filename),
+ {
+ responseType: 'blob',
+ }
+ )
+ );
+ return URL.createObjectURL(blob);
+ }
+
+ release(url: string) {
+ if (url.startsWith('blob:')) URL.revokeObjectURL(url);
+ }
+
+ async open(assetId: string) {
+ await this.native.openManagedFile(assetId);
+ }
+}
+
+/**
+ * Browser: the session cookie authorizes same-origin requests, so the
+ * page uses the content URL directly. The URL ends with the filename, so
+ * the browser's PDF viewer and downloads show it.
+ */
+@Injectable()
+export class BrowserManagedFiles extends ManagedFiles {
+ async viewUrl(assetId: string, filename: string) {
+ return this.contentPath(assetId, filename);
+ }
+
+ release() {}
+
+ async open(assetId: string, filename: string) {
+ window.open(this.contentPath(assetId, filename), '_blank', 'noopener');
+ }
+}
diff --git a/src/kc_angular/src/app/platform/native-files.ts b/src/kc_angular/src/app/platform/native-files.ts
new file mode 100644
index 00000000..bdaf8ade
--- /dev/null
+++ b/src/kc_angular/src/app/platform/native-files.ts
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2026 Rob Royce
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { AssetRecord } from '@contracts/storage';
+
+/**
+ * Local filesystem functions. Desktop only. The browser has no access to
+ * local paths, and the library API never accepts them.
+ */
+export abstract class NativeFiles {
+ abstract readonly available: boolean;
+
+ /** Original location of a file the user selected, or null. Metadata only. */
+ abstract pathOf(file: File): string | null;
+
+ /** Copy a local file by path into the library (watched folders). */
+ abstract importFromPath(
+ path: string,
+ mediaType?: string
+ ): Promise;
+
+ /** Open a managed file in its default application. */
+ abstract openManagedFile(assetId: string): Promise;
+}
+
+export class DesktopNativeFiles extends NativeFiles {
+ readonly available = true;
+
+ pathOf(file: File) {
+ const path = (file as any).path;
+ return typeof path === 'string' && path ? path : null;
+ }
+
+ importFromPath(path: string, mediaType?: string): Promise {
+ return window.api.invoke('A2E:Storage:ImportFile', { path, mediaType });
+ }
+
+ openManagedFile(assetId: string): Promise {
+ return window.api.invoke('A2E:Storage:OpenAsset', assetId);
+ }
+}
+
+export class BrowserNativeFiles extends NativeFiles {
+ readonly available = false;
+
+ pathOf() {
+ return null;
+ }
+
+ importFromPath(): Promise {
+ return Promise.reject(
+ new Error('Local file paths are not available in the browser.')
+ );
+ }
+
+ openManagedFile(): Promise {
+ return Promise.reject(
+ new Error('Opening files in other apps is available in the desktop app.')
+ );
+ }
+}
diff --git a/src/kc_angular/src/app/platform/platform.ts b/src/kc_angular/src/app/platform/platform.ts
new file mode 100644
index 00000000..521fd755
--- /dev/null
+++ b/src/kc_angular/src/app/platform/platform.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2026 Rob Royce
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+
+/** True when the Electron preload bridge exists. Decided once at startup. */
+export function isDesktop(): boolean {
+ return typeof window !== 'undefined' && !!(window as any).api;
+}
+
+export type Feature =
+ | 'chat'
+ | 'saveWebsite'
+ | 'embeddedBrowser'
+ | 'importSettings'
+ | 'windowControls'
+ | 'openInDefaultApp'
+ | 'showInFolder'
+ | 'fileThumbnails'
+ | 'fileIcons'
+ | 'dragOut';
+
+const DESKTOP_ONLY: Record = {
+ chat: 'Chat is available in the desktop app.',
+ saveWebsite: 'Saving websites is available in the desktop app.',
+ embeddedBrowser: 'The built-in browser is available in the desktop app.',
+ importSettings:
+ 'Watched folders and extension settings are available in the desktop app.',
+ windowControls: 'Window controls are part of the desktop app.',
+ openInDefaultApp:
+ 'Opening files in other apps is available in the desktop app.',
+ showInFolder: 'Showing files in Finder is available in the desktop app.',
+ fileThumbnails: 'File thumbnails are available in the desktop app.',
+ fileIcons: 'File icons are available in the desktop app.',
+ dragOut: 'Dragging files out of Knowledge is available in the desktop app.',
+};
+
+/** Which optional features this client has. */
+@Injectable({ providedIn: 'root' })
+export class Platform {
+ readonly kind: 'desktop' | 'browser' = isDesktop() ? 'desktop' : 'browser';
+
+ get desktop() {
+ return this.kind === 'desktop';
+ }
+
+ has(feature: Feature): boolean {
+ return this.desktop || !(feature in DESKTOP_ONLY);
+ }
+
+ /** Why a feature is not available, for tooltips and messages. */
+ unavailable(feature: Feature): string {
+ return this.has(feature) ? '' : DESKTOP_ONLY[feature];
+ }
+}
diff --git a/src/kc_angular/src/app/platform/settings-store.ts b/src/kc_angular/src/app/platform/settings-store.ts
new file mode 100644
index 00000000..3ffd51e5
--- /dev/null
+++ b/src/kc_angular/src/app/platform/settings-store.ts
@@ -0,0 +1,153 @@
+/*
+ * Copyright (c) 2026 Rob Royce
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Observable, ReplaySubject } from 'rxjs';
+import {
+ createDefaultSettings,
+ SettingsModel,
+} from '@shared/models/settings.model';
+
+/** Where settings come from and where changes go. */
+export abstract class SettingsStore {
+ /** Complete settings, on load and after every change. */
+ abstract readonly changes: Observable;
+
+ /** Default settings. */
+ abstract defaults(): Promise;
+
+ /** Request the current settings. */
+ abstract start(): void;
+
+ /** Merge a partial settings object and store it. */
+ abstract save(partial: object): void;
+}
+
+/** Desktop: Electron keeps settings in knowledge.settings.json. */
+export class DesktopSettingsStore extends SettingsStore {
+ private subject = new ReplaySubject(1);
+ readonly changes = this.subject.asObservable();
+
+ constructor() {
+ super();
+ window.api.receive('E2A:Settings:All', (settings: SettingsModel) =>
+ this.subject.next(settings)
+ );
+ }
+
+ defaults(): Promise {
+ return new Promise((resolve) => {
+ window.api.receiveOnce('E2A:Settings:Defaults', resolve);
+ window.api.send('A2E:Settings:Defaults');
+ });
+ }
+
+ start() {
+ window.api.send('A2E:Settings:Get');
+ }
+
+ save(partial: object) {
+ window.api.send('A2E:Settings:Set', partial);
+ }
+}
+
+const BROWSER_SETTINGS_KEY = 'kc-settings';
+
+function browserDefaults(): SettingsModel {
+ return createDefaultSettings({
+ env: {
+ appTitle: 'Knowledge',
+ settingsFilename: '',
+ DEFAULT_WINDOW_HEIGHT: 0,
+ DEFAULT_WINDOW_WIDTH: 0,
+ STARTUP_WINDOW_HEIGHT: 0,
+ STARTUP_WINDOW_WIDTH: 0,
+ },
+ system: {
+ appPath: '',
+ appVersion: '',
+ cwd: '',
+ downloadPath: '',
+ electronVersion: '',
+ envPath: '',
+ firstRun: false,
+ homePath: '',
+ nodeVersion: '',
+ osPlatform: 'browser',
+ osVersion: navigator.userAgent,
+ pathSep: '/',
+ resourcesPath: '',
+ settingsPath: '',
+ settingsFilePath: '',
+ },
+ storagePath: '',
+ extensionsPath: '',
+ autoscanPath: '',
+ });
+}
+
+/** Plain-object merge. Arrays and other values replace. */
+function merge(target: any, source: any): any {
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
+ return source;
+ }
+ const out = { ...(target && typeof target === 'object' ? target : {}) };
+ for (const [key, value] of Object.entries(source)) {
+ out[key] = merge(out[key], value);
+ }
+ return out;
+}
+
+/** Browser: settings are UI preferences in localStorage. */
+export class BrowserSettingsStore extends SettingsStore {
+ private subject = new ReplaySubject(1);
+ readonly changes = this.subject.asObservable();
+ private current: SettingsModel = browserDefaults();
+
+ defaults() {
+ return Promise.resolve(browserDefaults());
+ }
+
+ start() {
+ try {
+ const saved = JSON.parse(
+ localStorage.getItem(BROWSER_SETTINGS_KEY) ?? '{}'
+ );
+ this.current = merge(browserDefaults(), saved);
+ } catch {
+ this.current = browserDefaults();
+ }
+ this.subject.next(this.current);
+ }
+
+ /**
+ * Emit only real changes, and asynchronously, like the desktop store.
+ * Subscribers that save settings in response cannot start a loop.
+ */
+ save(partial: object) {
+ const next = merge(this.current, partial);
+ const json = JSON.stringify(next);
+ if (json === JSON.stringify(this.current)) {
+ return;
+ }
+ this.current = next;
+ try {
+ localStorage.setItem(BROWSER_SETTINGS_KEY, json);
+ } catch (e) {
+ console.warn('[Settings]: not saved', e);
+ }
+ setTimeout(() => this.subject.next(this.current));
+ }
+}
diff --git a/src/kc_angular/src/app/platform/window-controls.ts b/src/kc_angular/src/app/platform/window-controls.ts
new file mode 100644
index 00000000..15528145
--- /dev/null
+++ b/src/kc_angular/src/app/platform/window-controls.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2026 Rob Royce
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Native window functions. The browser has none. */
+export abstract class WindowControls {
+ abstract readonly available: boolean;
+ abstract minimize(): void;
+ abstract maximize(): void;
+ abstract setZoom(percent: number): void;
+}
+
+export class DesktopWindowControls extends WindowControls {
+ readonly available = true;
+ minimize() {
+ window.api.send('A2E:Window:Minimize');
+ }
+ maximize() {
+ window.api.send('A2E:Window:Maximize');
+ }
+ setZoom(percent: number) {
+ window.api.send('A2E:Window:ZoomIn', percent);
+ }
+}
+
+export class BrowserWindowControls extends WindowControls {
+ readonly available = false;
+ minimize() {}
+ maximize() {}
+ setZoom() {}
+}
diff --git a/src/kc_angular/src/app/services/chat-services/chat.service.ts b/src/kc_angular/src/app/services/chat-services/chat.service.ts
index 64ea6d08..2620cd6b 100755
--- a/src/kc_angular/src/app/services/chat-services/chat.service.ts
+++ b/src/kc_angular/src/app/services/chat-services/chat.service.ts
@@ -487,6 +487,11 @@ export class ChatService {
* and a successful response from the backend.
*/
private checkConnection() {
+ // No chat server (for example, in the browser client)
+ if (!this.backendUrl) {
+ this.canConnect.next(false);
+ return;
+ }
this.http.get(this.backendUrl + '/api/key').subscribe((result: any) => {
if (result) {
this.canConnect.next(result.apiKeySet);
diff --git a/src/kc_angular/src/app/services/command-services/ks-command.service.ts b/src/kc_angular/src/app/services/command-services/ks-command.service.ts
index 487710bb..433a6b1b 100755
--- a/src/kc_angular/src/app/services/command-services/ks-command.service.ts
+++ b/src/kc_angular/src/app/services/command-services/ks-command.service.ts
@@ -14,6 +14,8 @@
* limitations under the License.
*/
+import { Platform } from '@app/platform/platform';
+import { ManagedFiles } from '@app/platform/managed-files';
import { BehaviorSubject } from 'rxjs';
import { BrowserViewDialogService } from '@services/ipc-services/browser-view-dialog.service';
import { Clipboard } from '@angular/cdk/clipboard';
@@ -50,7 +52,9 @@ export class KsCommandService {
private confirmation: ConfirmationService,
private clipboard: Clipboard,
private notifications: NotificationsService,
- private projects: ProjectService
+ private projects: ProjectService,
+ private managedFiles: ManagedFiles,
+ private platform: Platform
) {}
update(ksList: KnowledgeSource[], notify = true) {
@@ -123,8 +127,12 @@ export class KsCommandService {
open(ks: Partial) {
if (ks.ingestType === 'file' && ks.assetId) {
- this.ipc
- .openAsset(ks.assetId)
+ // Desktop: default app. Browser: a new tab.
+ this.managedFiles
+ .open(
+ ks.assetId,
+ ks.reference?.source?.file?.filename ?? ks.title ?? 'file'
+ )
.then(() =>
this.notifications.success(
'Source Command',
@@ -132,7 +140,7 @@ export class KsCommandService {
ks.title ?? ''
)
)
- .catch((e) =>
+ .catch((e: unknown) =>
this.notifications.error(
'Source Command',
'Unable to Open File',
@@ -209,6 +217,11 @@ export class KsCommandService {
if (typeof ks.accessLink !== 'string') {
return;
}
+ // Browser: no access to local folders. Open the managed file instead.
+ if (!this.platform.has('showInFolder')) {
+ this.open(ks);
+ return;
+ }
this.ipc.showItemInFolder(ks.accessLink);
this._ksShowInFilesEvent.next(ks);
}
diff --git a/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts b/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts
index 847dbc84..bfbd6a17 100755
--- a/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts
+++ b/src/kc_angular/src/app/services/factory-services/ks-factory.service.ts
@@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { Platform } from '@app/platform/platform';
+import { StorageService } from '@services/ipc-services/storage.service';
+import { NativeFiles } from '@app/platform/native-files';
import { ElectronIpcService } from '@services/ipc-services/electron-ipc.service';
import { ExtractorService } from '@services/ingest-services/extractor.service';
import { FaviconService } from '@services/ingest-services/favicon.service';
@@ -57,7 +60,10 @@ export class KsFactoryService {
private http: HttpClient,
private settings: SettingsService,
private ipc: ElectronIpcService,
- private uuid: UuidService
+ private uuid: UuidService,
+ private storage: StorageService,
+ private native: NativeFiles,
+ private platform: Platform
) {
this.settings.search.subscribe((searchSettings) => {
if (searchSettings.provider) {
@@ -106,7 +112,7 @@ export class KsFactoryService {
return new Promise((resolve, reject) => {
if (type === 'file' && file) {
if (typeof link === 'string') {
- this.extractFileResource(link, file)
+ this.extractFileResource(file)
.then(this.getFileIcon)
.then((result) => {
resolve(result);
@@ -130,27 +136,31 @@ export class KsFactoryService {
async many(
requests: KnowledgeSourceFactoryRequest
): Promise {
- return new Promise((resolve) => {
+ return new Promise((resolve, reject) => {
const actions: Promise[] = [];
if (requests.ingestType === 'file' && requests.files?.length) {
for (const file of requests.files)
- actions.push(this.extractFileResource((file as any).path, file));
+ actions.push(this.extractFileResource(file));
- Promise.all(actions).then((results) => {
- this.getFileIcons(results).then((finalList: KnowledgeSource[]) => {
- if (this.settings.get().ingest.manager.target === 'all') {
- // TODO: move file to managed location...
- }
- if (requests.originals) {
- for (let i = 0; i < requests.originals.length; i++) {
- finalList[i].title = requests.originals[i].title;
- finalList[i].topics = requests.originals[i].topics;
+ // An upload failure rejects, so the caller can report it
+ Promise.all(actions)
+ .catch(reject)
+ .then((results) => {
+ if (!results) return;
+ this.getFileIcons(results).then((finalList: KnowledgeSource[]) => {
+ if (this.settings.get().ingest.manager.target === 'all') {
+ // TODO: move file to managed location...
}
- }
- resolve(finalList);
+ if (requests.originals) {
+ for (let i = 0; i < requests.originals.length; i++) {
+ finalList[i].title = requests.originals[i].title;
+ finalList[i].topics = requests.originals[i].topics;
+ }
+ }
+ resolve(finalList);
+ });
});
- });
}
if (requests.ingestType !== 'file' && requests.links?.length) {
@@ -227,15 +237,19 @@ export class KsFactoryService {
};
}
- private async extractFileResource(
- link: string,
- file: File
- ): Promise {
+ /**
+ * Create a file source from a selected file. The file is uploaded to the
+ * library first, so the source refers to a managed copy (asset ID). The
+ * desktop also records the original path, as information only.
+ */
+ private async extractFileResource(file: File): Promise {
+ const originalPath = this.native.pathOf(file);
+ const asset = await this.storage.uploadFile(file, originalPath);
const uuid: UUID = this.uuid.generate(1)[0];
const fileModel: FileSourceModel = {
id: uuid,
filename: file.name.trim(),
- path: (file as any).path,
+ path: originalPath ?? '',
size: file.size,
type: file.type,
creationTime: Date(),
@@ -243,8 +257,11 @@ export class KsFactoryService {
accessTime: Date(),
};
const source = new SourceModel(fileModel, undefined);
+ const link = originalPath ?? file.name.trim();
const ref = new KnowledgeSourceReference('file', source, link);
- return new KnowledgeSource(file.name.trim(), uuid, 'file', ref);
+ const ks = new KnowledgeSource(file.name.trim(), uuid, 'file', ref);
+ ks.assetId = asset.id;
+ return ks;
}
private getFileIcon(ks: KnowledgeSource): Promise {
@@ -274,6 +291,12 @@ export class KsFactoryService {
}
private extractWebResource(link: URL): Promise {
+ // The browser client must not fetch other sites from the page
+ if (!this.platform.has('saveWebsite')) {
+ return Promise.reject(
+ new Error(this.platform.unavailable('saveWebsite'))
+ );
+ }
const uuid: UUID = this.uuid.generate(1)[0];
const source = new SourceModel(undefined, { accessLink: link.href });
const ref = new KnowledgeSourceReference('website', source, link);
diff --git a/src/kc_angular/src/app/services/factory-services/sidebar.service.ts b/src/kc_angular/src/app/services/factory-services/sidebar.service.ts
index f593a640..12927b60 100644
--- a/src/kc_angular/src/app/services/factory-services/sidebar.service.ts
+++ b/src/kc_angular/src/app/services/factory-services/sidebar.service.ts
@@ -14,6 +14,7 @@
* limitations under the License.
*/
+import { Platform } from '@app/platform/platform';
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { UUID } from '@shared/models/uuid.model';
@@ -84,10 +85,12 @@ export class SidebarService {
items$ = this.items.asObservable();
- constructor() {}
+ constructor(private platform: Platform) {}
all(projectId: UUID) {
- const items = SIDEBAR_ITEMS.map((i) => {
+ const items = SIDEBAR_ITEMS.filter(
+ (i) => i.label !== 'Chat' || this.platform.has('chat')
+ ).map((i) => {
i.routerLink[2] = projectId.value;
return i;
});
diff --git a/src/kc_angular/src/app/services/ingest-services/autoscan.service.ts b/src/kc_angular/src/app/services/ingest-services/autoscan.service.ts
index 96aec336..d9e89b29 100755
--- a/src/kc_angular/src/app/services/ingest-services/autoscan.service.ts
+++ b/src/kc_angular/src/app/services/ingest-services/autoscan.service.ts
@@ -32,8 +32,8 @@ type FileManagerMove = {
providedIn: 'root',
})
export class AutoscanService {
- private send = window.api.send;
- private receive = window.api.receive;
+ private send = window.api?.send ?? (() => undefined);
+ private receive = window.api?.receive ?? (() => undefined);
private channels = {
fmNewFiles: 'E2A:FileManager:NewFiles',
fmError: 'E2A:FileManager:Error',
diff --git a/src/kc_angular/src/app/services/ingest-services/drag-and-drop.service.ts b/src/kc_angular/src/app/services/ingest-services/drag-and-drop.service.ts
index cba0fc16..a70aa52c 100755
--- a/src/kc_angular/src/app/services/ingest-services/drag-and-drop.service.ts
+++ b/src/kc_angular/src/app/services/ingest-services/drag-and-drop.service.ts
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { isDesktop } from '@app/platform/platform';
import { Injectable } from '@angular/core';
import { KnowledgeSourceFactoryRequest } from '@services/factory-services/ks-factory.service';
import { KnowledgeSource } from '@app/models/knowledge.source.model';
@@ -118,6 +119,10 @@ export class DragAndDropService {
{
HANDLER_TYPE: 'Web Links',
accepts: (data: DragAndDropPacket) => {
+ // Saving websites needs the desktop app
+ if (!isDesktop()) {
+ return false;
+ }
const text = data.text,
uri = data.uri;
if (!text || !text.length || !uri || !uri.length) {
@@ -181,7 +186,9 @@ export class DragAndDropService {
) {}
get supportedTypes() {
- return this.__data_transfer_handlers.map((dth) => dth.HANDLER_TYPE);
+ return this.__data_transfer_handlers
+ .map((dth) => dth.HANDLER_TYPE)
+ .filter((type) => isDesktop() || type !== 'Web Links');
}
async parseDragEvent(
@@ -259,7 +266,8 @@ export class DragAndDropService {
$event.preventDefault();
$event.stopPropagation();
- if (ks && ks.ingestType === 'file') {
+ // Dragging a file out to the operating system is a desktop feature
+ if (ks && ks.ingestType === 'file' && isDesktop()) {
this.pending =
typeof ks.accessLink === 'string' ? ks.accessLink : ks.accessLink?.href;
this.pending = this.pending?.split('\\').pop()?.split('/').pop();
diff --git a/src/kc_angular/src/app/services/ingest-services/extension.service.ts b/src/kc_angular/src/app/services/ingest-services/extension.service.ts
index 89a98c21..bb69b0ee 100755
--- a/src/kc_angular/src/app/services/ingest-services/extension.service.ts
+++ b/src/kc_angular/src/app/services/ingest-services/extension.service.ts
@@ -28,7 +28,7 @@ export class ExtensionService {
import: 'E2A:Extension:Import',
};
- private receive = window.api.receive;
+ private receive = window.api?.receive ?? (() => undefined);
private __links = new BehaviorSubject<
Partial
diff --git a/src/kc_angular/src/app/services/ingest-services/favicon.service.ts b/src/kc_angular/src/app/services/ingest-services/favicon.service.ts
index 383ad69a..46ee21df 100755
--- a/src/kc_angular/src/app/services/ingest-services/favicon.service.ts
+++ b/src/kc_angular/src/app/services/ingest-services/favicon.service.ts
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { Platform } from '@app/platform/platform';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { ElectronIpcService } from '@services/ipc-services/electron-ipc.service';
import { HttpClient, HttpResponse } from '@angular/common/http';
@@ -35,7 +36,8 @@ export class FaviconService {
private httpClient: HttpClient,
private sanitizer: DomSanitizer,
private ipcService: ElectronIpcService,
- private notifications: NotificationsService
+ private notifications: NotificationsService,
+ private platform: Platform
) {}
loading() {
@@ -213,6 +215,11 @@ export class FaviconService {
return undefined;
}
+ // Plain URLs (for example, the generic file icon) need no decoding
+ if (!iconStr.startsWith('data:')) {
+ return iconStr;
+ }
+
const blob = this.dataURItoBlob(iconStr);
if (!blob) {
return undefined;
@@ -284,6 +291,12 @@ export class FaviconService {
this.webIconsFromKsList(webList);
+ // File icons come from the operating system (desktop only)
+ if (!this.platform.has('fileIcons')) {
+ fileList.forEach((ks) => (ks.icon = this.file()));
+ return ksList;
+ }
+
this.ipcService.getFileIcon(filePaths).then((icons) => {
for (let i = 0; i < fileList.length; i++) {
fileList[i].icon = icons[i];
diff --git a/src/kc_angular/src/app/services/ipc-services/backend.service.ts b/src/kc_angular/src/app/services/ipc-services/backend.service.ts
index 02c3ace9..b7868dc5 100644
--- a/src/kc_angular/src/app/services/ipc-services/backend.service.ts
+++ b/src/kc_angular/src/app/services/ipc-services/backend.service.ts
@@ -35,13 +35,23 @@ export interface BackendInfo {
}
/**
- * Addresses and tokens of the local servers that belong to this instance.
- * Electron provides them through IPC. Load before the app starts.
+ * How this client reaches the library API (and chat) and authorizes its
+ * requests. One implementation per platform, chosen at startup.
*/
-@Injectable({
- providedIn: 'root',
-})
-export class BackendService {
+export abstract class BackendService {
+ abstract get chat(): BackendEndpoint;
+ abstract get storage(): BackendEndpoint;
+ abstract load(): Promise;
+ /** Add the credentials that this request needs, if it goes to our servers. */
+ abstract authorize(req: HttpRequest): HttpRequest;
+}
+
+/**
+ * Desktop: Electron gives the addresses and bearer tokens of this
+ * instance's local servers through IPC.
+ */
+@Injectable()
+export class DesktopBackendService extends BackendService {
private info: BackendInfo = {
chat: { error: 'Backend information not loaded.' },
storage: { error: 'Backend information not loaded.' },
@@ -69,21 +79,85 @@ export class BackendService {
}
}
- /** Bearer token for a URL that belongs to this instance, if any. */
- tokenFor(url: string): string | undefined {
+ authorize(req: HttpRequest) {
for (const endpoint of Object.values(this.info)) {
if (
endpoint.url &&
endpoint.token &&
- url.startsWith(endpoint.url + '/')
+ req.url.startsWith(endpoint.url + '/')
) {
- return endpoint.token;
+ return req.clone({
+ setHeaders: { Authorization: `Bearer ${endpoint.token}` },
+ });
}
}
- return undefined;
+ return req;
}
}
+/**
+ * Browser: the storage service serves this page, so the API is on the
+ * same origin. A launch code in the URL fragment starts a session cookie.
+ * State-changing requests carry the CSRF token, which stays in memory.
+ */
+@Injectable()
+export class BrowserBackendService extends BackendService {
+ private csrfToken = '';
+ private storageEndpoint: BackendEndpoint = { error: 'No browser session.' };
+
+ get chat(): BackendEndpoint {
+ return { error: 'Chat is available in the desktop app.' };
+ }
+
+ get storage(): BackendEndpoint {
+ return this.storageEndpoint;
+ }
+
+ async load() {
+ const code = new URLSearchParams(location.hash.slice(1)).get('launch');
+ if (code) {
+ // Remove the one-time code from the address bar and history
+ history.replaceState(null, '', location.pathname + location.search);
+ }
+
+ const res = code
+ ? await fetch('/v1/session', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ code }),
+ })
+ : await fetch('/v1/session');
+
+ if (res.ok) {
+ this.csrfToken = (await res.json()).csrfToken;
+ this.storageEndpoint = { url: location.origin };
+ } else {
+ const message = await res
+ .json()
+ .then((b) => b?.error?.message)
+ .catch(() => undefined);
+ this.storageEndpoint = {
+ error:
+ message ??
+ 'No browser session. Open the launch link that "yarn browser" prints.',
+ };
+ }
+ }
+
+ authorize(req: HttpRequest) {
+ const sameOrigin =
+ req.url.startsWith('/') || req.url.startsWith(location.origin + '/');
+ if (sameOrigin && !['GET', 'HEAD'].includes(req.method) && this.csrfToken) {
+ return req.clone({ setHeaders: { 'X-Knowledge-CSRF': this.csrfToken } });
+ }
+ return req;
+ }
+}
+
+export function backendServiceFactory(desktop: boolean): BackendService {
+ return desktop ? new DesktopBackendService() : new BrowserBackendService();
+}
+
@Injectable()
export class BackendAuthInterceptor implements HttpInterceptor {
constructor(private backend: BackendService) {}
@@ -92,13 +166,7 @@ export class BackendAuthInterceptor implements HttpInterceptor {
req: HttpRequest,
next: HttpHandler
): Observable> {
- const token = this.backend.tokenFor(req.url);
- if (!token) {
- return next.handle(req);
- }
- return next.handle(
- req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
- );
+ return next.handle(this.backend.authorize(req));
}
}
diff --git a/src/kc_angular/src/app/services/ipc-services/electron-ipc.service.ts b/src/kc_angular/src/app/services/ipc-services/electron-ipc.service.ts
index 0ba35ee5..e54793ef 100755
--- a/src/kc_angular/src/app/services/ipc-services/electron-ipc.service.ts
+++ b/src/kc_angular/src/app/services/ipc-services/electron-ipc.service.ts
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import type { AssetRecord } from '@contracts/storage';
+import { isDesktop } from '@app/platform/platform';
import { Injectable, NgZone } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import {
@@ -36,10 +36,11 @@ export class ElectronIpcService {
browserViewExtractText: 'E2A:BrowserView:ExtractedText',
};
private ClassName = 'ElectronIpcService';
- private send = window.api.send;
- private receive = window.api.receive;
- private receiveOnce = window.api.receiveOnce;
- private removeAllListeners = window.api.removeAllListeners;
+ private send = window.api?.send ?? (() => undefined);
+ private receive = window.api?.receive ?? (() => undefined);
+ private receiveOnce = window.api?.receiveOnce ?? (() => undefined);
+ private removeAllListeners =
+ window.api?.removeAllListeners ?? (() => undefined);
private channels = {
autoUpdateReceive: 'E2A:AutoUpdate:Update',
browserView: 'A2E:BrowserView:Open',
@@ -111,6 +112,11 @@ export class ElectronIpcService {
extractedText = this._extractedText.asObservable();
constructor(private zone: NgZone) {
+ // Desktop only. In a browser there is no Electron bridge.
+ if (!isDesktop()) {
+ return;
+ }
+
/**
* Listen for auto update messages
*/
@@ -348,19 +354,6 @@ export class ElectronIpcService {
});
}
- /**
- * Copy a local file into the storage service. Desktop only: the renderer
- * cannot read local paths. The original file is not changed.
- */
- importFile(path: string, mediaType?: string): Promise {
- return window.api.invoke('A2E:Storage:ImportFile', { path, mediaType });
- }
-
- /** Open a managed file in its default application. Desktop only. */
- openAsset(assetId: string): Promise {
- return window.api.invoke('A2E:Storage:OpenAsset', assetId);
- }
-
generateUuid(quantity: number): Promise {
return new Promise((resolve, reject) => {
if (quantity <= 0) {
diff --git a/src/kc_angular/src/app/services/ipc-services/settings.service.ts b/src/kc_angular/src/app/services/ipc-services/settings.service.ts
index cf73b816..6ac296cc 100755
--- a/src/kc_angular/src/app/services/ipc-services/settings.service.ts
+++ b/src/kc_angular/src/app/services/ipc-services/settings.service.ts
@@ -16,7 +16,8 @@
import { Injectable, NgZone } from '@angular/core';
import { BehaviorSubject, tap } from 'rxjs';
-import { ElectronIpcService } from './electron-ipc.service';
+import { SettingsStore } from '@app/platform/settings-store';
+import { WindowControls } from '@app/platform/window-controls';
import {
ApplicationSettingsModel,
DisplaySettingsModel,
@@ -52,94 +53,67 @@ export class SettingsService {
private _graph = new BehaviorSubject({} as any);
graph = this._graph.asObservable();
- private send = window.api.send;
-
- private receive = window.api.receive;
-
- private receiveOnce = window.api.receiveOnce;
-
- private settingsChannels = {
- getSettings: 'A2E:Settings:Get',
- getDefaults: 'A2E:Settings:Defaults',
- receiveAll: 'E2A:Settings:All',
- receiveDefaults: 'E2A:Settings:Defaults',
- setSettings: 'A2E:Settings:Set',
- };
-
private ref?: DynamicDialogRef;
constructor(
- private ipcService: ElectronIpcService,
+ private store: SettingsStore,
+ private windowControls: WindowControls,
private dialog: DialogService,
private router: Router,
private zone: NgZone
) {
- /**
- * Keep a copy of default settings to allow other components and services to instantiate
- */
- this.receiveOnce(
- this.settingsChannels.receiveDefaults,
- (settings: SettingsModel) => {
- this.zone.run(() => {
- this._defaults = settings;
- });
- }
- );
-
- this.send(this.settingsChannels.getDefaults);
-
- /**
- * Settings are stored in a JSON file via Electron and kept consistent through IPC messages
- */
- this.receive(
- this.settingsChannels.receiveAll,
- (settings: SettingsModel) => {
- this.zone.run(() => {
- console.debug(
- `[Debug]-[${new Date().toLocaleString()}]-[SettingsService]: Settings Updated: `,
- settings
- );
- this._settings.next(settings);
-
- if (settings.search) {
- this._search.next(settings.search);
- } else {
- console.error('SettingsService - Search Settings not found...');
- }
-
- if (settings.app) {
- this._app.next(settings.app);
- } else {
- console.error(
- 'SettingsService - Application Settings not found...'
- );
- }
-
- if (settings.ingest) {
- this._ingest.next(settings.ingest);
- } else {
- console.error('SettingsService - Ingest Settings not found...');
- }
-
- if (settings.display) {
- this._display.next(settings.display);
- setTimeout(() => {
- this.send('A2E:Window:ZoomIn', settings.display.zoom);
- });
- } else {
- console.error('SettingsService - Display Settings not found...');
- }
+ // Keep a copy of default settings for other components and services
+ this.store.defaults().then((settings) => {
+ this.zone.run(() => {
+ this._defaults = settings;
+ });
+ });
- if (settings.app.graph) {
- this._graph.next(settings.app.graph);
- } else {
- console.error('SettingsService - Graph Settings not found...');
- }
- });
- }
- );
+ // Desktop: Electron settings file. Browser: localStorage.
+ this.store.changes.subscribe((settings: SettingsModel) => {
+ this.zone.run(() => {
+ console.debug(
+ `[Debug]-[${new Date().toLocaleString()}]-[SettingsService]: Settings Updated: `,
+ settings
+ );
+ this._settings.next(settings);
+
+ if (settings.search) {
+ this._search.next(settings.search);
+ } else {
+ console.error('SettingsService - Search Settings not found...');
+ }
+
+ if (settings.app) {
+ this._app.next(settings.app);
+ } else {
+ console.error('SettingsService - Application Settings not found...');
+ }
+
+ if (settings.ingest) {
+ this._ingest.next(settings.ingest);
+ } else {
+ console.error('SettingsService - Ingest Settings not found...');
+ }
+
+ if (settings.display) {
+ this._display.next(settings.display);
+ setTimeout(() => {
+ this.windowControls.setZoom(settings.display.zoom);
+ });
+ } else {
+ console.error('SettingsService - Display Settings not found...');
+ }
+
+ if (settings.app.graph) {
+ this._graph.next(settings.app.graph);
+ } else {
+ console.error('SettingsService - Graph Settings not found...');
+ }
+ });
+ });
- this.send(this.settingsChannels.getSettings);
+ this.store.start();
}
private _defaults!: SettingsModel;
@@ -158,7 +132,7 @@ export class SettingsService {
* @param settings
*/
set(settings: SettingsModel | Object) {
- this.send(this.settingsChannels.setSettings, settings);
+ this.store.save(settings);
}
show(category?: 'display' | 'search' | 'import' | 'graph' | 'chat') {
diff --git a/src/kc_angular/src/app/services/ipc-services/storage.service.ts b/src/kc_angular/src/app/services/ipc-services/storage.service.ts
index 5e134235..74ca64fe 100755
--- a/src/kc_angular/src/app/services/ipc-services/storage.service.ts
+++ b/src/kc_angular/src/app/services/ipc-services/storage.service.ts
@@ -31,14 +31,19 @@ import { KnowledgeSource } from '@app/models/knowledge.source.model';
import { AutoscanService } from '@services/ingest-services/autoscan.service';
import { NotificationsService } from '@services/user-services/notifications.service';
import { BackendService } from '@services/ipc-services/backend.service';
-import { ElectronIpcService } from '@services/ipc-services/electron-ipc.service';
+import { NativeFiles } from '@app/platform/native-files';
import {
projectToRecord,
recordToProject,
recordToSource,
sourceToRecord,
} from '@contracts/mapping';
+import {
+ ASSET_FILENAME_HEADER,
+ ASSET_ORIGINAL_PATH_HEADER,
+} from '@contracts/storage';
import type {
+ AssetRecord,
LibraryStatus,
ProjectList,
ProjectRecord,
@@ -65,7 +70,7 @@ export class StorageService {
constructor(
private http: HttpClient,
private backend: BackendService,
- private ipc: ElectronIpcService,
+ private native: NativeFiles,
private autoscan: AutoscanService,
private notifications: NotificationsService
) {}
@@ -213,12 +218,43 @@ export class StorageService {
}
}
+ /**
+ * Upload the bytes of a selected file. The service returns the managed
+ * asset. Desktop and browser use this same operation. The original path
+ * is sent as information only, when the desktop knows it.
+ */
+ async uploadFile(
+ file: File,
+ originalPath: string | null
+ ): Promise {
+ const headers: Record = {
+ 'Content-Type': file.type || 'application/octet-stream',
+ [ASSET_FILENAME_HEADER]: encodeURIComponent(file.name),
+ };
+ if (originalPath) {
+ headers[ASSET_ORIGINAL_PATH_HEADER] = encodeURIComponent(originalPath);
+ }
+ const { asset } = await firstValueFrom(
+ this.http.post<{ asset: AssetRecord }>(`${this.api}/assets`, file, {
+ headers,
+ })
+ );
+ return asset;
+ }
+
+ /**
+ * A file source without a managed copy, for example from a watched
+ * folder (desktop). Copy it by path through the desktop app.
+ */
private async copyFile(ks: KnowledgeSource) {
const file = ks.reference?.source?.file;
const path =
file?.path ?? (typeof ks.accessLink === 'string' ? ks.accessLink : '');
try {
- const asset = await this.ipc.importFile(path, file?.type || undefined);
+ const asset = await this.native.importFromPath(
+ path,
+ file?.type || undefined
+ );
ks.assetId = asset.id;
} catch (e) {
// The source is saved without a managed copy. Report it.
@@ -251,11 +287,6 @@ export class StorageService {
this.synced.delete(key);
}
- /** URL of a managed file's content. Fetch it with HttpClient (adds the token). */
- assetContentUrl(assetId: string) {
- return `${this.api}/assets/${assetId}/content`;
- }
-
/** Download the library backup (projects, sources, managed files). */
async exportLibrary() {
const blob = await firstValueFrom(
diff --git a/src/kc_angular/src/app/services/ipc-services/uuid.service.ts b/src/kc_angular/src/app/services/ipc-services/uuid.service.ts
index 06dc044f..c7863098 100755
--- a/src/kc_angular/src/app/services/ipc-services/uuid.service.ts
+++ b/src/kc_angular/src/app/services/ipc-services/uuid.service.ts
@@ -15,7 +15,6 @@
*/
import { Injectable } from '@angular/core';
-import { ElectronIpcService } from './electron-ipc.service';
import { UUID } from '@shared/models/uuid.model';
declare global {
@@ -24,33 +23,18 @@ declare global {
}
}
+/** Random v4 UUIDs from the Web Crypto API. Same on desktop and browser. */
@Injectable({
providedIn: 'root',
})
export class UuidService {
- private uuidBuffer: UUID[] = [];
-
- constructor(private ipcService: ElectronIpcService) {
- this.asyncGenerate();
- }
-
generate(quantity: number): UUID[] {
if (quantity < 1) {
console.error('Requested less than 1 UUID.. which is invalid...');
return [];
}
-
- const uuids: UUID[] = this.uuidBuffer.slice(0, quantity);
- this.uuidBuffer = this.uuidBuffer.slice(quantity);
- if (this.uuidBuffer.length <= 32) {
- this.asyncGenerate();
- }
- return uuids;
- }
-
- private asyncGenerate() {
- this.ipcService.generateUuid(128).then((ids: UUID[]) => {
- if (ids) this.uuidBuffer = ids;
- });
+ return Array.from({ length: quantity }, () => ({
+ value: crypto.randomUUID(),
+ }));
}
}
diff --git a/src/kc_electron/src/app/services/settings.service.ts b/src/kc_electron/src/app/services/settings.service.ts
index ca842cc1..c2c54d6a 100755
--- a/src/kc_electron/src/app/services/settings.service.ts
+++ b/src/kc_electron/src/app/services/settings.service.ts
@@ -17,28 +17,15 @@
import * as fs from "fs";
import { BehaviorSubject, Observable } from "rxjs";
import {
- AutoscanSettingsModel,
- CalendarSettingsModel,
- ChatSettingsModel,
- DockerSettingsModel,
+ createDefaultSettings,
EnvironmentSettingsModel,
- ExtensionServerSettingsModel,
- FileManagerSettingsModel,
- GraphSettingsModel,
- GridSettingsModel,
- LoggingSettingsModel,
- ProjectSettingsModel,
- SearchSettingsModel,
SettingsModel,
SystemSettingsModel,
- TableSettingsModel,
- UserSettingsModel,
} from "../../../../kc_shared/models/settings.model";
import * as dotenv from "dotenv";
import path from "path";
import os from "os";
import * as lodash from "lodash";
-import { KcTheme } from "../../../../kc_shared/models/style.model";
import { app, BrowserWindow, ipcMain } from "electron";
import { profile, resourcesDir } from "../profile";
@@ -209,36 +196,13 @@ class SettingsService {
defaults(envPath: string): SettingsModel {
const env = SettingsService.getEnvironment(envPath);
const system: SystemSettingsModel = this.system(envPath);
- return {
- env: env,
- system: system,
- app: {
- table: new TableSettingsModel(),
- grid: new GridSettingsModel(),
- calendar: new CalendarSettingsModel(),
- projects: new ProjectSettingsModel(),
- graph: new GraphSettingsModel(),
- chat: new ChatSettingsModel(),
- },
- display: {
- theme: new KcTheme(),
- syncTheme: true,
- logging: new LoggingSettingsModel(),
- zoom: 100,
- autoplay: true,
- animations: true,
- },
- docker: new DockerSettingsModel(),
- ingest: {
- manager: new FileManagerSettingsModel(path.resolve(system.appPath)),
- extensions: new ExtensionServerSettingsModel(__dirname),
- autoscan: new AutoscanSettingsModel(
- path.resolve(system.downloadPath, "Knowledge")
- ),
- },
- search: new SearchSettingsModel(),
- user: new UserSettingsModel(),
- };
+ return createDefaultSettings({
+ env,
+ system,
+ storagePath: path.resolve(system.appPath),
+ extensionsPath: __dirname,
+ autoscanPath: path.resolve(system.downloadPath, "Knowledge"),
+ });
}
error(summary: string, description: string) {
diff --git a/src/kc_electron/tsconfig.json b/src/kc_electron/tsconfig.json
index 67c56b9c..452c7154 100755
--- a/src/kc_electron/tsconfig.json
+++ b/src/kc_electron/tsconfig.json
@@ -66,10 +66,8 @@
/* Module Resolution Options */
"moduleResolution": "node",
/* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
- "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
- "paths": {
- "@shared/*": ["../kc_shared/*"]
- }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
diff --git a/src/kc_shared/models/project.model.ts b/src/kc_shared/models/project.model.ts
index 995d3230..4acdc573 100755
--- a/src/kc_shared/models/project.model.ts
+++ b/src/kc_shared/models/project.model.ts
@@ -13,24 +13,34 @@
See the License for the specific language governing permissions and
limitations under the License.
*/
-import {KnowledgeSource} from "kc_angular/src/app/models/knowledge.source.model";
-import {UUID} from "./uuid.model";
-import {EventModel} from "./event.model";
+import { KnowledgeSourceModel } from "./knowledge.source.model";
+import { UUID } from "./uuid.model";
+import { EventModel } from "./event.model";
-export type KcProjectType = 'default' | 'school' | 'work' | 'hobby' | 'research';
+export type KcProjectType =
+ | "default"
+ | "school"
+ | "work"
+ | "hobby"
+ | "research";
-export interface KcProjectModel {
- readonly id: UUID;
- name: string;
- type: KcProjectType;
- description: string;
- events?: EventModel[];
- authors: string[];
- parentId: UUID;
- subprojects: string[];
- topics: string[];
- sources: UUID[];
+/**
+ * S is the source type of the client. The default is the shared source
+ * model. This file must not import from Angular or Electron.
+ */
+export interface KcProjectModel {
+ readonly id: UUID;
+ name: string;
+ type: KcProjectType;
+ description: string;
+ events?: EventModel[];
+ authors: string[];
+ parentId: UUID;
+ subprojects: string[];
+ topics: string[];
+ sources: UUID[];
- // TODO: this needs to be removed
- knowledgeSource: KnowledgeSource[];
+ // Sources are stored separately by the storage service. The UI keeps
+ // them on the project object.
+ knowledgeSource: S[];
}
diff --git a/src/kc_shared/models/settings.model.ts b/src/kc_shared/models/settings.model.ts
index 1a3d7278..c62c338a 100755
--- a/src/kc_shared/models/settings.model.ts
+++ b/src/kc_shared/models/settings.model.ts
@@ -259,3 +259,51 @@ export class ChatSettingsModel {
model: ChatModel = SupportedChatModels[0];
}
+
+export interface DefaultSettingsInput {
+ env: EnvironmentSettingsModel;
+ system: SystemSettingsModel;
+ /** Managed storage root (desktop). Empty in a browser. */
+ storagePath: string;
+ /** Browser extension server files (desktop). Empty in a browser. */
+ extensionsPath: string;
+ /** Watched folder (desktop). Empty in a browser. */
+ autoscanPath: string;
+}
+
+/**
+ * Default settings. Electron and the browser client both use this, so the
+ * settings have one shape on all platforms.
+ */
+export function createDefaultSettings(
+ input: DefaultSettingsInput
+): SettingsModel {
+ return {
+ env: input.env,
+ system: input.system,
+ app: {
+ table: new TableSettingsModel(),
+ grid: new GridSettingsModel(),
+ calendar: new CalendarSettingsModel(),
+ projects: new ProjectSettingsModel(),
+ graph: new GraphSettingsModel(),
+ chat: new ChatSettingsModel(),
+ },
+ display: {
+ theme: new KcTheme(),
+ syncTheme: true,
+ logging: new LoggingSettingsModel(),
+ zoom: 100,
+ autoplay: true,
+ animations: true,
+ },
+ docker: new DockerSettingsModel(),
+ ingest: {
+ manager: new FileManagerSettingsModel(input.storagePath),
+ extensions: new ExtensionServerSettingsModel(input.extensionsPath),
+ autoscan: new AutoscanSettingsModel(input.autoscanPath),
+ },
+ search: new SearchSettingsModel(),
+ user: new UserSettingsModel(),
+ };
+}
diff --git a/src/kc_storage/src/cli.ts b/src/kc_storage/src/cli.ts
index 4db1aafa..fae51fdc 100644
--- a/src/kc_storage/src/cli.ts
+++ b/src/kc_storage/src/cli.ts
@@ -18,13 +18,17 @@ const USAGE = `kc-storage ${VERSION}
Usage:
node src/main.ts serve --data-dir [--port ] [--allow-origin ]...
[--max-upload-mb ] [--exit-on-stdin-close]
+ [--web-root [--session-idle-minutes ]
+ [--session-max-hours ] [--launch-code-minutes ]]
node src/main.ts migrate --data-dir --from [--dry-run]
node src/main.ts backup --data-dir --out
node src/main.ts restore --data-dir --from
serve needs the bearer token in the KC_STORAGE_TOKEN environment variable.
serve listens on 127.0.0.1 only. Port 0 (default) picks a free port.
-When ready, serve prints one JSON line: {"event":"ready","url":...}.`;
+When ready, serve prints one JSON line: {"event":"ready","url":...}.
+--web-root serves the browser UI from and enables browser sessions.
+Use scripts/start-browser.mjs (yarn browser) to start it with a launch link.`;
function fail(message: string): never {
console.error(`kc-storage: ${message}`);
@@ -43,6 +47,10 @@ export async function main(argv: string[]) {
"allow-origin": { type: "string", multiple: true, default: [] },
"exit-on-stdin-close": { type: "boolean", default: false },
"max-upload-mb": { type: "string", default: "2048" },
+ "web-root": { type: "string" },
+ "session-idle-minutes": { type: "string", default: "30" },
+ "session-max-hours": { type: "string", default: "12" },
+ "launch-code-minutes": { type: "string", default: "5" },
from: { type: "string" },
out: { type: "string" },
"dry-run": { type: "boolean", default: false },
@@ -170,11 +178,23 @@ function serve(dataDir: string, values: Record) {
`[kc-storage] removed ${dir.swept.length} unreferenced or temporary files`
);
}
+ const minutes = (name: string) => {
+ const n = Number(values[name]);
+ if (!Number.isFinite(n) || n <= 0)
+ fail(`--${name} must be a positive number.`);
+ return n * 60 * 1000;
+ };
const server = createServer({
dir,
token,
allowedOrigins: values["allow-origin"],
maxUploadBytes: Math.floor(maxUploadMb * 1024 * 1024),
+ webRoot: values["web-root"],
+ sessionOptions: {
+ idleMs: minutes("session-idle-minutes"),
+ maxMs: minutes("session-max-hours") * 60,
+ launchCodeMs: minutes("launch-code-minutes"),
+ },
});
let stopping = false;
@@ -215,6 +235,7 @@ function serve(dataDir: string, values: Record) {
url: `http://127.0.0.1:${actual}`,
version: VERSION,
dataDir: dir.root,
+ webUi: Boolean(values["web-root"]),
})
);
});
diff --git a/src/kc_storage/src/http.ts b/src/kc_storage/src/http.ts
index 992b092b..7d810931 100644
--- a/src/kc_storage/src/http.ts
+++ b/src/kc_storage/src/http.ts
@@ -46,6 +46,14 @@ import {
validSource,
} from "./validate.ts";
import { VERSION } from "./version.ts";
+import {
+ equalSecrets,
+ readCookie,
+ SessionStore,
+ type Session,
+ type SessionOptions,
+} from "./sessions.ts";
+import { createWebUi } from "./webui.ts";
/** How long a validated restore waits for confirmation. */
const RESTORE_PENDING_MS = 30 * 60 * 1000;
@@ -53,10 +61,61 @@ const RESTORE_PENDING_MS = 30 * 60 * 1000;
export interface ServerOptions {
dir: DataDir;
token: string;
- /** Value of the Origin header that may use the API. "null" for file:// pages. */
+ /** Other origins that may read responses (CORS). "null" for file:// pages. */
allowedOrigins: string[];
maxJsonBytes?: number;
maxUploadBytes?: number;
+ /** Serve the browser UI from this directory and enable browser sessions. */
+ webRoot?: string;
+ sessionOptions?: SessionOptions;
+}
+
+const CSRF_HEADER = "x-knowledge-csrf";
+const UNSAFE_METHODS = ["POST", "PUT", "DELETE", "PATCH"];
+
+/** Managed file types that a browser may show inline. */
+const INLINE_TYPES = [
+ "application/pdf",
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/webp",
+];
+
+/**
+ * Response headers for a managed file. Imported content must not run with
+ * the application's origin: only PDF, raster images, and text show inline.
+ * Text is always served as text/plain. Other files are downloads. All
+ * files except PDF get a sandbox CSP.
+ */
+export function contentHeaders(asset: {
+ mediaType: string;
+ size: number;
+ filename: string;
+}) {
+ const name = encodeURIComponent(asset.filename);
+ const isText =
+ asset.mediaType.startsWith("text/") &&
+ !["text/html", "text/xml"].includes(asset.mediaType);
+ const inline = INLINE_TYPES.includes(asset.mediaType) || isText;
+ const headers: Record = {
+ "Content-Type": isText
+ ? "text/plain; charset=utf-8"
+ : inline
+ ? asset.mediaType
+ : "application/octet-stream",
+ "Content-Length": asset.size,
+ "Content-Disposition": `${
+ inline ? "inline" : "attachment"
+ }; filename*=UTF-8''${name}`,
+ "X-Content-Type-Options": "nosniff",
+ "Cache-Control": "no-store",
+ };
+ if (asset.mediaType !== "application/pdf") {
+ headers["Content-Security-Policy"] =
+ "sandbox; default-src 'none'; img-src 'self'; style-src 'unsafe-inline'";
+ }
+ return headers;
}
const ALLOWED_HEADERS = [
@@ -115,6 +174,8 @@ export function createServer(options: ServerOptions): http.Server {
const { library, assets } = dir;
const maxJson = options.maxJsonBytes ?? 10 * 1024 * 1024;
const maxUpload = options.maxUploadBytes ?? 2 * 1024 ** 3;
+ const webUi = options.webRoot ? createWebUi(options.webRoot) : undefined;
+ const sessions = webUi ? new SessionStore(options.sessionOptions) : undefined;
// A validated restore waits here for confirmation. One at a time.
let pending: { staged: StagedRestore; timer: NodeJS.Timeout } | undefined;
@@ -151,6 +212,102 @@ export function createServer(options: ServerOptions): http.Server {
await pipeline(req, meter, fs.createWriteStream(file, { flags: "wx" }));
}
+ const port = () => (server.address() as AddressInfo).port;
+
+ /** Cookie names are per port: browsers share 127.0.0.1 cookies across ports. */
+ const cookieName = () => `kc_session_${port()}`;
+
+ /** The origin of this service, as the browser addressed it. */
+ const ownOrigin = (req: IncomingMessage) => `http://${req.headers.host}`;
+
+ /**
+ * Requests with a session cookie must come from this service's own pages.
+ * State-changing requests also need the CSRF token and the own Origin.
+ */
+ function checkSessionRequest(
+ req: IncomingMessage,
+ method: string,
+ session: Session
+ ) {
+ const site = req.headers["sec-fetch-site"];
+ if (site !== undefined && site !== "same-origin" && site !== "none") {
+ throw forbidden("Session requests must come from this service's pages.");
+ }
+ if (UNSAFE_METHODS.includes(method)) {
+ if (req.headers.origin !== ownOrigin(req)) {
+ throw forbidden(
+ "A state-changing session request needs this service's Origin."
+ );
+ }
+ const csrf = req.headers[CSRF_HEADER];
+ if (typeof csrf !== "string" || !equalSecrets(csrf, session.csrfToken)) {
+ throw forbidden("Missing or invalid CSRF token.");
+ }
+ }
+ }
+
+ async function sessionRoute(
+ req: IncomingMessage,
+ res: ServerResponse,
+ method: string,
+ sub: string | undefined,
+ bearer: boolean
+ ) {
+ const store = sessions!;
+ const describe = (session: Session) => ({
+ csrfToken: session.csrfToken,
+ expiresAt: new Date(store.expiresAt(session)).toISOString(),
+ });
+
+ // A bearer client (the launcher) asks for a one-time launch code
+ if (sub === "launch" && method === "POST") {
+ if (!bearer) throw unauthorized("A launch code needs the bearer token.");
+ const { code, expiresAt } = store.createLaunchCode();
+ return send(res, 201, {
+ url: `http://127.0.0.1:${port()}/#launch=${code}`,
+ expiresAt: new Date(expiresAt).toISOString(),
+ });
+ }
+ if (sub) throw notFound(`No route for ${method} ${req.url}.`);
+
+ if (method === "POST") {
+ if (req.headers.origin !== ownOrigin(req)) {
+ throw forbidden("A session can start only from this service's pages.");
+ }
+ const body = (await readJson(req, 4096)) as { code?: unknown };
+ const session = store.exchange(body?.code);
+ if (!session) {
+ throw unauthorized(
+ "The launch link is invalid, used, or expired. Get a new link from the terminal."
+ );
+ }
+ res.setHeader(
+ "Set-Cookie",
+ `${cookieName()}=${session.id}; HttpOnly; SameSite=Strict; Path=/`
+ );
+ return send(res, 201, describe(session));
+ }
+
+ const id = readCookie(req.headers.cookie, cookieName());
+ const session = store.get(id);
+ if (!session)
+ throw unauthorized(
+ "No browser session. Open the launch link from the terminal."
+ );
+ checkSessionRequest(req, method, session);
+
+ if (method === "GET") return send(res, 200, describe(session));
+ if (method === "DELETE") {
+ store.end(session.id);
+ res.setHeader(
+ "Set-Cookie",
+ `${cookieName()}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`
+ );
+ return send(res, 204);
+ }
+ throw notFound(`No route for ${method} ${req.url}.`);
+ }
+
async function route(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? "/", "http://localhost");
let parts: string[];
@@ -172,13 +329,28 @@ export function createServer(options: ServerOptions): http.Server {
}
if (parts[0] !== "v1") {
+ if (webUi && webUi(req, res, url.pathname)) return;
throw notFound(`No route for ${method} ${url.pathname}.`);
}
- if (!tokenMatches(req.headers.authorization, token)) {
- throw unauthorized("Missing or invalid bearer token.");
- }
const [, resource, id, sub] = parts;
+ const bearer = tokenMatches(req.headers.authorization, token);
+
+ // Browser session routes
+ if (sessions && resource === "session") {
+ return sessionRoute(req, res, method, id, bearer);
+ }
+
+ if (!bearer) {
+ // A browser session can use the API in place of the bearer token
+ const session = sessions?.get(
+ readCookie(req.headers.cookie, cookieName())
+ );
+ if (!session) {
+ throw unauthorized("Missing or invalid bearer token or session.");
+ }
+ checkSessionRequest(req, method, session);
+ }
if (resource === "projects" && !sub) {
if (method === "GET" && !id)
@@ -268,15 +440,8 @@ export function createServer(options: ServerOptions): http.Server {
`Managed file for asset ${asset.id} is missing.`
);
}
- res.writeHead(200, {
- "Content-Type": asset.mediaType,
- "Content-Length": asset.size,
- "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(
- asset.filename
- )}`,
- "X-Content-Type-Options": "nosniff",
- "Cache-Control": "no-store",
- });
+ // An optional last path part (the filename) only names the response
+ res.writeHead(200, contentHeaders(asset));
fs.createReadStream(file).pipe(res);
return;
}
@@ -349,15 +514,14 @@ export function createServer(options: ServerOptions): http.Server {
const server = http.createServer(async (req, res) => {
try {
// Reject requests that do not address this server by its loopback name
- const port = (server.address() as AddressInfo).port;
const host = req.headers.host;
- if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
+ if (host !== `127.0.0.1:${port()}` && host !== `localhost:${port()}`) {
throw forbidden("Invalid Host header.");
}
// CORS lets the configured page read responses. It is not authentication.
const origin = req.headers.origin;
- if (origin !== undefined) {
+ if (origin !== undefined && !(webUi && origin === ownOrigin(req))) {
if (!allowedOrigins.includes(origin)) {
throw forbidden("Origin not allowed.");
}
diff --git a/src/kc_storage/src/sessions.ts b/src/kc_storage/src/sessions.ts
new file mode 100644
index 00000000..65d0d243
--- /dev/null
+++ b/src/kc_storage/src/sessions.ts
@@ -0,0 +1,114 @@
+/*
+ * Browser sessions for the local web UI. See docs/browser-library.md.
+ *
+ * - A bearer-token client creates a launch code (single use, short-lived).
+ * - The page exchanges the code for a session cookie and a CSRF token.
+ * - Sessions live in memory. They end after an idle time, after a maximum
+ * age, on logout, or when the service stops.
+ */
+
+import crypto from "node:crypto";
+
+export interface SessionOptions {
+ launchCodeMs: number;
+ idleMs: number;
+ maxMs: number;
+}
+
+export const DEFAULT_SESSION_OPTIONS: SessionOptions = {
+ launchCodeMs: 5 * 60 * 1000,
+ idleMs: 30 * 60 * 1000,
+ maxMs: 12 * 60 * 60 * 1000,
+};
+
+export interface Session {
+ id: string;
+ csrfToken: string;
+ createdAt: number;
+ lastSeen: number;
+}
+
+const random = () => crypto.randomBytes(32).toString("base64url");
+
+export function equalSecrets(a: string, b: string) {
+ const x = Buffer.from(a);
+ const y = Buffer.from(b);
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
+}
+
+export class SessionStore {
+ private codes = new Map();
+ private sessions = new Map();
+ readonly options: SessionOptions;
+
+ constructor(options: SessionOptions = DEFAULT_SESSION_OPTIONS) {
+ this.options = options;
+ }
+
+ createLaunchCode(now = Date.now()) {
+ this.prune(now);
+ const code = random();
+ const expiresAt = now + this.options.launchCodeMs;
+ this.codes.set(code, expiresAt);
+ return { code, expiresAt };
+ }
+
+ /** Use a launch code once. Returns a new session, or undefined. */
+ exchange(code: unknown, now = Date.now()): Session | undefined {
+ this.prune(now);
+ if (typeof code !== "string") return undefined;
+ const expiresAt = this.codes.get(code);
+ this.codes.delete(code);
+ if (expiresAt === undefined || expiresAt < now) return undefined;
+ const session: Session = {
+ id: random(),
+ csrfToken: random(),
+ createdAt: now,
+ lastSeen: now,
+ };
+ this.sessions.set(session.id, session);
+ return session;
+ }
+
+ /** A valid session for the cookie value. Updates its idle time. */
+ get(id: string | undefined, now = Date.now()): Session | undefined {
+ if (!id) return undefined;
+ const session = this.sessions.get(id);
+ if (!session) return undefined;
+ if (
+ now - session.lastSeen > this.options.idleMs ||
+ now - session.createdAt > this.options.maxMs
+ ) {
+ this.sessions.delete(id);
+ return undefined;
+ }
+ session.lastSeen = now;
+ return session;
+ }
+
+ expiresAt(session: Session) {
+ return Math.min(
+ session.lastSeen + this.options.idleMs,
+ session.createdAt + this.options.maxMs
+ );
+ }
+
+ end(id: string) {
+ this.sessions.delete(id);
+ }
+
+ private prune(now: number) {
+ for (const [code, expiresAt] of this.codes) {
+ if (expiresAt < now) this.codes.delete(code);
+ }
+ }
+}
+
+/** Value of one cookie from a Cookie header. */
+export function readCookie(header: string | undefined, name: string) {
+ for (const part of (header ?? "").split(";")) {
+ const [key, ...rest] = part.trim().split("=");
+ if (key === name) return rest.join("=");
+ }
+ return undefined;
+}
diff --git a/src/kc_storage/src/webui.ts b/src/kc_storage/src/webui.ts
new file mode 100644
index 00000000..5f463be6
--- /dev/null
+++ b/src/kc_storage/src/webui.ts
@@ -0,0 +1,111 @@
+/*
+ * Serves the built Angular UI for the local browser client.
+ *
+ * Paths are resolved inside the web root only. Unknown paths without a
+ * file extension return index.html, so client routes survive a reload.
+ * The desktop build uses for file:// pages. The service
+ * changes it to "/" when it serves index.html.
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+import type { IncomingMessage, ServerResponse } from "node:http";
+
+const TYPES: Record = {
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".mjs": "text/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".map": "application/json; charset=utf-8",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".gif": "image/gif",
+ ".ico": "image/x-icon",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+ ".ttf": "font/ttf",
+ ".eot": "application/vnd.ms-fontobject",
+ ".mp3": "audio/mpeg",
+ ".wav": "audio/wav",
+};
+
+/** Content Security Policy for UI pages. */
+export const UI_CSP = [
+ "default-src 'self'",
+ "script-src 'self'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: blob: https:",
+ "font-src 'self' data:",
+ "connect-src 'self'",
+ "media-src 'self'",
+ "object-src 'self'",
+ "frame-src 'self'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ "frame-ancestors 'none'",
+].join("; ");
+
+export function createWebUi(webRoot: string) {
+ const root = path.resolve(webRoot);
+ const indexFile = path.join(root, "index.html");
+ if (!fs.existsSync(indexFile)) {
+ throw new Error(
+ `No index.html in the web root ${root}. Build the UI first.`
+ );
+ }
+
+ return function serve(
+ req: IncomingMessage,
+ res: ServerResponse,
+ pathname: string
+ ): boolean {
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
+
+ let decoded: string;
+ try {
+ decoded = decodeURIComponent(pathname);
+ } catch {
+ return false;
+ }
+ let file = path.resolve(root, "." + decoded);
+ if (!file.startsWith(root + path.sep) && file !== root) return false;
+
+ const isFile = fs.existsSync(file) && fs.statSync(file).isFile();
+ if (!isFile) {
+ // Client routes have no extension. Missing files with one are 404.
+ if (path.extname(pathname)) return false;
+ file = indexFile;
+ }
+
+ const headers: Record = {
+ "Content-Type": TYPES[path.extname(file)] ?? "application/octet-stream",
+ "X-Content-Type-Options": "nosniff",
+ "Referrer-Policy": "no-referrer",
+ "Cache-Control": "no-cache",
+ };
+
+ if (file === indexFile) {
+ const html = fs
+ .readFileSync(indexFile, "utf8")
+ .replace(' ', ' ');
+ res.writeHead(200, {
+ ...headers,
+ "Content-Security-Policy": UI_CSP,
+ "X-Frame-Options": "DENY",
+ "Cache-Control": "no-store",
+ });
+ res.end(req.method === "HEAD" ? undefined : html);
+ return true;
+ }
+
+ res.writeHead(200, {
+ ...headers,
+ "Content-Length": fs.statSync(file).size,
+ });
+ if (req.method === "HEAD") res.end();
+ else fs.createReadStream(file).pipe(res);
+ return true;
+ };
+}
diff --git a/src/kc_storage/test/browser-session.test.ts b/src/kc_storage/test/browser-session.test.ts
new file mode 100644
index 00000000..45ebf5ad
--- /dev/null
+++ b/src/kc_storage/test/browser-session.test.ts
@@ -0,0 +1,415 @@
+import { afterEach, test } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import {
+ FIXTURES,
+ client,
+ rawRequest,
+ startService,
+ stopAll,
+ tempDir,
+ type Service,
+} from "./helpers.ts";
+
+afterEach(stopAll);
+
+/** A minimal built UI: index.html with the desktop base href, and a script. */
+function webRoot() {
+ const dir = tempDir("web");
+ fs.writeFileSync(
+ path.join(dir, "index.html"),
+ ' '
+ );
+ fs.writeFileSync(path.join(dir, "main.js"), "console.log('ui');");
+ return dir;
+}
+
+async function browserService(extra: string[] = []) {
+ const svc = await startService(tempDir("session"), [
+ "--web-root",
+ webRoot(),
+ ...extra,
+ ]);
+ return svc;
+}
+
+const own = (svc: Service) => `http://127.0.0.1:${svc.port}`;
+
+async function launchCode(svc: Service) {
+ const res = await fetch(`${svc.url}/v1/session/launch`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${svc.token}` },
+ });
+ assert.equal(res.status, 201);
+ const { url } = (await res.json()) as any;
+ assert.match(url, new RegExp(`^${own(svc)}/#launch=[A-Za-z0-9_-]{43}$`));
+ return url.split("#launch=")[1];
+}
+
+async function startSession(svc: Service) {
+ const code = await launchCode(svc);
+ const res = await rawRequest(svc, {
+ method: "POST",
+ path: "/v1/session",
+ headers: { Origin: own(svc), "Content-Type": "application/json" },
+ body: JSON.stringify({ code }),
+ });
+ assert.equal(res.status, 201, res.body);
+ const cookie = String(res.headers["set-cookie"]).split(";")[0];
+ return {
+ code,
+ cookie,
+ csrf: JSON.parse(res.body).csrfToken as string,
+ setCookie: String(res.headers["set-cookie"]),
+ };
+}
+
+test("the service serves the UI only with --web-root, inside the web root", async () => {
+ const plain = await startService(tempDir("noweb"));
+ assert.equal((await rawRequest(plain, { path: "/" })).status, 404);
+ assert.equal(
+ (
+ await rawRequest(plain, {
+ method: "POST",
+ path: "/v1/session/launch",
+ headers: { Authorization: `Bearer ${plain.token}` },
+ })
+ ).status,
+ 404
+ );
+
+ const svc = await browserService();
+ const index = await rawRequest(svc, { path: "/" });
+ assert.equal(index.status, 200);
+ assert.match(index.body, / /);
+ assert.match(
+ String(index.headers["content-security-policy"]),
+ /default-src 'self'; script-src 'self'/
+ );
+ assert.match(
+ String(index.headers["content-security-policy"]),
+ /frame-ancestors 'none'/
+ );
+ assert.equal(index.headers["x-frame-options"], "DENY");
+
+ // Client routes return index.html; files are served with their type
+ assert.match(
+ (await rawRequest(svc, { path: "/app/inbox/123" })).body,
+ //
+ );
+ const js = await rawRequest(svc, { path: "/main.js" });
+ assert.equal(js.headers["content-type"], "text/javascript; charset=utf-8");
+
+ // Nothing outside the web root
+ for (const p of [
+ "/%2e%2e/library.sqlite",
+ "/..%2Flibrary.sqlite",
+ "/missing.js",
+ ]) {
+ const res = await rawRequest(svc, { path: p });
+ assert.ok([400, 404].includes(res.status), `${p} -> ${res.status}`);
+ assert.ok(!res.body.includes("SQLite format"));
+ }
+});
+
+test("a launch code is single use, needs the bearer token, and expires", async () => {
+ const svc = await browserService(["--launch-code-minutes", "0.005"]);
+ assert.equal(
+ (await rawRequest(svc, { method: "POST", path: "/v1/session/launch" }))
+ .status,
+ 401
+ );
+
+ const { code, setCookie, csrf } = await startSession(svc);
+ assert.match(
+ setCookie,
+ new RegExp(
+ `^kc_session_${svc.port}=[A-Za-z0-9_-]{43}; HttpOnly; SameSite=Strict; Path=/$`
+ )
+ );
+ assert.match(csrf, /^[A-Za-z0-9_-]{43}$/);
+
+ const reuse = await rawRequest(svc, {
+ method: "POST",
+ path: "/v1/session",
+ headers: { Origin: own(svc), "Content-Type": "application/json" },
+ body: JSON.stringify({ code }),
+ });
+ assert.equal(reuse.status, 401);
+
+ // The exchange must come from the service's own page
+ for (const origin of [
+ undefined,
+ "https://evil.example",
+ `http://127.0.0.1:${svc.port + 1}`,
+ ]) {
+ const fresh = await launchCode(svc);
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ if (origin) headers.Origin = origin;
+ const res = await rawRequest(svc, {
+ method: "POST",
+ path: "/v1/session",
+ headers,
+ body: JSON.stringify({ code: fresh }),
+ });
+ assert.equal(res.status, 403, `origin ${origin}`);
+ }
+
+ const late = await launchCode(svc);
+ await new Promise((r) => setTimeout(r, 600));
+ const expired = await rawRequest(svc, {
+ method: "POST",
+ path: "/v1/session",
+ headers: { Origin: own(svc), "Content-Type": "application/json" },
+ body: JSON.stringify({ code: late }),
+ });
+ assert.equal(expired.status, 401);
+});
+
+test("session requests need same-origin fetches, and writes need CSRF token and Origin", async () => {
+ const svc = await browserService();
+ const { cookie, csrf } = await startSession(svc);
+ const body = JSON.stringify({
+ name: "From browser",
+ parentId: null,
+ data: {},
+ });
+ const put = (headers: Record) =>
+ rawRequest(svc, {
+ method: "PUT",
+ path: "/v1/projects/p1",
+ headers: {
+ Cookie: cookie,
+ "Content-Type": "application/json",
+ ...headers,
+ },
+ body,
+ });
+
+ // No credentials, or another port's cookie
+ assert.equal((await rawRequest(svc, { path: "/v1/projects" })).status, 401);
+ const otherPortCookie = cookie.replace(
+ `kc_session_${svc.port}`,
+ `kc_session_${svc.port + 1}`
+ );
+ assert.equal(
+ (
+ await rawRequest(svc, {
+ path: "/v1/projects",
+ headers: { Cookie: otherPortCookie },
+ })
+ ).status,
+ 401
+ );
+
+ // Reads from the own page work; reads started by other sites do not
+ assert.equal(
+ (
+ await rawRequest(svc, {
+ path: "/v1/projects",
+ headers: { Cookie: cookie, "Sec-Fetch-Site": "same-origin" },
+ })
+ ).status,
+ 200
+ );
+ for (const site of ["cross-site", "same-site"]) {
+ assert.equal(
+ (
+ await rawRequest(svc, {
+ path: "/v1/projects",
+ headers: { Cookie: cookie, "Sec-Fetch-Site": site },
+ })
+ ).status,
+ 403,
+ site
+ );
+ }
+
+ // Writes
+ assert.equal((await put({ Origin: own(svc) })).status, 403, "no CSRF token");
+ assert.equal(
+ (await put({ "X-Knowledge-CSRF": csrf })).status,
+ 403,
+ "no Origin"
+ );
+ assert.equal(
+ (await put({ "X-Knowledge-CSRF": csrf, Origin: "https://evil.example" }))
+ .status,
+ 403,
+ "other Origin"
+ );
+ assert.equal(
+ (await put({ "X-Knowledge-CSRF": "x".repeat(43), Origin: own(svc) }))
+ .status,
+ 403,
+ "wrong CSRF token"
+ );
+ assert.equal(
+ (await put({ "X-Knowledge-CSRF": csrf, Origin: own(svc) })).status,
+ 201
+ );
+
+ // The page gets its CSRF token again after a reload
+ const again = await rawRequest(svc, {
+ path: "/v1/session",
+ headers: { Cookie: cookie },
+ });
+ assert.equal(JSON.parse(again.body).csrfToken, csrf);
+
+ // Logout ends the session
+ const logout = await rawRequest(svc, {
+ method: "DELETE",
+ path: "/v1/session",
+ headers: { Cookie: cookie, Origin: own(svc), "X-Knowledge-CSRF": csrf },
+ });
+ assert.equal(logout.status, 204);
+ assert.match(String(logout.headers["set-cookie"]), /Max-Age=0/);
+ assert.equal(
+ (
+ await rawRequest(svc, {
+ path: "/v1/projects",
+ headers: { Cookie: cookie },
+ })
+ ).status,
+ 401
+ );
+
+ // Bearer clients are unchanged
+ assert.equal((await client(svc).get("/v1/projects")).status, 200);
+});
+
+test("sessions end after the idle time and the maximum age", async () => {
+ const idle = await browserService(["--session-idle-minutes", "0.005"]);
+ const a = await startSession(idle);
+ assert.equal(
+ (
+ await rawRequest(idle, {
+ path: "/v1/projects",
+ headers: { Cookie: a.cookie },
+ })
+ ).status,
+ 200
+ );
+ await new Promise((r) => setTimeout(r, 600));
+ assert.equal(
+ (
+ await rawRequest(idle, {
+ path: "/v1/projects",
+ headers: { Cookie: a.cookie },
+ })
+ ).status,
+ 401
+ );
+
+ const max = await browserService(["--session-max-hours", "0.0001"]);
+ const b = await startSession(max);
+ for (let i = 0; i < 4; i++) {
+ assert.equal(
+ (
+ await rawRequest(max, {
+ path: "/v1/projects",
+ headers: { Cookie: b.cookie },
+ })
+ ).status,
+ 200
+ );
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ await new Promise((r) => setTimeout(r, 400));
+ assert.equal(
+ (
+ await rawRequest(max, {
+ path: "/v1/projects",
+ headers: { Cookie: b.cookie },
+ })
+ ).status,
+ 401
+ );
+});
+
+test("sessions end when the service restarts", async () => {
+ const dataDir = tempDir("restart");
+ const root = webRoot();
+ const first = await startService(dataDir, ["--web-root", root]);
+ const { cookie } = await startSession(first);
+ await first.stop();
+ const second = await startService(dataDir, ["--web-root", root]);
+ const res = await rawRequest(second, {
+ path: "/v1/projects",
+ headers: { Cookie: cookie.replace(`_${first.port}`, `_${second.port}`) },
+ });
+ assert.equal(res.status, 401);
+});
+
+test("managed files cannot run as the application; filenames are kept", async () => {
+ const svc = await browserService();
+ const api = client(svc);
+ const html = path.join(tempDir("html"), "page.html");
+ fs.writeFileSync(html, "");
+ const svg = path.join(tempDir("svg"), "pic.svg");
+ fs.writeFileSync(
+ svg,
+ ""
+ );
+
+ const cases = [
+ {
+ file: html,
+ type: "text/html",
+ disposition: /^attachment/,
+ contentType: "application/octet-stream",
+ csp: true,
+ },
+ {
+ file: svg,
+ type: "image/svg+xml",
+ disposition: /^attachment/,
+ contentType: "application/octet-stream",
+ csp: true,
+ },
+ {
+ file: path.join(FIXTURES, "recovery-note.txt"),
+ type: "text/markdown",
+ disposition: /^inline/,
+ contentType: "text/plain; charset=utf-8",
+ csp: true,
+ },
+ {
+ file: path.join(FIXTURES, "recovery-fixture.pdf"),
+ type: "application/pdf",
+ disposition: /^inline/,
+ contentType: "application/pdf",
+ csp: false,
+ },
+ ];
+ for (const c of cases) {
+ const asset = (await api.upload(c.file, path.basename(c.file), c.type)).body
+ .asset;
+ const res = await rawRequest(svc, {
+ path: `/v1/assets/${asset.id}/content/${encodeURIComponent(
+ asset.filename
+ )}`,
+ headers: { Authorization: `Bearer ${svc.token}` },
+ });
+ assert.equal(res.status, 200, c.type);
+ assert.match(
+ String(res.headers["content-disposition"]),
+ c.disposition,
+ c.type
+ );
+ assert.match(
+ String(res.headers["content-disposition"]),
+ new RegExp(encodeURIComponent(asset.filename).replace(/\./g, "\\."))
+ );
+ assert.equal(res.headers["content-type"], c.contentType, c.type);
+ assert.equal(res.headers["x-content-type-options"], "nosniff");
+ assert.equal(
+ String(res.headers["content-security-policy"] ?? "").startsWith("sandbox"),
+ c.csp,
+ c.type
+ );
+ }
+});
diff --git a/src/kc_storage/test/helpers.ts b/src/kc_storage/test/helpers.ts
index 84757a96..96f1f39f 100644
--- a/src/kc_storage/test/helpers.ts
+++ b/src/kc_storage/test/helpers.ts
@@ -161,7 +161,12 @@ export function client(service: Service) {
/** Raw request with full control of headers (Host, Origin). */
export function rawRequest(
service: Service,
- options: { method?: string; path: string; headers?: Record }
+ options: {
+ method?: string;
+ path: string;
+ headers?: Record;
+ body?: string | Buffer;
+ }
): Promise<{
status: number;
headers: http.IncomingHttpHeaders;
@@ -188,6 +193,6 @@ export function rawRequest(
req.setTimeout(5000, () =>
req.destroy(new Error(`Timed out: ${options.path}`))
);
- req.end();
+ req.end(options.body);
});
}