Files
superwork/vscode/src/panel/handlers/profileAssets.ts
T
cruldra dae1232f19 🎨 style(vscode): 清零 lint 欠账
非纯格式的改动:
- 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
2026-08-31 22:33:11 +08:00

113 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* profile 值编辑器粘贴图片的磁盘存取。
*
* 图片落到 `<workspace>/.spx/profile-assets/<sha1前16>.<ext>`,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<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),
})
}
}