pmx-canvas 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/dist/types/server/image-source.d.ts +3 -0
- package/package.json +1 -1
- package/src/cli/agent.ts +40 -5
- package/src/server/canvas-operations.ts +3 -11
- package/src/server/image-source.ts +206 -0
- package/src/server/server.ts +11 -5
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,62 @@
|
|
|
3
3
|
All notable changes to `pmx-canvas` are documented here. This project follows
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.1.5] - 2026-04-26
|
|
7
|
+
|
|
8
|
+
Image-validation hardening + CLI ergonomics. The boundary where untrusted
|
|
9
|
+
file paths become canvas state now validates magic bytes, and common CLI
|
|
10
|
+
typos produce one-line suggestions instead of help-block dumps.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Magic-byte validation for local image nodes.** PNG / JPEG / GIF / SVG /
|
|
15
|
+
WebP / BMP / ICO / AVIF headers are sniffed before a file becomes an
|
|
16
|
+
`image` node. A file renamed `screenshot.png` containing PowerPoint XML
|
|
17
|
+
is now rejected with a clear error before it reaches the renderer.
|
|
18
|
+
- **macOS cloud-on-demand placeholder detection.** Files in iCloud Drive,
|
|
19
|
+
OneDrive, etc. that are not yet downloaded locally are detected via
|
|
20
|
+
`stat -f %Xf` flags and rejected with a hint to download them first —
|
|
21
|
+
no more silent freezes when an iCloud-only file is dropped on the canvas.
|
|
22
|
+
- **`/bin/dd` escape hatch with a 5s timeout** for macOS-only paths where
|
|
23
|
+
the direct fs read could hang on an unresponsive volume (e.g. an
|
|
24
|
+
unmounted SMB share that still satisfies `existsSync`). Distinguishes
|
|
25
|
+
timeout (`SIGTERM` / `ETIMEDOUT`) from generic spawn failures so the
|
|
26
|
+
cloud-storage hint isn't shown for unrelated errors.
|
|
27
|
+
- **CLI typo hints for resource subcommands.**
|
|
28
|
+
- `pmx-canvas node delete <id>` and `pmx-canvas node rm <id>` exit 1 with
|
|
29
|
+
`Did you mean: pmx-canvas node remove?`.
|
|
30
|
+
- `pmx-canvas edge delete <id>` and `pmx-canvas edge rm <id>` get the
|
|
31
|
+
same treatment.
|
|
32
|
+
- `pmx-canvas node pin <id>` redirects to the top-level
|
|
33
|
+
`pmx-canvas pin <id>` command.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **`GET /api/canvas/image/:id` is now async** (`fs/promises.readFile`) and
|
|
38
|
+
validates content before serving — returns **400** on invalid image bytes
|
|
39
|
+
instead of 200 with `application/octet-stream`.
|
|
40
|
+
- **Bare `pmx-canvas node` (no subcommand)** now exits 1 with structured
|
|
41
|
+
JSON instead of printing the resource help block. Use
|
|
42
|
+
`pmx-canvas node --help` for the listing.
|
|
43
|
+
|
|
44
|
+
### Internal
|
|
45
|
+
|
|
46
|
+
- New module `src/server/image-source.ts` extracts and extends image
|
|
47
|
+
validation from `canvas-operations.ts`. Same error contract; richer
|
|
48
|
+
checks. The MCP and HTTP layers both flow through `addCanvasNode`, so
|
|
49
|
+
CLAUDE.md rule #5 (four-layer parity) is preserved without touching
|
|
50
|
+
the SDK or MCP server.
|
|
51
|
+
- Direct fs read is the fast path on every platform (no fork, no shell);
|
|
52
|
+
`dd` is only consulted on macOS as a fallback when direct read fails on
|
|
53
|
+
a path that wasn't flagged as a placeholder.
|
|
54
|
+
- Real magic-byte fixtures in `tests/unit/canvas-operations.test.ts` (was:
|
|
55
|
+
`*.png` extension smoke tests). New HTTP coverage in
|
|
56
|
+
`tests/unit/server-api.test.ts` for valid / invalid / missing image
|
|
57
|
+
paths. New CLI coverage for `node delete`, `node pin`, `edge delete`,
|
|
58
|
+
`edge rm` typo hints.
|
|
59
|
+
|
|
60
|
+
[0.1.5]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.5
|
|
61
|
+
|
|
6
62
|
## [0.1.4] - 2026-04-26
|
|
7
63
|
|
|
8
64
|
Graph/CLI ergonomics + canvas-node taxonomy hardening. Three threads:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server/index.ts",
|
package/src/cli/agent.ts
CHANGED
|
@@ -873,6 +873,21 @@ function filterJsonRenderSchemaView(
|
|
|
873
873
|
// ── Commands ─────────────────────────────────────────────────
|
|
874
874
|
|
|
875
875
|
const COMMANDS: Record<string, { run: (args: string[]) => Promise<void>; help: string; examples: string[] }> = {};
|
|
876
|
+
const RESOURCE_COMMAND_ALIASES: Record<string, Record<string, string>> = {
|
|
877
|
+
node: {
|
|
878
|
+
delete: 'remove',
|
|
879
|
+
rm: 'remove',
|
|
880
|
+
},
|
|
881
|
+
edge: {
|
|
882
|
+
delete: 'remove',
|
|
883
|
+
rm: 'remove',
|
|
884
|
+
},
|
|
885
|
+
};
|
|
886
|
+
const RESOURCE_SUBCOMMAND_HINTS: Record<string, Record<string, string>> = {
|
|
887
|
+
node: {
|
|
888
|
+
pin: 'Use the top-level pin command instead: pmx-canvas pin <node-id>',
|
|
889
|
+
},
|
|
890
|
+
};
|
|
876
891
|
|
|
877
892
|
function cmd(
|
|
878
893
|
name: string,
|
|
@@ -2225,12 +2240,32 @@ export async function runAgentCli(args: string[]): Promise<void> {
|
|
|
2225
2240
|
// Unknown command — show help for the resource if it exists
|
|
2226
2241
|
const resourceCommands = Object.keys(COMMANDS).filter((k) => k.startsWith(oneWord + ' '));
|
|
2227
2242
|
if (resourceCommands.length > 0) {
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2243
|
+
if (args[1] === '--help' || args[1] === '-h') {
|
|
2244
|
+
console.log(`\nAvailable "${oneWord}" commands:\n`);
|
|
2245
|
+
for (const k of resourceCommands) {
|
|
2246
|
+
console.log(` pmx-canvas ${k.padEnd(20)} ${COMMANDS[k].help}`);
|
|
2247
|
+
}
|
|
2248
|
+
console.log('\nRun any command with --help for details.\n');
|
|
2249
|
+
return;
|
|
2231
2250
|
}
|
|
2232
|
-
|
|
2233
|
-
|
|
2251
|
+
const subcommand = args[1];
|
|
2252
|
+
const suggestion = subcommand ? RESOURCE_COMMAND_ALIASES[oneWord]?.[subcommand] : undefined;
|
|
2253
|
+
const extraHint = subcommand ? RESOURCE_SUBCOMMAND_HINTS[oneWord]?.[subcommand] : undefined;
|
|
2254
|
+
const available = resourceCommands
|
|
2255
|
+
.map((k) => k.slice(oneWord.length + 1))
|
|
2256
|
+
.sort()
|
|
2257
|
+
.join(', ');
|
|
2258
|
+
const hints = [
|
|
2259
|
+
suggestion ? `Did you mean: pmx-canvas ${oneWord} ${suggestion}?` : undefined,
|
|
2260
|
+
extraHint,
|
|
2261
|
+
`Available subcommands: ${available}`,
|
|
2262
|
+
].filter((hint): hint is string => typeof hint === 'string');
|
|
2263
|
+
die(
|
|
2264
|
+
subcommand
|
|
2265
|
+
? `Unknown ${oneWord} subcommand: "${subcommand}".`
|
|
2266
|
+
: `Missing ${oneWord} subcommand.`,
|
|
2267
|
+
hints.join(' '),
|
|
2268
|
+
);
|
|
2234
2269
|
}
|
|
2235
2270
|
|
|
2236
2271
|
die(`Unknown command: ${oneWord}`, 'Run: pmx-canvas --help');
|
|
@@ -4,7 +4,6 @@ import { recomputeCodeGraph } from './code-graph.js';
|
|
|
4
4
|
import {
|
|
5
5
|
canvasState,
|
|
6
6
|
type CanvasEdge,
|
|
7
|
-
IMAGE_MIME_MAP,
|
|
8
7
|
type CanvasNodeState,
|
|
9
8
|
type CanvasNodeUpdate,
|
|
10
9
|
type CanvasSnapshot,
|
|
@@ -36,6 +35,7 @@ import {
|
|
|
36
35
|
getWebpageFetchErrorDetails,
|
|
37
36
|
normalizeWebpageUrl,
|
|
38
37
|
} from './webpage-node.js';
|
|
38
|
+
import { validateLocalImageFile } from './image-source.js';
|
|
39
39
|
import { buildExcalidrawRestoreCheckpointToolInput, ensureExcalidrawCheckpointId, isExcalidrawCreateView } from './diagram-presets.js';
|
|
40
40
|
|
|
41
41
|
export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
|
|
@@ -435,22 +435,14 @@ function buildImageNodeData(input: CanvasAddNodeInput): Record<string, unknown>
|
|
|
435
435
|
|
|
436
436
|
if (!isDataUri && !isUrl && src) {
|
|
437
437
|
const resolved = resolve(src);
|
|
438
|
-
const ext = resolved.split('.').pop()?.toLowerCase() ?? '';
|
|
439
438
|
const fileName = resolved.split('/').pop() ?? src;
|
|
440
|
-
const
|
|
441
|
-
if (!mime) {
|
|
442
|
-
throw new Error(
|
|
443
|
-
`Invalid image node: "${fileName}" has unsupported extension ".${ext}". ` +
|
|
444
|
-
`Accepted: ${Object.keys(IMAGE_MIME_MAP).join(', ')}. ` +
|
|
445
|
-
`For non-image files use type="file" (live viewer) or type="webpage" (URL) instead.`,
|
|
446
|
-
);
|
|
447
|
-
}
|
|
439
|
+
const { mimeType } = validateLocalImageFile(resolved);
|
|
448
440
|
return {
|
|
449
441
|
...(input.data ?? {}),
|
|
450
442
|
src: resolved,
|
|
451
443
|
title: input.title ?? fileName,
|
|
452
444
|
path: resolved,
|
|
453
|
-
mimeType
|
|
445
|
+
mimeType,
|
|
454
446
|
};
|
|
455
447
|
}
|
|
456
448
|
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { IMAGE_MIME_MAP } from './canvas-state.js';
|
|
5
|
+
|
|
6
|
+
const IMAGE_HEADER_BYTES = 512;
|
|
7
|
+
const IMAGE_HEADER_READ_TIMEOUT_MS = 5000;
|
|
8
|
+
// Set by `chflags hidden`, iCloud Drive, OneDrive, etc. on macOS — the
|
|
9
|
+
// metadata exists but the file content has not been downloaded locally.
|
|
10
|
+
const MACOS_DATALESS_FLAG = 0x40000000;
|
|
11
|
+
// Per macOS `man stat -f %Xf`, this bit is also set on iCloud Documents
|
|
12
|
+
// & Files for content-not-yet-downloaded entries on newer OS releases.
|
|
13
|
+
const MACOS_BSD_NODUMP_FLAG = 0x00000001;
|
|
14
|
+
|
|
15
|
+
function fileName(path: string): string {
|
|
16
|
+
return basename(path) || path;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readMacosFileFlags(path: string): number | null {
|
|
20
|
+
if (process.platform !== 'darwin') return null;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const raw = execFileSync('/usr/bin/stat', ['-f', '%Xf', path], {
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
26
|
+
timeout: 1000,
|
|
27
|
+
}).trim();
|
|
28
|
+
return raw.length > 0 ? Number.parseInt(raw, 16) : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isMacosCloudPlaceholder(path: string): boolean {
|
|
35
|
+
const flags = readMacosFileFlags(path);
|
|
36
|
+
if (flags === null) return false;
|
|
37
|
+
// Both flags can indicate iCloud-on-demand status depending on macOS
|
|
38
|
+
// release; treat either as a placeholder.
|
|
39
|
+
return (flags & MACOS_DATALESS_FLAG) !== 0 || (flags & MACOS_BSD_NODUMP_FLAG) !== 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertNotCloudPlaceholder(path: string): void {
|
|
43
|
+
if (isMacosCloudPlaceholder(path)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Invalid image node: "${fileName(path)}" appears to be a cloud-on-demand placeholder. ` +
|
|
46
|
+
'Ensure the file is downloaded locally before adding it as an image.',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readHeaderWithDirectFs(path: string, size: number): Buffer {
|
|
52
|
+
const length = Math.min(IMAGE_HEADER_BYTES, size);
|
|
53
|
+
const buffer = Buffer.alloc(length);
|
|
54
|
+
const fd = openSync(path, 'r');
|
|
55
|
+
try {
|
|
56
|
+
const bytesRead = readSync(fd, buffer, 0, length, 0);
|
|
57
|
+
return buffer.subarray(0, bytesRead);
|
|
58
|
+
} finally {
|
|
59
|
+
closeSync(fd);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ProcessSpawnError {
|
|
64
|
+
code?: string;
|
|
65
|
+
signal?: NodeJS.Signals;
|
|
66
|
+
status?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isProcessSpawnError(value: unknown): value is ProcessSpawnError {
|
|
70
|
+
return value !== null && typeof value === 'object';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readHeaderWithDd(path: string): Buffer {
|
|
74
|
+
return execFileSync('/bin/dd', [`if=${path}`, `bs=${IMAGE_HEADER_BYTES}`, 'count=1'], {
|
|
75
|
+
encoding: 'buffer',
|
|
76
|
+
maxBuffer: IMAGE_HEADER_BYTES,
|
|
77
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
78
|
+
timeout: IMAGE_HEADER_READ_TIMEOUT_MS,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readHeaderWithTimeout(path: string, size: number): Buffer {
|
|
83
|
+
// Direct fs read is the fast path on every platform — no fork, no shell,
|
|
84
|
+
// no timeout required because the kernel either returns bytes immediately
|
|
85
|
+
// or fails synchronously. The `dd` escape hatch only matters for macOS
|
|
86
|
+
// cloud-on-demand placeholders, which `assertNotCloudPlaceholder` rejected
|
|
87
|
+
// at the call site, so this path is safe.
|
|
88
|
+
try {
|
|
89
|
+
return readHeaderWithDirectFs(path, size);
|
|
90
|
+
} catch (directError) {
|
|
91
|
+
// On macOS, fall through to `/bin/dd` so a kernel-level stall on a path
|
|
92
|
+
// we did not flag as a placeholder (e.g. an unmounted SMB share that
|
|
93
|
+
// still satisfies `existsSync`) cannot wedge a Bun fiber.
|
|
94
|
+
if (process.platform !== 'darwin' || !existsSync('/bin/dd')) {
|
|
95
|
+
throw directError;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return readHeaderWithDd(path);
|
|
99
|
+
} catch (ddError) {
|
|
100
|
+
const reason = isProcessSpawnError(ddError) ? ddError : null;
|
|
101
|
+
const timedOut = reason?.signal === 'SIGTERM' || reason?.code === 'ETIMEDOUT';
|
|
102
|
+
if (timedOut) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Invalid image node: could not read image header for "${fileName(path)}" within ${IMAGE_HEADER_READ_TIMEOUT_MS}ms. ` +
|
|
105
|
+
'If this file is stored in OneDrive, iCloud Drive, or another cloud-on-demand provider, download it locally first.',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const detail = ddError instanceof Error ? ddError.message : String(ddError);
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Invalid image node: could not read "${fileName(path)}" — ${detail}.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hasAscii(buffer: Buffer, offset: number, value: string): boolean {
|
|
117
|
+
return buffer.subarray(offset, offset + value.length).toString('ascii') === value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function detectImageMimeType(header: Buffer): string | null {
|
|
121
|
+
if (
|
|
122
|
+
header.length >= 8 &&
|
|
123
|
+
header[0] === 0x89 &&
|
|
124
|
+
hasAscii(header, 1, 'PNG') &&
|
|
125
|
+
header[4] === 0x0d &&
|
|
126
|
+
header[5] === 0x0a &&
|
|
127
|
+
header[6] === 0x1a &&
|
|
128
|
+
header[7] === 0x0a
|
|
129
|
+
) {
|
|
130
|
+
return 'image/png';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (header.length >= 3 && header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) {
|
|
134
|
+
return 'image/jpeg';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (header.length >= 6 && (hasAscii(header, 0, 'GIF87a') || hasAscii(header, 0, 'GIF89a'))) {
|
|
138
|
+
return 'image/gif';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (header.length >= 12 && hasAscii(header, 0, 'RIFF') && hasAscii(header, 8, 'WEBP')) {
|
|
142
|
+
return 'image/webp';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (header.length >= 2 && hasAscii(header, 0, 'BM')) {
|
|
146
|
+
return 'image/bmp';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
header.length >= 4 &&
|
|
151
|
+
header[0] === 0x00 &&
|
|
152
|
+
header[1] === 0x00 &&
|
|
153
|
+
(header[2] === 0x01 || header[2] === 0x02) &&
|
|
154
|
+
header[3] === 0x00
|
|
155
|
+
) {
|
|
156
|
+
return 'image/x-icon';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (header.length >= 12 && hasAscii(header, 4, 'ftyp')) {
|
|
160
|
+
const brandArea = header.subarray(8, Math.min(header.length, 32)).toString('ascii');
|
|
161
|
+
if (brandArea.includes('avif') || brandArea.includes('avis')) return 'image/avif';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const text = header.toString('utf8').replace(/^\uFEFF/, '').trimStart().toLowerCase();
|
|
165
|
+
if (text.startsWith('<svg') || (text.startsWith('<?xml') && text.includes('<svg'))) {
|
|
166
|
+
return 'image/svg+xml';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function validateLocalImageFile(path: string): { mimeType: string } {
|
|
173
|
+
const name = fileName(path);
|
|
174
|
+
if (!existsSync(path)) {
|
|
175
|
+
throw new Error(`Invalid image node: "${name}" does not exist.`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const stat = statSync(path);
|
|
179
|
+
if (!stat.isFile()) {
|
|
180
|
+
throw new Error(`Invalid image node: "${name}" is not a regular file.`);
|
|
181
|
+
}
|
|
182
|
+
if (stat.size <= 0) {
|
|
183
|
+
throw new Error(`Invalid image node: "${name}" is empty.`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
187
|
+
if (!IMAGE_MIME_MAP[ext]) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Invalid image node: "${name}" has unsupported extension ".${ext}". ` +
|
|
190
|
+
`Accepted: ${Object.keys(IMAGE_MIME_MAP).join(', ')}. ` +
|
|
191
|
+
'For non-image files use type="file" (live viewer) or type="webpage" (URL) instead.',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
assertNotCloudPlaceholder(path);
|
|
196
|
+
const header = readHeaderWithTimeout(path, stat.size);
|
|
197
|
+
const mimeType = detectImageMimeType(header);
|
|
198
|
+
if (!mimeType) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Invalid image node: "${name}" is not a recognized image file. ` +
|
|
201
|
+
'Expected PNG, JPEG, GIF, SVG, WebP, BMP, ICO, or AVIF image bytes.',
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { mimeType };
|
|
206
|
+
}
|
package/src/server/server.ts
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
import { spawnSync } from 'node:child_process';
|
|
38
38
|
import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
39
|
+
import { readFile } from 'node:fs/promises';
|
|
39
40
|
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
40
41
|
import * as marked from 'marked';
|
|
41
42
|
import type {
|
|
@@ -67,6 +68,7 @@ import { diffLayouts, formatDiff, mutationHistory } from './mutation-history.js'
|
|
|
67
68
|
import { buildCanvasSummary, serializeCanvasLayout, serializeCanvasNode } from './canvas-serialization.js';
|
|
68
69
|
import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
|
|
69
70
|
import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
|
|
71
|
+
import { validateLocalImageFile } from './image-source.js';
|
|
70
72
|
import {
|
|
71
73
|
addCanvasNode,
|
|
72
74
|
addCanvasEdge,
|
|
@@ -1091,7 +1093,7 @@ async function handleCanvasViewport(req: Request): Promise<Response> {
|
|
|
1091
1093
|
}
|
|
1092
1094
|
|
|
1093
1095
|
// ── Serve image file for image nodes ─────────────────────────
|
|
1094
|
-
function handleCanvasImage(pathname: string): Response {
|
|
1096
|
+
async function handleCanvasImage(pathname: string): Promise<Response> {
|
|
1095
1097
|
const nodeId = pathname.replace('/api/canvas/image/', '');
|
|
1096
1098
|
const node = canvasState.getNode(nodeId);
|
|
1097
1099
|
if (!node || node.type !== 'image') {
|
|
@@ -1105,9 +1107,13 @@ function handleCanvasImage(pathname: string): Response {
|
|
|
1105
1107
|
if (!existsSync(safePath)) {
|
|
1106
1108
|
return responseText('Image file not found', 404);
|
|
1107
1109
|
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1110
|
+
let contentType: string;
|
|
1111
|
+
try {
|
|
1112
|
+
contentType = validateLocalImageFile(safePath).mimeType;
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
return responseText(error instanceof Error ? error.message : 'Invalid image file', 400);
|
|
1115
|
+
}
|
|
1116
|
+
const data = await readFile(safePath);
|
|
1111
1117
|
return new Response(data, {
|
|
1112
1118
|
headers: {
|
|
1113
1119
|
'Content-Type': contentType,
|
|
@@ -3773,7 +3779,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3773
3779
|
}
|
|
3774
3780
|
|
|
3775
3781
|
if (url.pathname.startsWith('/api/canvas/image/') && req.method === 'GET') {
|
|
3776
|
-
return handleCanvasImage(url.pathname);
|
|
3782
|
+
return await handleCanvasImage(url.pathname);
|
|
3777
3783
|
}
|
|
3778
3784
|
|
|
3779
3785
|
if (url.pathname === '/api/canvas/edge' && req.method === 'POST') {
|