From 3b10cc6feb6365402cb6c92181851ce7ea276c60 Mon Sep 17 00:00:00 2001 From: Geert Theys Date: Fri, 4 Sep 2026 14:20:09 +0700 Subject: [PATCH 1/2] assets: browser downloads + loud 404s for file-like paths (#716) Server: - GET /api/assets/raw accepts ?download=1, serving the asset with an attachment Content-Disposition (RFC 5987 filename*) so plain browsers and links save the file under its own name. - The SPA fallback now returns a real 404 for unknown file-looking paths (anything with a non-.html extension, e.g. /files/assets/img.png) instead of index.html with HTTP 200, which made naive download attempts 'succeed' while yielding an HTML file. Unknown app routes still fall through to the SPA shell. Web + Desktop UI (shared app-core Assets view): - New 'Download' context-menu item and a keyboard 'd' on focused asset rows. The shared downloadAsset() helper resolves the asset URL exactly like embedded images do (same-origin HTTP on web, zen-asset:// scheme in the desktop app for local and remote vaults) and saves it via a blob + click, with no bridge-contract change. Tests: Go coverage for the download flag and the fallback 404; vitest coverage for the download helper (resolve failure, read failure, and anchor naming). --- .../httpserver/download_asset_test.go | 114 ++++++++++++++++++ apps/server/internal/httpserver/server.go | 18 ++- .../app-core/src/components/AssetsView.tsx | 14 +++ .../app-core/src/lib/download-asset.test.ts | 66 ++++++++++ packages/app-core/src/lib/download-asset.ts | 27 +++++ 5 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 apps/server/internal/httpserver/download_asset_test.go create mode 100644 packages/app-core/src/lib/download-asset.test.ts create mode 100644 packages/app-core/src/lib/download-asset.ts diff --git a/apps/server/internal/httpserver/download_asset_test.go b/apps/server/internal/httpserver/download_asset_test.go new file mode 100644 index 00000000..8dea8940 --- /dev/null +++ b/apps/server/internal/httpserver/download_asset_test.go @@ -0,0 +1,114 @@ +package httpserver + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "github.com/ZenNotes/zennotes/apps/server/internal/config" + "github.com/ZenNotes/zennotes/apps/server/internal/vault" +) + +// TestAssetDownloadDisposition covers the `?download=1` flag on +// /api/assets/raw (#716): the asset is served with an attachment +// Content-Disposition naming the original file, so plain browsers can save it. +func TestAssetDownloadDisposition(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "assets"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "assets", "pic.png"), []byte("PNGDATA"), 0o600); err != nil { + t.Fatal(err) + } + server, _ := newTestServer(t, config.Config{ + VaultPath: root, + DefaultVaultPath: root, + Bind: "127.0.0.1:7878", + AuthToken: "secret-token", + }) + jar := loginAndJar(t, server, "secret-token") + client := &http.Client{Jar: jar} + + resp, err := client.Get(server.URL + "/api/assets/raw?path=assets/pic.png&download=1") + if err != nil { + t.Fatalf("GET raw?download=1: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + disp := resp.Header.Get("Content-Disposition") + if disp != "attachment; filename*=UTF-8''pic.png" { + t.Fatalf("Content-Disposition = %q", disp) + } + if ct := resp.Header.Get("Content-Type"); ct != "image/png" { + t.Fatalf("Content-Type = %q, want image/png", ct) + } + + // Without the flag, the embed-serving behavior is unchanged: inline, no + // attachment header. + respInline, err := client.Get(server.URL + "/api/assets/raw?path=assets/pic.png") + if err != nil { + t.Fatalf("GET raw: %v", err) + } + defer respInline.Body.Close() + if respInline.StatusCode != http.StatusOK { + t.Fatalf("inline status = %d, want 200", respInline.StatusCode) + } + if disp := respInline.Header.Get("Content-Disposition"); strings.Contains(disp, "attachment") { + t.Fatalf("inline Content-Disposition = %q, want no attachment", disp) + } +} + +// TestStaticFallback404sAssetLikePaths pins the SPA-fallback guard (#716): +// unknown file-looking paths get a real 404 instead of index.html with HTTP +// 200, while unknown app routes still fall through to the SPA shell. +func TestStaticFallback404sAssetLikePaths(t *testing.T) { + static := fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte("ZenNotes"), + }, + } + root := t.TempDir() + cfg := config.Config{ + VaultPath: root, + DefaultVaultPath: root, + Bind: "127.0.0.1:7878", + AllowInsecureNoAuth: true, + } + v, err := vault.New(cfg.VaultPath, vault.Options{}) + if err != nil { + t.Fatalf("vault.New: %v", err) + } + server := httptest.NewServer(New(v, nil, fs.FS(static), cfg).Router()) + t.Cleanup(server.Close) + + for _, path := range []string{"/files/assets/image.png", "/assets/missing.png", "/notes/export.pdf"} { + resp, err := http.Get(server.URL + path) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("GET %s status = %d, want 404", path, resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") { + t.Fatalf("GET %s Content-Type = %q, want non-HTML", path, ct) + } + } + + // Unknown extension-less app routes still hit the SPA fallback. + resp, err := http.Get(server.URL + "/some/app/route") + if err != nil { + t.Fatalf("GET app route: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("app route status = %d, want 200", resp.StatusCode) + } +} diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index 0fc1d558..60bf3d3a 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -3,10 +3,12 @@ package httpserver import ( "encoding/json" "errors" + "fmt" "io/fs" "log" "mime" "net/http" + "net/url" "os" "path/filepath" "runtime" @@ -1026,6 +1028,13 @@ func (s *Server) rawAsset(w http.ResponseWriter, r *http.Request) { if t := mime.TypeByExtension(ext); t != "" { w.Header().Set("Content-Type", t) } + // `?download=1` asks the browser to save the asset instead of rendering + // it (#716). The web Assets view uses this for its Download action, and it + // lets any client turn a plain GET into a file download. + if r.URL.Query().Get("download") == "1" { + w.Header().Set("Content-Disposition", + fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(filepath.Base(abs)))) + } w.Header().Set("Cache-Control", "private, max-age=3600") http.ServeFile(w, r, abs) } @@ -1245,7 +1254,14 @@ func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) { } f, err := s.Static.Open(urlPath) if err != nil { - // SPA fallback: serve index.html for unknown paths. + // SPA fallback: serve index.html for unknown *app* paths. Paths that + // look like files (e.g. /files/assets/image.png) get a real 404, so a + // mistyped asset URL fails loudly instead of "downloading" an HTML + // file with HTTP 200. (#716) + if ext := strings.ToLower(filepath.Ext(urlPath)); ext != "" && ext != ".html" { + http.NotFound(w, r) + return + } s.serveIndexHTML(w) return } diff --git a/packages/app-core/src/components/AssetsView.tsx b/packages/app-core/src/components/AssetsView.tsx index 55e92d8b..e7c6a1c4 100644 --- a/packages/app-core/src/components/AssetsView.tsx +++ b/packages/app-core/src/components/AssetsView.tsx @@ -6,6 +6,7 @@ import { confirmMoveToTrash } from '../lib/confirm-trash' import { promptApp } from '../lib/prompt-requests' import { naturalCompare } from '../lib/natural-sort' import { resolveAssetVaultRelativePath } from '../lib/local-assets' +import { downloadAsset } from '../lib/download-asset' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { DocumentIcon, ImageIcon, PaperclipIcon, SearchIcon, TrashIcon } from './icons' @@ -166,6 +167,12 @@ export function AssetsView(): JSX.Element { void navigator.clipboard?.writeText(`![[${asset.name}]]`) } + const triggerDownload = (asset: AssetMeta): void => { + downloadAsset(vaultRoot, asset.path).catch((err: unknown) => { + window.alert(err instanceof Error ? err.message : String(err)) + }) + } + const renameAsset = async (asset: AssetMeta): Promise => { if (typeof window.zen.renameAsset !== 'function') return const ext = asset.name.includes('.') ? asset.name.slice(asset.name.lastIndexOf('.')) : '' @@ -190,6 +197,7 @@ export function AssetsView(): JSX.Element { const menuItems = (asset: AssetMeta): ContextMenuItem[] => [ { label: 'Open', onSelect: () => void openNoteInTab(assetTabPath(asset.path)) }, { label: 'Copy embed', onSelect: () => copyEmbed(asset) }, + { label: 'Download', onSelect: () => void triggerDownload(asset) }, { label: 'Reveal in file manager', onSelect: () => void window.zen.revealNote(asset.path) }, { label: 'Rename…', onSelect: () => void renameAsset(asset) }, { @@ -273,6 +281,12 @@ export function AssetsView(): JSX.Element { e.preventDefault() open() } + // Keyboard-first download (#716): `d` on a focused + // row saves the asset, matching the context menu. + if (e.key === 'd' && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault() + triggerDownload(asset) + } }} onContextMenu={(e) => { e.preventDefault() diff --git a/packages/app-core/src/lib/download-asset.test.ts b/packages/app-core/src/lib/download-asset.test.ts new file mode 100644 index 00000000..28d868d0 --- /dev/null +++ b/packages/app-core/src/lib/download-asset.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { downloadAsset } from './download-asset' + +// The helper talks to the DOM (`window.zen`, `document`, `URL.createObjectURL`) +// and to `fetch`. Vitest here runs in a node environment, so each test stubs +// exactly the surface the helper touches: resolve the embed URL, fetch it, +// and click a hidden anchor carrying the asset's basename. +describe('downloadAsset', () => { + afterEach(() => { + vi.unstubAllGlobals() + delete (URL as unknown as Record).createObjectURL + delete (URL as unknown as Record).revokeObjectURL + }) + + function stubDom(options: { + resolve?: string | null + fetchOk?: boolean + fetchedUrl?: (url: string) => void + }): { anchors: Array<{ download: string; click: ReturnType }> } { + const anchors: Array<{ download: string; click: ReturnType }> = [] + const resolve = 'resolve' in options ? options.resolve : 'https://vault.test/api/assets/raw?path=x' + vi.stubGlobal('window', { + zen: { resolveVaultAssetUrl: vi.fn(() => resolve) } + }) + vi.stubGlobal('document', { + body: { appendChild: vi.fn() }, + createElement: () => { + const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() } + anchors.push(anchor) + return anchor + } + }) + ;(URL as unknown as Record).createObjectURL = vi.fn(() => 'blob:mock') + ;(URL as unknown as Record).revokeObjectURL = vi.fn() + vi.stubGlobal( + 'fetch', + vi.fn((url: string | URL) => { + options.fetchedUrl?.(String(url)) + return Promise.resolve({ ok: options.fetchOk ?? true, blob: () => Promise.resolve(new Blob(['PNG'])) }) + }) + ) + return { anchors } + } + + it('fetches the resolved asset URL and clicks an anchor named after the asset', async () => { + let fetched = '' + const { anchors } = stubDom({ fetchedUrl: (url) => (fetched = url) }) + + await downloadAsset('/vault', 'assets/holiday pic.png') + + expect(fetched).toBe('https://vault.test/api/assets/raw?path=x') + expect(anchors).toHaveLength(1) + expect(anchors[0]?.download).toBe('holiday pic.png') + expect(anchors[0]?.click).toHaveBeenCalledOnce() + }) + + it('throws when the bridge cannot resolve the path', async () => { + stubDom({ resolve: null }) + await expect(downloadAsset('/vault', '../escape.png')).rejects.toThrow('Asset path is invalid.') + }) + + it('throws when the asset cannot be read', async () => { + stubDom({ fetchOk: false }) + await expect(downloadAsset('/vault', 'assets/missing.png')).rejects.toThrow('Asset could not be read.') + }) +}) diff --git a/packages/app-core/src/lib/download-asset.ts b/packages/app-core/src/lib/download-asset.ts new file mode 100644 index 00000000..cdc3af6b --- /dev/null +++ b/packages/app-core/src/lib/download-asset.ts @@ -0,0 +1,27 @@ +/** + * Trigger a browser download of a vault asset (#716). + * + * Resolves the asset's URL exactly the way embedded images do — + * same-origin HTTP on web, the `zen-asset://` privileged scheme in the + * desktop app (local or remote vault) — fetches it as a blob, and clicks a + * hidden `` anchor so the browser saves it under the asset's + * own name. Works in the renderer without any bridge-contract change. + */ +export async function downloadAsset( + vaultRoot: string | null, + assetPath: string +): Promise { + const url = window.zen.resolveVaultAssetUrl(vaultRoot ?? '', assetPath) + if (!url) throw new Error('Asset path is invalid.') + const response = await fetch(url) + if (!response.ok) throw new Error('Asset could not be read.') + const blob = await response.blob() + const objectUrl = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = objectUrl + anchor.download = assetPath.split('/').pop() ?? assetPath + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(objectUrl) +} From 630569470615c8d6deedd1ab81ff3e4343139a0d Mon Sep 17 00:00:00 2001 From: Geert Theys Date: Fri, 4 Sep 2026 15:15:43 +0700 Subject: [PATCH 2/2] assets: zn asset CLI, MCP get_asset, desktop save dialog (#716) CLI: - zn asset list [--json] and zn asset get [--output ] [--quiet], backed by a new readAsset() on the VaultBackend seam: local vaults read under the root (vault-ops readAsset, path-escape guarded), remote vaults stream GET /api/assets/raw with Bearer auth. Without --output the bytes go to stdout, so piping works. MCP: - New get_asset tool next to list_assets: path in, { path, size, mimeType, base64 } out, with a 10 MB cap that points agents at zn asset get for bigger files. Desktop: - Bridge contract gains optional downloadAsset(relPath): IPC handler shows the native save dialog (suggested name = basename), then copies the file for local vaults or streams the server's raw-asset response for remote/self-hosted vaults. Preload passthrough included; the shared Assets-view Download action prefers it and keeps the web blob path as fallback. Tests: vitest for the CLI commands, the MCP tool, and the shared helper's desktop-vs-web branching. Verified live against a running server: local list/get, remote list/get/output/401. --- apps/desktop/src/cli/backend.ts | 5 ++ apps/desktop/src/cli/commands/assets.test.ts | 84 +++++++++++++++++++ apps/desktop/src/cli/commands/assets.ts | 70 ++++++++++++++++ apps/desktop/src/cli/help.ts | 7 ++ apps/desktop/src/cli/index.ts | 4 + apps/desktop/src/cli/remote/client.ts | 33 +++++++- apps/desktop/src/main/index.ts | 36 ++++++++ apps/desktop/src/mcp/get-asset.test.ts | 62 ++++++++++++++ apps/desktop/src/mcp/server.test.ts | 1 + apps/desktop/src/mcp/server.ts | 66 +++++++++++++++ apps/desktop/src/mcp/vault-ops.ts | 8 ++ apps/desktop/src/preload/index.ts | 2 + .../app-core/src/lib/download-asset.test.ts | 29 +++++-- packages/app-core/src/lib/download-asset.ts | 22 +++-- packages/bridge-contract/src/bridge.ts | 5 ++ packages/bridge-contract/src/ipc.ts | 1 + 16 files changed, 418 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/cli/commands/assets.test.ts create mode 100644 apps/desktop/src/cli/commands/assets.ts create mode 100644 apps/desktop/src/mcp/get-asset.test.ts diff --git a/apps/desktop/src/cli/backend.ts b/apps/desktop/src/cli/backend.ts index bf45889c..f2fe6e13 100644 --- a/apps/desktop/src/cli/backend.ts +++ b/apps/desktop/src/cli/backend.ts @@ -41,6 +41,7 @@ import { readDatabaseVaultLayout, readNote, readPrimaryNotesLocation, + readAsset, readVaultFileTextOrNull, renameFolder, renameNote, @@ -114,6 +115,8 @@ export interface VaultBackend { describe(): Promise listNotes(): Promise listAssets(): Promise + /** An asset's raw bytes (#716). */ + readAsset(rel: string): Promise listFolders(): Promise<{ folder: NoteFolder; subpath: string }[]> readNote(rel: string): Promise writeNote(rel: string, body: string): Promise @@ -222,6 +225,7 @@ class LocalBackend implements VaultBackend { }) listNotes = (): Promise => listNotes(this.root) listAssets = (): Promise => listAssets(this.root) + readAsset = (rel: string): Promise => readAsset(this.root, rel) listFolders = (): Promise<{ folder: NoteFolder; subpath: string }[]> => listFolders(this.root) readNote = (rel: string): Promise => readNote(this.root, rel) writeNote = (rel: string, body: string): Promise => writeNote(this.root, rel, body) @@ -328,6 +332,7 @@ class RemoteBackend implements VaultBackend { size: asset.size, updatedAt: asset.updatedAt })) + readAsset = (rel: string): Promise => this.client.readAsset(rel) listFolders = (): Promise<{ folder: NoteFolder; subpath: string }[]> => this.client.listFolders() readNote = (rel: string): Promise => this.client.readNote(rel) diff --git a/apps/desktop/src/cli/commands/assets.test.ts b/apps/desktop/src/cli/commands/assets.test.ts new file mode 100644 index 00000000..04360794 --- /dev/null +++ b/apps/desktop/src/cli/commands/assets.test.ts @@ -0,0 +1,84 @@ +import { promises as fsp } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createBackend } from '../backend' +import type { ParsedArgs } from '../args' +import { cmdAssetGet, cmdAssetList } from './assets' + +function makeArgs(positionals: string[], flags: Array<[string, string]> = []): ParsedArgs { + const map = new Map() + for (const [k, v] of flags) map.set(k, [...(map.get(k) ?? []), v]) + return { positionals, flags: map } +} + +let tmpDir: string +let root: string +let out: string[] +let binChunks: Uint8Array[] + +beforeAll(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-assets-cli-')) + root = path.join(tmpDir, 'vault') + await fsp.mkdir(path.join(root, 'assets'), { recursive: true }) + await fsp.writeFile(path.join(root, 'assets', 'pic.png'), 'PNGDATA') + await fsp.writeFile(path.join(root, 'assets', 'doc.pdf'), '%PDF-1.4') +}) + +afterAll(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }) +}) + +beforeEach(() => { + out = [] + binChunks = [] + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + if (typeof chunk === 'string') out.push(chunk) + else binChunks.push(chunk) + return true + }) +}) + +const backend = (): ReturnType => createBackend({ kind: 'local', root }) + +describe('zn asset list', () => { + it('lists assets with size and path', async () => { + await cmdAssetList(backend(), makeArgs([])) + const text = out.join('') + expect(text).toContain('assets/pic.png') + expect(text).toContain('assets/doc.pdf') + }) + + it('emits JSON with --json', async () => { + await cmdAssetList(backend(), makeArgs([], [['json', 'true']])) + const rows = JSON.parse(out.join('')) as Array<{ path: string; size: number }> + expect(rows.map((r) => r.path)).toContain('assets/pic.png') + expect(rows.find((r) => r.path === 'assets/pic.png')?.size).toBe(7) + }) +}) + +describe('zn asset get', () => { + it('writes the raw bytes to stdout', async () => { + await cmdAssetGet(backend(), makeArgs(['assets/pic.png'])) + // The mocked write records Uint8Array chunks untouched, so decode the + // first binary chunk before comparing. + expect(new TextDecoder().decode(binChunks[0])).toBe('PNGDATA') + }) + + it('saves to --output and reports the byte count', async () => { + const dest = path.join(tmpDir, 'out', 'copy.png') + await cmdAssetGet(backend(), makeArgs(['assets/pic.png'], [['output', dest]])) + expect(await fsp.readFile(dest, 'utf8')).toBe('PNGDATA') + expect(out.join('')).toContain(`Wrote 7 bytes to ${dest}`) + }) + + it('rejects paths that escape the vault', async () => { + await expect( + cmdAssetGet(backend(), makeArgs(['../../../etc/passwd'])) + ).rejects.toThrow(/escapes vault/) + }) + + it('rejects missing assets', async () => { + await expect(cmdAssetGet(backend(), makeArgs(['assets/nope.png']))).rejects.toThrow() + }) +}) diff --git a/apps/desktop/src/cli/commands/assets.ts b/apps/desktop/src/cli/commands/assets.ts new file mode 100644 index 00000000..52683e57 --- /dev/null +++ b/apps/desktop/src/cli/commands/assets.ts @@ -0,0 +1,70 @@ +/** + * Asset commands (#716): list and fetch the binary files embedded in notes. + * Works against a local folder or a self-hosted server, like every `zn` + * command — the VaultBackend seam picks the wire. + * + * `zn asset get` writes binary to stdout when no --output is given, so + * `zn asset get assets/pic.png > x.png` and piping into other tools work. + */ + +import { promises as fsp } from 'node:fs' +import path from 'node:path' +import type { VaultBackend } from '../backend.js' +import { getBool, getString, type ParsedArgs } from '../args.js' +import { emitJson, emitLine, pad, truncate } from '../format.js' +import { formatRelativeAge } from '../format.js' + +export async function cmdAssetList(vault: VaultBackend, args: ParsedArgs): Promise { + const assets = await vault.listAssets() + if (getBool(args, 'json')) { + emitJson(assets) + return + } + if (assets.length === 0) { + emitLine('No assets in this vault.') + return + } + emitLine(`${pad('PATH', 48)} ${pad('SIZE', 10)} MODIFIED`) + for (const a of assets) { + emitLine( + `${pad(truncate(a.path, 47), 48)} ${pad(formatBytes(a.size), 10)} ${formatRelativeAge(a.updatedAt)}` + ) + } +} + +export async function cmdAssetGet(vault: VaultBackend, args: ParsedArgs): Promise { + const rel = getString(args, 'path') ?? args.positionals[0] + if (!rel) throw new Error('zn asset get requires an asset path (see `zn asset list`).') + const output = getString(args, 'output') + const bytes = await vault.readAsset(rel) + + if (output && output !== '-') { + await fsp.mkdir(path.dirname(path.resolve(output)), { recursive: true }) + await fsp.writeFile(output, bytes) + if (!getBool(args, 'quiet')) { + emitLine(`Wrote ${bytes.length} bytes to ${output}.`) + } + return + } + + // Binary to stdout. stdin/stdout are the streams `main()` returns through, + // so drain the write to keep the process from exiting early on Windows. + await new Promise((resolve, reject) => { + const flushed = process.stdout.write(bytes, (err) => { + if (err) reject(err) + }) + if (flushed) resolve() + }) +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const units = ['KB', 'MB', 'GB'] + let value = bytes / 1024 + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit++ + } + return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)}${units[unit]}` +} diff --git a/apps/desktop/src/cli/help.ts b/apps/desktop/src/cli/help.ts index f342354a..83554223 100644 --- a/apps/desktop/src/cli/help.ts +++ b/apps/desktop/src/cli/help.ts @@ -124,6 +124,13 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [ { name: 'folder delete

', description: 'Delete a subfolder and everything in it', flags: '--yes' } ] }, + { + heading: 'ASSETS', + rows: [ + { name: 'asset list', description: 'List images and attachments in the vault', flags: '--json' }, + { name: 'asset get ', description: 'Fetch an asset: binary to stdout, or saved via --output', flags: '--output --quiet' } + ] + }, { heading: 'TAGS', rows: [ diff --git a/apps/desktop/src/cli/index.ts b/apps/desktop/src/cli/index.ts index 79e37e26..7ef985d9 100644 --- a/apps/desktop/src/cli/index.ts +++ b/apps/desktop/src/cli/index.ts @@ -38,6 +38,7 @@ import { cmdWrite } from './commands/notes.js' import { cmdBacklinks, cmdSearch, cmdSearchTitle } from './commands/search.js' +import { cmdAssetGet, cmdAssetList } from './commands/assets.js' import { cmdFolderCreate, cmdFolderDelete, @@ -128,6 +129,8 @@ async function main(argv: string[]): Promise { 'folder create': cmdFolderCreate, 'folder rename': cmdFolderRename, 'folder delete': cmdFolderDelete, + 'asset list': cmdAssetList, + 'asset get': cmdAssetGet, 'tag list': cmdTagList, 'tag find': cmdTagFind, 'task list': cmdTaskList, @@ -161,6 +164,7 @@ function peelSubcommand( ): { subcommand: string | null; parsed: ParsedArgs } { const SUBCOMMANDS: Record = { folder: ['list', 'create', 'rename', 'delete'], + asset: ['list', 'get'], tag: ['list', 'find'], task: ['list', 'toggle'], vault: ['info', 'list'], diff --git a/apps/desktop/src/cli/remote/client.ts b/apps/desktop/src/cli/remote/client.ts index 4a3a165f..af415bf7 100644 --- a/apps/desktop/src/cli/remote/client.ts +++ b/apps/desktop/src/cli/remote/client.ts @@ -12,7 +12,12 @@ * commands keep printing exactly the fields they print for a local vault. */ -import { remoteJsonRequest } from '../../main/remote/connection.js' +import { + RemoteRequestError, + connectionErrorMessage, + remoteJsonRequest, + requestErrorMessage +} from '../../main/remote/connection.js' import type { NoteContent, NoteFolder, @@ -97,6 +102,32 @@ export class CliRemoteClient { return this.get('/api/assets') } + /** An asset's raw bytes (#716). Binary on purpose — no JSON envelope — + * so `zn asset get` can stream exactly what the server serves. */ + async readAsset(relPath: string): Promise { + const headers = new Headers() + if (this.authToken) { + headers.set('Authorization', `Bearer ${this.authToken}`) + } + let response: Response + try { + response = await fetch( + `${this.baseUrl}/api/assets/raw?path=${encodeURIComponent(relPath)}`, + { headers } + ) + } catch (error) { + throw new Error(connectionErrorMessage(this.baseUrl, error)) + } + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new RemoteRequestError( + requestErrorMessage(this.baseUrl, `/api/assets/raw?path=${relPath}`, response, text), + response.status + ) + } + return new Uint8Array(await response.arrayBuffer()) + } + scanTasksForPath( relPath: string, opts?: { includeExcluded?: boolean } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 9db6ac09..bd1da50d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3228,6 +3228,42 @@ function registerIpc(): void { return (await fsp.readFile(absolutePath(v.root, rel))).toString("base64"); }); + // Save an asset out of the vault (#716). Local vaults copy the file; + // remote/self-hosted vaults stream the server's raw-asset response so the + // bytes never need to land in the renderer first. + handle(IPC.VAULT_DOWNLOAD_ASSET, async (e, assetPath: string) => { + const rel = String(assetPath ?? "").trim(); + if (!rel) throw new Error("Asset path is required."); + const suggestedName = path.basename(rel); + + const parentWindow = BrowserWindow.fromWebContents(e.sender); + const saveDialogOptions = { + title: "Save Asset", + defaultPath: path.join(app.getPath("documents"), suggestedName), + buttonLabel: "Save", + }; + const result = parentWindow + ? await dialog.showSaveDialog(parentWindow, saveDialogOptions) + : await dialog.showSaveDialog(saveDialogOptions); + if (result.canceled || !result.filePath) return; + + if (isRemoteWorkspaceActive()) { + const response = await requireRemoteWorkspaceClient().fetchAssetResponse(rel); + if (!response.body) { + throw new Error("Remote asset response had no body."); + } + const { createWriteStream } = await import("node:fs"); + const { Readable } = await import("node:stream"); + const { pipeline } = await import("node:stream/promises"); + const nodeStream = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream); + await pipeline(nodeStream, createWriteStream(result.filePath)); + return; + } + + const v = requireVault(); + await fsp.copyFile(absolutePath(v.root, rel), result.filePath); + }); + handle(IPC.VAULT_HAS_ASSETS_DIR, async () => { if (isRemoteWorkspaceActive()) return await requireRemoteWorkspaceClient().hasAssetsDir(); diff --git a/apps/desktop/src/mcp/get-asset.test.ts b/apps/desktop/src/mcp/get-asset.test.ts new file mode 100644 index 00000000..f50ce06f --- /dev/null +++ b/apps/desktop/src/mcp/get-asset.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import type { VaultBackend } from '../cli/backend' +import { callTool, listToolNames } from './server' + +// Only the members a given test reaches are implemented; the cast keeps the +// stubs honest about being partial. +function backend(partial: Partial): VaultBackend { + return partial as VaultBackend +} + +describe('get_asset (#716)', () => { + it('is registered next to list_assets', () => { + const names = listToolNames() + expect(names).toContain('list_assets') + expect(names).toContain('get_asset') + }) + + it('returns base64, size, and a guessed MIME type', async () => { + const bytes = new TextEncoder().encode('PNGDATA') + const result = (await callTool( + 'get_asset', + { path: 'assets/pic.png' }, + backend({ readAsset: async () => bytes }) + )) as Record + expect(result).toEqual({ + path: 'assets/pic.png', + size: 7, + mimeType: 'image/png', + base64: Buffer.from(bytes).toString('base64') + }) + }) + + it('falls back to application/octet-stream for unknown extensions', async () => { + const result = (await callTool( + 'get_asset', + { path: 'assets/blob.bin' }, + backend({ readAsset: async () => new Uint8Array([1]) }) + )) as Record + expect(result.mimeType).toBe('application/octet-stream') + }) + + it('rejects assets over the 10 MB tool limit and points at the CLI', async () => { + const big = new Uint8Array(10 * 1024 * 1024 + 1) + await expect( + callTool( + 'get_asset', + { path: 'assets/huge.mp4' }, + backend({ readAsset: async () => big }) + ) + ).rejects.toThrow(/zn asset get/) + }) + + it('surfaces backend errors (missing asset, escaping path)', async () => { + await expect( + callTool( + 'get_asset', + { path: 'assets/nope.png' }, + backend({ readAsset: async () => { throw new Error('Asset not found: assets/nope.png') } }) + ) + ).rejects.toThrow(/not found/i) + }) +}) diff --git a/apps/desktop/src/mcp/server.test.ts b/apps/desktop/src/mcp/server.test.ts index 9fcfdf6b..e7d58578 100644 --- a/apps/desktop/src/mcp/server.test.ts +++ b/apps/desktop/src/mcp/server.test.ts @@ -91,6 +91,7 @@ describe('tools run through the backend', () => { 'list_notes', 'list_folders', 'list_assets', + 'get_asset', 'read_note', 'write_note', 'create_note', diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index c7caef8e..56b786ae 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -28,6 +28,39 @@ interface ToolDef { handler: (args: Record, backend: VaultBackend) => Promise } +/* ---------- Asset helpers (#716) -------------------------------------- */ + +/** A base64 payload is ~1.37x the raw bytes and rides inside one JSON-RPC + * message; past ~10 MB the round trip stops being useful for a model. */ +const MAX_MCP_ASSET_BYTES = 10 * 1024 * 1024 + +const ASSET_MIME_BY_EXT: Record = { + '.apng': 'image/apng', + '.avif': 'image/avif', + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.pdf': 'application/pdf', + '.aac': 'audio/aac', + '.flac': 'audio/flac', + '.m4a': 'audio/mp4', + '.mp3': 'audio/mpeg', + '.ogg': 'audio/ogg', + '.wav': 'audio/wav', + '.m4v': 'video/mp4', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.webm': 'video/webm' +} + +function assetMimeType(rel: string): string { + const ext = rel.slice(rel.lastIndexOf('.')).toLowerCase() + return ASSET_MIME_BY_EXT[ext] ?? 'application/octet-stream' +} + /* ---------- Argument helpers ----------------------------------------- */ function requireString(args: Record, key: string): string { @@ -203,6 +236,39 @@ const TOOLS: ToolDef[] = [ }, handler: async (_args, backend) => await backend.listAssets() }, + { + schema: { + name: 'get_asset', + description: + 'Read one asset (image, PDF, audio, video, other binary) as base64, with its MIME type and byte size. Use the path verbatim from list_assets or from a note’s `![[…]]` embed. Assets larger than 10 MB are rejected — fetch those with `zn asset get ` instead.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Vault-relative POSIX path exactly as another tool returned it (e.g. "assets/screenshot.png"). Pass it through unchanged.' + } + }, + required: ['path'] + } + }, + handler: async (args, backend) => { + const rel = requireString(args, 'path') + const bytes = await backend.readAsset(rel) + if (bytes.byteLength > MAX_MCP_ASSET_BYTES) { + throw new Error( + `Asset is ${bytes.byteLength} bytes, over the ${MAX_MCP_ASSET_BYTES} tool limit. Use \`zn asset get ${rel}\` (CLI) for large files.` + ) + } + return { + path: rel, + size: bytes.byteLength, + mimeType: assetMimeType(rel), + base64: Buffer.from(bytes).toString('base64') + } + } + }, { schema: { name: 'read_note', diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 2942d5e2..72d21400 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -898,6 +898,14 @@ export async function listAssets(root: string): Promise< /* ---------- Read / write / create ------------------------------------ */ +/** Read an asset's raw bytes. The remote half (server raw-asset fetch) lives + * in the CLI's remote client; both halves back `zn asset get` and the MCP + * `get_asset` tool. (#716) */ +export async function readAsset(root: string, rel: string): Promise { + const abs = resolveSafe(root, rel) + return new Uint8Array(await fs.readFile(abs)) +} + export async function readNote(root: string, rel: string): Promise { const abs = resolveSafe(root, rel) const folder = await folderOf(root, abs) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index dfb7a65a..4c669205 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -501,6 +501,8 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.VAULT_IMPORT_PASTED_IMAGE, input), readVaultAssetBase64: (assetPath: string): Promise => ipcRenderer.invoke(IPC.VAULT_READ_ASSET_BASE64, assetPath), + downloadAsset: (relPath: string): Promise => + ipcRenderer.invoke(IPC.VAULT_DOWNLOAD_ASSET, relPath), renameAsset: (relPath: string, nextName: string): Promise => ipcRenderer.invoke(IPC.VAULT_RENAME_ASSET, relPath, nextName), moveAsset: (relPath: string, targetDir: string): Promise => diff --git a/packages/app-core/src/lib/download-asset.test.ts b/packages/app-core/src/lib/download-asset.test.ts index 28d868d0..75a1134f 100644 --- a/packages/app-core/src/lib/download-asset.test.ts +++ b/packages/app-core/src/lib/download-asset.test.ts @@ -3,8 +3,9 @@ import { downloadAsset } from './download-asset' // The helper talks to the DOM (`window.zen`, `document`, `URL.createObjectURL`) // and to `fetch`. Vitest here runs in a node environment, so each test stubs -// exactly the surface the helper touches: resolve the embed URL, fetch it, -// and click a hidden anchor carrying the asset's basename. +// exactly the surface the helper touches: on desktop, the native +// `window.zen.downloadAsset` bridge call; on web, the resolve→fetch→anchor +// click path. describe('downloadAsset', () => { afterEach(() => { vi.unstubAllGlobals() @@ -12,7 +13,7 @@ describe('downloadAsset', () => { delete (URL as unknown as Record).revokeObjectURL }) - function stubDom(options: { + function stubWebDom(options: { resolve?: string | null fetchOk?: boolean fetchedUrl?: (url: string) => void @@ -42,9 +43,19 @@ describe('downloadAsset', () => { return { anchors } } - it('fetches the resolved asset URL and clicks an anchor named after the asset', async () => { + it('uses the desktop bridge save-dialog path when available', async () => { + const downloadAssetBridge = vi.fn(async () => {}) + vi.stubGlobal('window', { zen: { downloadAsset: downloadAssetBridge } }) + + await downloadAsset('/vault', 'assets/holiday pic.png') + + expect(downloadAssetBridge).toHaveBeenCalledOnce() + expect(downloadAssetBridge).toHaveBeenCalledWith('assets/holiday pic.png') + }) + + it('web: fetches the resolved asset URL and clicks an anchor named after the asset', async () => { let fetched = '' - const { anchors } = stubDom({ fetchedUrl: (url) => (fetched = url) }) + const { anchors } = stubWebDom({ fetchedUrl: (url) => (fetched = url) }) await downloadAsset('/vault', 'assets/holiday pic.png') @@ -54,13 +65,13 @@ describe('downloadAsset', () => { expect(anchors[0]?.click).toHaveBeenCalledOnce() }) - it('throws when the bridge cannot resolve the path', async () => { - stubDom({ resolve: null }) + it('web: throws when the bridge cannot resolve the path', async () => { + stubWebDom({ resolve: null }) await expect(downloadAsset('/vault', '../escape.png')).rejects.toThrow('Asset path is invalid.') }) - it('throws when the asset cannot be read', async () => { - stubDom({ fetchOk: false }) + it('web: throws when the asset cannot be read', async () => { + stubWebDom({ fetchOk: false }) await expect(downloadAsset('/vault', 'assets/missing.png')).rejects.toThrow('Asset could not be read.') }) }) diff --git a/packages/app-core/src/lib/download-asset.ts b/packages/app-core/src/lib/download-asset.ts index cdc3af6b..30c9e618 100644 --- a/packages/app-core/src/lib/download-asset.ts +++ b/packages/app-core/src/lib/download-asset.ts @@ -1,16 +1,24 @@ /** - * Trigger a browser download of a vault asset (#716). + * Trigger a download of a vault asset (#716). * - * Resolves the asset's URL exactly the way embedded images do — - * same-origin HTTP on web, the `zen-asset://` privileged scheme in the - * desktop app (local or remote vault) — fetches it as a blob, and clicks a - * hidden `` anchor so the browser saves it under the asset's - * own name. Works in the renderer without any bridge-contract change. + * Desktop: `window.zen.downloadAsset` opens the native save dialog and the + * main process copies the file (local vault) or streams the server's + * raw-asset response (remote/self-hosted vault). + * + * Web: resolves the asset's URL exactly the way embedded images do — + * same-origin HTTP, cookie-authenticated — fetches it as a blob, and clicks + * a hidden `` anchor so the browser saves it under the asset's + * own name. */ export async function downloadAsset( vaultRoot: string | null, assetPath: string ): Promise { + const name = assetPath.split('/').pop() ?? assetPath + if (typeof window.zen.downloadAsset === 'function') { + await window.zen.downloadAsset(assetPath) + return + } const url = window.zen.resolveVaultAssetUrl(vaultRoot ?? '', assetPath) if (!url) throw new Error('Asset path is invalid.') const response = await fetch(url) @@ -19,7 +27,7 @@ export async function downloadAsset( const objectUrl = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = objectUrl - anchor.download = assetPath.split('/').pop() ?? assetPath + anchor.download = name document.body.appendChild(anchor) anchor.click() anchor.remove() diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index 2417f065..df6d738e 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -323,6 +323,11 @@ export interface ZenBridge { importPastedImage(input: PastedImageInput): Promise /** Read an asset from the active vault without exposing a host filesystem path. */ readVaultAssetBase64(assetPath: string): Promise + /** + * Save an asset to a user-picked location via the native save dialog. + * Desktop only — web clients use the blob download path instead (#716). + */ + downloadAsset?(relPath: string): Promise renameAsset(relPath: string, nextName: string): Promise moveAsset(relPath: string, targetDir: string): Promise duplicateAsset(relPath: string): Promise diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index e7479eb8..5c6c9d42 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -66,6 +66,7 @@ export const IPC = { VAULT_IMPORT_FILES: 'vault:import-files', VAULT_IMPORT_PASTED_IMAGE: 'vault:import-pasted-image', VAULT_READ_ASSET_BASE64: 'vault:read-asset-base64', + VAULT_DOWNLOAD_ASSET: 'vault:download-asset', VAULT_RENAME_ASSET: 'vault:rename-asset', VAULT_MOVE_ASSET: 'vault:move-asset', VAULT_DUPLICATE_ASSET: 'vault:duplicate-asset',