非纯格式的改动: - logger:info 级日志原先走 console.log(no-console 禁止),改为通过 VS Code LogOutputChannel(Output 面板 "Superpowers")输出 info/warn/error,并在 activate 时注册 dispose - esbuild.mjs:顶层 await 收进 async main(),失败时退出码 1;构建脚本打印 进度属正当输出,文件级关闭 no-console 并注明原因 - KanbanPanel 资源路径正则:去掉 `\.\/[^"]+` 分支(已被 `[^"/][^"]*` 完全 覆盖),匹配结果不变 - webhook/server:onError/onListening 互相引用,改为函数声明消除先用后定义 - SettingsModal:删掉针对未启用规则 react-hooks/exhaustive-deps 的失效 disable 注释,effect 依赖 [open] 保持不变(按打开时机重置表单是有意的) - issues.ts 提示词里两处全角空格换成普通空格 - 其余为 node:process / node:buffer 显式导入与 switch 拆行 Claude-Session: https://claude.ai/code/session_01HTMgwaE3woqM5yvsHiuSAa
113 lines
4.3 KiB
TypeScript
113 lines
4.3 KiB
TypeScript
/**
|
||
* profile 值编辑器粘贴图片的磁盘存取。
|
||
*
|
||
* 图片落到 `<workspace>/.spx/profile-assets/<sha1前16>.<ext>`,profile 值里只留
|
||
* markdown 引用 ``——把 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<void> {
|
||
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 规范化后必须仍落在 `<root>/.spx/profile-assets/` 内,挡 `..`
|
||
* 路径穿越——这个路径来自 profile 值字符串,用户可编辑,不能信。
|
||
*/
|
||
export async function handleLoadProfileAsset(
|
||
panel: KanbanWebviewPanel,
|
||
args: { requestId: string, assetPath: string },
|
||
): Promise<void> {
|
||
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),
|
||
})
|
||
}
|
||
}
|