/** * profile 值编辑器粘贴图片的磁盘存取。 * * 图片落到 `/.spx/profile-assets/.`,profile 值里只留 * markdown 引用 `![](.spx/profile-assets/xxx.png)`——把 base64 塞进 profiles.json * 会把配置撑成几百 KB 且不可读,故只存引用、内容单独落盘。 * * 内容寻址(sha1 前 16 位命名)让同一张图重复粘贴自动去重、复用同一文件。 * 移除引用只改 profile 值字符串,磁盘孤儿文件本期不做回收(低频、无害)。 */ import type { KanbanWebviewPanel } from '../KanbanPanel' import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' import { promises as fsp } from 'node:fs' import * as path from 'node:path' import { workspace } from 'vscode' /** 相对工作区根的资源目录;也是路径穿越校验的白名单根。 */ const ASSET_DIR = '.spx/profile-assets' /** mediaType → 落盘扩展名;未知一律按 png(前面已保证是 image/*)。 */ function extForMedia(mediaType: string): string { switch (mediaType) { case 'image/jpeg': return 'jpg' case 'image/gif': return 'gif' case 'image/webp': return 'webp' case 'image/svg+xml': return 'svg' case 'image/png': default: return 'png' } } /** 扩展名 → mediaType,用于读盘时拼 data URL;未知按 png。 */ function mediaForExt(ext: string): string { switch (ext.toLowerCase()) { case 'jpg': case 'jpeg': return 'image/jpeg' case 'gif': return 'image/gif' case 'webp': return 'image/webp' case 'svg': return 'image/svg+xml' case 'png': default: return 'image/png' } } /** * 把 base64 图片写盘并回传其工作区相对路径。非图片 mediaType 直接拒绝,避免被 * 当成任意文件写入通道。写盘失败兜底回 error,让编辑器提示而非静默丢图。 */ export async function handleSaveProfileAsset( panel: KanbanWebviewPanel, args: { requestId: string, base64: string, mediaType: string }, ): Promise { try { const root = workspace.workspaceFolders?.[0]?.uri.fsPath if (!root) { panel.postMessage({ type: 'profile-asset/saved', requestId: args.requestId, error: '未打开工作区' }) return } if (!args.mediaType.startsWith('image/')) { panel.postMessage({ type: 'profile-asset/saved', requestId: args.requestId, error: '仅支持图片' }) return } const hash = createHash('sha1').update(args.base64).digest('hex').slice(0, 16) const rel = `${ASSET_DIR}/${hash}.${extForMedia(args.mediaType)}` const abs = path.join(root, rel) await fsp.mkdir(path.dirname(abs), { recursive: true }) await fsp.writeFile(abs, Buffer.from(args.base64, 'base64')) panel.postMessage({ type: 'profile-asset/saved', requestId: args.requestId, assetPath: rel }) } catch (err) { panel.postMessage({ type: 'profile-asset/saved', requestId: args.requestId, error: err instanceof Error ? err.message : String(err), }) } } /** * 读回资源文件并拼成 data URL 供编辑器渲染缩略图。 * * 安全:assetPath 规范化后必须仍落在 `/.spx/profile-assets/` 内,挡 `..` * 路径穿越——这个路径来自 profile 值字符串,用户可编辑,不能信。 */ export async function handleLoadProfileAsset( panel: KanbanWebviewPanel, args: { requestId: string, assetPath: string }, ): Promise { try { const root = workspace.workspaceFolders?.[0]?.uri.fsPath if (!root) { panel.postMessage({ type: 'profile-asset/loaded', requestId: args.requestId, error: '未打开工作区' }) return } const baseDir = path.resolve(root, ASSET_DIR) const abs = path.resolve(root, args.assetPath) if (abs !== baseDir && !abs.startsWith(baseDir + path.sep)) { panel.postMessage({ type: 'profile-asset/loaded', requestId: args.requestId, error: '非法资源路径' }) return } const buf = await fsp.readFile(abs) const ext = path.extname(abs).slice(1) const dataUrl = `data:${mediaForExt(ext)};base64,${buf.toString('base64')}` panel.postMessage({ type: 'profile-asset/loaded', requestId: args.requestId, dataUrl }) } catch (err) { panel.postMessage({ type: 'profile-asset/loaded', requestId: args.requestId, error: err instanceof Error ? err.message : String(err), }) } }