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 ` (default `.dev-profiles/browser/library`), +`--web-root ` (default `src/kc_angular/dist/main`), `--open` (macOS: +open the default browser). + +What works in the browser: projects, file upload (PDF, text, and other +files), the Document view for PDF, text, and images, topics and metadata, +search, opening managed files in a new tab, library export and restore, +chat and preferences backup (browser local storage). + +Desktop only (hidden or disabled in the browser, with a reason): saving +websites and the example websites, chat, the built-in browser, watched +folders and extension settings, window controls, opening files in other +apps, showing files in Finder, file thumbnails and file icons, dragging +files out. + +### Browser session + +- The launch code is 256 random bits, single use, and expires after 5 + minutes. Only a bearer-token client (the launcher) can create one. +- The page exchanges it for an `HttpOnly`, `SameSite=Strict` cookie, + `kc_session_`, and a CSRF token that stays in page memory. +- Writes need the CSRF token and the service's own `Origin`. Requests that + a browser marks as cross-site or same-site (another local port) are + refused. +- A session ends after 30 minutes without requests, after 12 hours, on + `DELETE /v1/session`, or when the service stops. +- The bearer token is never printed or sent to the browser. The launch + code appears once in the terminal and in the first URL fragment. +- This is a local, single-user setup. It is not a hosted deployment. + ## Storage service without Electron ```sh @@ -265,6 +326,7 @@ Stop the app first. The command uses the same checks as the app. | `yarn workspace kc_storage typecheck` | Storage service types (TypeScript 5) | | | `yarn e2e` | Desktop UI with real profiles (below) | macOS, `yarn build-dev` first | | `yarn e2e-packaged` | The unsigned package (below) | macOS arm64, `yarn package-local` first | +| `yarn e2e-browser` | The browser client in Google Chrome, no Electron (below) | Google Chrome, `yarn build-angular-dev` first | The end-to-end tests start the real app with new, empty profiles in `e2e/.output//`. They never use your normal profile. @@ -291,6 +353,15 @@ The end-to-end tests start the real app with new, empty profiles in two instances at the same time, and that no service process remains. Output stays in `$TMPDIR/kc-packaged-*`. The test removes the app copy. +- `e2e/browser/library.e2e.mjs`: starts `yarn browser` with scratch + libraries and opens the launch link in headless Google Chrome. It + creates a project, uploads a PDF and a text file, shows both, annotates, + searches, restarts the service and the browser, checks that the records + remain, opens the managed files in new tabs, exports the library, and + restores it through the UI into a second library. It also checks + requests without a session, reads and writes from another local site, + and that the launcher does not print the bearer token. + Test files are in `e2e/fixtures/`. To delete old test output, delete `e2e/.output/`. diff --git a/docs/browser-library.md b/docs/browser-library.md new file mode 100644 index 00000000..dfaf4863 --- /dev/null +++ b/docs/browser-library.md @@ -0,0 +1,138 @@ +# Browser Library Workflow (Milestone 3) + +## Purpose + +Run one complete library workflow of the existing Angular interface in a +normal browser, without Electron. Keep the desktop app working. + +Workflow: create a project, upload files, show sources, annotate, search, +restart, open managed files, export and restore the library. + +Not in this milestone: website extraction in the browser, chat in the +browser, hosted deployment, accounts. + +## Startup dependencies found + +The Angular app could not start in a browser. These dependencies run at +startup, before any user action: + +| Dependency | Where | Problem in a browser | +| --- | --- | --- | +| Settings from Electron (`A2E:Settings:*`) | `SettingsService`, used by most services | `window.api` is undefined. No settings, no theme, no search options. | +| Field initializers `window.api.send` | `SettingsService`, `AutoscanService`, `ExtensionService`, `ElectronIpcService`, display settings | Throw when the service is created. | +| UUIDs from Electron (`A2E:Uuid:Generate`) | `UuidService` (projects, sources) | No IDs. | +| Server addresses and tokens (`A2E:Backend:Info`) | `BackendService` | No API address. | +| App version (`A2E:Version:Get`) | `StartupService` | Throws. | +| Window controls | `AppComponent` | Throws on click. | +| `kc_shared` imports an Angular model | `kc_shared/models/project.model.ts` | Shared code depends on the frontend. | + +Workflow dependencies: + +| Step | Desktop dependency | +| --- | --- | +| Select and import a file | `File.path`; copy by path through Electron (`A2E:Storage:ImportFile`) | +| File icon and thumbnail | `A2E:FileSystem:FileIcon`, `A2E:FileSystem:FileThumbnail` | +| Show a managed file | Fetch with bearer token, then `blob:` URL (filename lost) | +| Open a managed file | `A2E:Storage:OpenAsset` (default app) | +| Save a website | Renderer fetches other sites (needs Electron) | +| Chat | Electron chat server | + +## Responsibilities + +| Part | Owns | +| --- | --- | +| Frontend (`kc_angular`) | Navigation, presentation, user input and editing, temporary UI state, calls to the application API. Uses only the capability interfaces below. | +| Backend (`kc_storage`) | Projects, sources, managed files, validation, persistence, backup and restore, browser sessions, serving the browser UI files. Same API for all clients. | +| Electron (`kc_electron`) | Windows and menus, native file selection and file access by path, watched folders (autoscan), desktop capture, native shortcuts and drag-out, embedded browsing, thumbnails and file icons, local backend start and stop. | +| Contracts (`kc_contracts`, `kc_shared`) | Data types, record mapping, default settings. No Angular or Electron imports. | +| Chat and extraction | Stay in the Electron chat server for now. See "Next extraction steps". | + +## Capability interfaces (frontend) + +Small interfaces. Each has a desktop and a browser implementation. The +platform is chosen once at startup: desktop if the preload bridge exists. + +| Interface | Purpose | Desktop | Browser | +| --- | --- | --- | --- | +| `Platform` | Which optional features exist | All features | Library features only | +| `BackendConnection` | Address and request authorization for the library API (and chat) | IPC info, bearer token | Same origin, session cookie, CSRF header | +| `SettingsStore` | Load, save, and default settings | Electron settings file (IPC) | `localStorage`, shared defaults | +| `WindowControls` | Minimize, maximize, zoom | IPC | Not available (controls hidden) | +| `NativeFiles` | Original path of a selected file, copy a file by path, open in default app, show in folder | IPC | Not available | +| `ManagedFiles` | URL to show or open a managed file | `blob:` URL from an authorized fetch | Direct same-origin URL with the filename | + +Other changes: + +- IDs: `crypto.randomUUID()` on both platforms. No IPC. +- File import on both platforms: the user selects a `File`. The frontend + uploads its bytes with `POST /v1/assets` and stores the returned asset + ID in the source. The desktop also sends the original path as metadata. + Path-based copy stays in Electron for watched folders only. +- `ElectronIpcService` stays the desktop adapter for embedded browsing and + other desktop features. In a browser it is inert and not called. +- Desktop-only actions are hidden or disabled in the browser, with an + explanation: save website, chat, embedded browser, import and watched + folder settings, window controls, open in default app, show in folder, + drag-out, file thumbnails. + +## Browser session + +The service serves the built Angular files and the API from one origin, +`http://127.0.0.1:`. The frontend uses its own origin as the API +address. So the page always talks to the service instance that served it. + +| Question | Answer | +| --- | --- | +| How does the user open a session? | `yarn browser` starts the service with a random bearer token that it never prints. It asks the service for a launch code and prints `http://127.0.0.1:/#launch=`. 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 @@
-
+
+ DESKTOP ? new DesktopSettingsStore() : new BrowserSettingsStore(), + }, + { + provide: WindowControls, + useFactory: () => + DESKTOP ? new DesktopWindowControls() : new BrowserWindowControls(), + }, + { + provide: NativeFiles, + useFactory: () => + DESKTOP ? new DesktopNativeFiles() : new BrowserNativeFiles(), + }, + { + provide: ManagedFiles, + useClass: DESKTOP ? DesktopManagedFiles : BrowserManagedFiles, + }, ConfirmationService, DialogService, IngestService, diff --git a/src/kc_angular/src/app/components/home.component.ts b/src/kc_angular/src/app/components/home.component.ts index a895c25f..cbdcbb8b 100755 --- a/src/kc_angular/src/app/components/home.component.ts +++ b/src/kc_angular/src/app/components/home.component.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Platform } from '@app/platform/platform'; import { Component, HostListener, OnDestroy, ViewChild } from '@angular/core'; import { ConfirmationService, @@ -172,11 +173,18 @@ import { finalize, map, take, takeUntil, tap } from 'rxjs/operators'; [tipShowOnHover]="true" > + +
@@ -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); }); }