@dsh-plus/image-studio 0.1.5 → 0.1.7

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.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * 上传暂存区:图生图源图/遮罩的本地文件上传(blobs + JSONL 索引,原子写)。
3
+ * 与画廊分离——上传图片不属于任何画廊条目;生图成功后由条目的
4
+ * sourceUploads 引用(衍生链可见),未被任何条目引用且超过 TTL 的由
5
+ * pruneUploads 回收(见 docs/repo/插件存储规范.md)。
6
+ * 落点 $DSH_HOME/dsh-plus/image-studio/uploads/{index.jsonl, blobs/}
7
+ * @module image-studio/uploads/store
8
+ */
9
+
10
+ import { randomUUID } from 'node:crypto'
11
+ import { appendFile, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
12
+ import { join } from 'node:path'
13
+
14
+ import { pluginDataPath } from '@dsh-plus/shared'
15
+ import { isImageId } from '../images/id.ts'
16
+ import { extOfMime } from '../images/mime.ts'
17
+
18
+ /** 上传记录元数据(wire 视图原样投影)。 */
19
+ export interface UploadEntry {
20
+ id: string
21
+ createdAt: string
22
+ /** 原始文件名(仅作展示,不参与路径拼接)。 */
23
+ name: string
24
+ mime: string
25
+ ext: string
26
+ /** 字节数。 */
27
+ size: number
28
+ }
29
+
30
+ /** 存储层根路径(惰性解析:DSH_HOME 可能在模块加载后才被测试环境设置)。 */
31
+ function uploadsRoot(): string {
32
+ return pluginDataPath('image-studio', 'uploads')
33
+ }
34
+
35
+ function indexFile(): string {
36
+ return join(uploadsRoot(), 'index.jsonl')
37
+ }
38
+
39
+ function blobsDir(): string {
40
+ return join(uploadsRoot(), 'blobs')
41
+ }
42
+
43
+ /** 上传 id 即文件名前缀:uuid 形态白名单,兼作路径穿越防线。 */
44
+ function validateUploadId(id: string): string {
45
+ if (!isImageId(id)) throw new Error(`非法上传标识:${id}`)
46
+ return id
47
+ }
48
+
49
+ /** 图片文件绝对路径。 */
50
+ function blobPathOf(id: string, ext: string): string {
51
+ validateUploadId(id)
52
+ if (!/^[a-z0-9]{2,4}$/.test(ext)) throw new Error(`非法上传扩展名:${ext}`)
53
+ return join(blobsDir(), `${id}.${ext}`)
54
+ }
55
+
56
+ async function ensureDirs(): Promise<void> {
57
+ await mkdir(blobsDir(), { recursive: true })
58
+ }
59
+
60
+ /** 解析 JSONL 全量索引(坏行跳过并告警;空文件返回空表)。 */
61
+ export async function loadUploads(log: (message: string) => void): Promise<UploadEntry[]> {
62
+ let text: string
63
+ try {
64
+ text = await readFile(indexFile(), 'utf8')
65
+ } catch {
66
+ return []
67
+ }
68
+ const items: UploadEntry[] = []
69
+ for (const line of text.split('\n')) {
70
+ if (line.trim().length === 0) continue
71
+ try {
72
+ items.push(JSON.parse(line) as UploadEntry)
73
+ } catch {
74
+ log(`上传索引坏行跳过:${line.slice(0, 80)}`)
75
+ }
76
+ }
77
+ return items
78
+ }
79
+
80
+ /** 单条上传记录查询(未知 id 返回 null)。 */
81
+ export async function findUpload(
82
+ id: string,
83
+ log: (message: string) => void,
84
+ ): Promise<UploadEntry | null> {
85
+ if (!isImageId(id)) return null
86
+ return (await loadUploads(log)).find((item) => item.id === id) ?? null
87
+ }
88
+
89
+ /** 落盘一张上传图片(临时文件 + rename;索引追加在字节落盘之后)。 */
90
+ export async function saveUpload(input: {
91
+ name: string
92
+ mime: string
93
+ data: Uint8Array
94
+ }): Promise<UploadEntry> {
95
+ await ensureDirs()
96
+ const id = randomUUID()
97
+ const ext = extOfMime(input.mime)
98
+ const target = blobPathOf(id, ext)
99
+ const tmp = `${target}.tmp-${process.pid}-${Date.now()}`
100
+ await writeFile(tmp, input.data)
101
+ await rename(tmp, target)
102
+ const entry: UploadEntry = {
103
+ id,
104
+ createdAt: new Date().toISOString(),
105
+ name: input.name,
106
+ mime: input.mime,
107
+ ext,
108
+ size: input.data.byteLength,
109
+ }
110
+ await appendFile(indexFile(), `${JSON.stringify(entry)}\n`, 'utf8')
111
+ return entry
112
+ }
113
+
114
+ /** 读取上传图片字节(未知 id 抛错由调用方映射 404)。 */
115
+ export async function readUploadBytes(id: string): Promise<{ data: Uint8Array; ext: string }> {
116
+ const files = await readdir(blobsDir()).catch(() => [] as string[])
117
+ const hit = files.find((name) => name.startsWith(`${id}.`))
118
+ if (hit === undefined) throw new Error(`unknown-upload: ${id}`)
119
+ const ext = hit.slice(id.length + 1)
120
+ const data = await readFile(blobPathOf(id, ext))
121
+ return { data: new Uint8Array(data), ext }
122
+ }
123
+
124
+ /** 删除单条(字节 + 索引行)。 */
125
+ async function removeUpload(entry: UploadEntry): Promise<void> {
126
+ await rm(blobPathOf(entry.id, entry.ext), { force: true })
127
+ }
128
+
129
+ /** 索引导出为 JSONL(重写式删除的公共收尾)。 */
130
+ async function rewriteIndex(kept: UploadEntry[]): Promise<void> {
131
+ const tmp = `${indexFile()}.tmp-${process.pid}-${Date.now()}`
132
+ await writeFile(
133
+ tmp,
134
+ kept.map((item) => JSON.stringify(item)).join('\n') + (kept.length > 0 ? '\n' : ''),
135
+ 'utf8',
136
+ )
137
+ await rename(tmp, indexFile())
138
+ }
139
+
140
+ /**
141
+ * 回收孤儿上传:未被 keepIds 引用且创建时间早于 ttlMs 的条目删除。
142
+ * @returns 删除条数。
143
+ */
144
+ export async function pruneUploads(options: {
145
+ keepIds: ReadonlySet<string>
146
+ ttlMs: number
147
+ now?: number
148
+ log: (message: string) => void
149
+ }): Promise<number> {
150
+ const { keepIds, ttlMs, log } = options
151
+ const now = options.now ?? Date.now()
152
+ const items = await loadUploads(log)
153
+ const kept: UploadEntry[] = []
154
+ let removed = 0
155
+ for (const item of items) {
156
+ const expired = now - Date.parse(item.createdAt) > ttlMs
157
+ if (!keepIds.has(item.id) && expired) {
158
+ await removeUpload(item).catch((error: unknown) => {
159
+ log(`上传回收失败(忽略):${item.id} ${String(error)}`)
160
+ })
161
+ removed += 1
162
+ continue
163
+ }
164
+ kept.push(item)
165
+ }
166
+ if (removed > 0) await rewriteIndex(kept)
167
+ return removed
168
+ }