dsh-multimodal-node 0.0.0-stage → 0.3.1

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/lib/apikeys.js ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * API 接入凭据(apikey)。
3
+ *
4
+ * 与服务节点绑定的第二条凭据线,和网页账号完全分开:
5
+ *
6
+ * 网页账号 = 用户名 + 密码 + 6 位配对码 → 人,浏览器,Cookie 会话。
7
+ * apikey = 一个 Bearer 串 → 程序,脚本,别的 DSH。
8
+ *
9
+ * 为什么分开:把 apikey 存进 remote.accounts 那一套里,任何一次「给脚本发个 key」都会
10
+ * 顺手生成一个能登进网页的密码;反过来,一次网页改密码会静默打断所有正在跑的脚本。
11
+ *
12
+ * 存储形态:**只存 sha256,不存明文**。创建时返回一次完整 key,之后列表里只剩前缀。
13
+ * 这不是洁癖——settings.json 落在用户目录、会被同步盘带走、会被截图贴进群里。
14
+ *
15
+ * 分级(tier):SFW / NSFW 是**上限**,与网页账号同一套语义。
16
+ * SFW 的 key 能提交 SFW 工作流、看 SFW 模板与产物;
17
+ * NSFW 的 key 两者都能。
18
+ * 判定复用 lib/tiers.js,所以「什么算成人内容」只有一份定义。
19
+ */
20
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
21
+ import { normalizeTier } from './tiers.js';
22
+
23
+ const KEY_PREFIX = 'mmn_';
24
+ const PREFIX_SHOWN = 10;
25
+ const NAME_PATTERN = /^[a-z0-9_.-]{2,32}$/;
26
+
27
+ export function hashKey(key) {
28
+ return createHash('sha256').update(String(key)).digest('hex');
29
+ }
30
+
31
+ /** 生成一个新 key:`mmn_` + 32 字节 base64url。 */
32
+ export function generateKey() {
33
+ return `${KEY_PREFIX}${randomBytes(32).toString('base64url')}`;
34
+ }
35
+
36
+ function publicRecord(entry) {
37
+ return {
38
+ id: entry.id,
39
+ name: entry.name,
40
+ tier: normalizeTier(entry.tier),
41
+ enabled: entry.enabled !== false,
42
+ prefix: entry.prefix ?? '',
43
+ note: entry.note ?? '',
44
+ createdAt: entry.createdAt ?? null,
45
+ lastUsedAt: entry.lastUsedAt ?? null,
46
+ };
47
+ }
48
+
49
+ export class ApiKeyStore {
50
+ constructor(store) {
51
+ this.store = store;
52
+ /**
53
+ * 写盘串行链。
54
+ *
55
+ * 一个 key 上每次请求都会调 touch(),而来回是同一份 settings.json:
56
+ * 两个 touch 在同一个 tick 里 rename 到同一个目标,Windows 上直接
57
+ * EPERM operation not permitted(Linux 上则是抢临时文件名)。
58
+ * 这不是理论问题——第一版就是这样把测试打挂的。
59
+ */
60
+ this.writing = Promise.resolve();
61
+ }
62
+
63
+ /**
64
+ * 排进写链。
65
+ *
66
+ * 两条要求同时成立:
67
+ * 1. 一次失败不能卡住后面的写入 —— 所以链本身吃掉错误;
68
+ * 2. 失败的调用方必须能看到自己的失败 —— 所以返回的是**另一个** promise。
69
+ *
70
+ * 第一版只做了第 1 条(直接把吃掉错误的链返回),于是「重名」这种校验错误变成
71
+ * undefined,调用方接着读它的字段才炸,报错位置离真原因十万八千里。
72
+ */
73
+ enqueue(work) {
74
+ const attempt = this.writing.then(work);
75
+ this.writing = attempt.catch(() => {});
76
+ return attempt;
77
+ }
78
+
79
+ get block() {
80
+ return this.store.get().service ?? {};
81
+ }
82
+
83
+ records() {
84
+ return Array.isArray(this.block.apiKeys) ? this.block.apiKeys : [];
85
+ }
86
+
87
+ /** 面板用:永不含 hash,也永不含明文。 */
88
+ list() {
89
+ return this.records().map(publicRecord);
90
+ }
91
+
92
+ requireKey() {
93
+ return this.block.requireKey === true;
94
+ }
95
+
96
+ async setRequireKey(on) {
97
+ await this.enqueue(async () => {
98
+ await this.store.patch({ service: { requireKey: on === true } });
99
+ });
100
+ return { ok: true, requireKey: this.block.requireKey === true };
101
+ }
102
+
103
+ /**
104
+ * 建一个 key。返回的 `key` 只在这一刻存在,之后再也拿不到——
105
+ * 面板必须当场显示并提示复制,这是唯一一次。
106
+ */
107
+ async create(input = {}) {
108
+ const name = String(input.name ?? '').trim().toLowerCase();
109
+ if (!NAME_PATTERN.test(name)) throw new Error('名称需为 2–32 位字母、数字、下划线、点或减号');
110
+ const key = generateKey();
111
+ /**
112
+ * 读-改-写整个在队列里做。
113
+ *
114
+ * 之前的写法是「先读 records、算好新数组、再入队写」。两次并发 create 会读到
115
+ * 同一份 records,各自的数组都只含自己的新 key,后写的那次把前一次整个覆盖——
116
+ * 面板上表现为「刚生成的那把 key 消失了」,而且没有任何日志。
117
+ * 队列只保证写盘不撞车,保证不了「算的时候看到的是最新的」。
118
+ */
119
+ const record = await this.enqueue(async () => {
120
+ if (this.records().some((entry) => entry.name === name)) throw new Error(`apikey ${name} 已存在`);
121
+ const entry = {
122
+ id: randomBytes(6).toString('hex'),
123
+ name,
124
+ tier: normalizeTier(input.tier),
125
+ enabled: true,
126
+ keyHash: hashKey(key),
127
+ prefix: key.slice(0, PREFIX_SHOWN),
128
+ note: String(input.note ?? '').slice(0, 200),
129
+ createdAt: new Date().toISOString(),
130
+ lastUsedAt: null,
131
+ };
132
+ await this.store.patch({ service: { apiKeys: [...this.records(), entry] } });
133
+ return entry;
134
+ });
135
+ return { ok: true, key, record: publicRecord(record), apiKeys: this.list() };
136
+ }
137
+
138
+ async update(name, patch = {}) {
139
+ const target = String(name ?? '').toLowerCase();
140
+ // 读-改-写全在队列里(理由同 create):在队列外读,两次并发改不同字段会互相覆盖。
141
+ const next = await this.enqueue(async () => {
142
+ const records = this.records();
143
+ const index = records.findIndex((entry) => entry.name === target || entry.id === target);
144
+ if (index < 0) throw new Error(`没有 apikey ${name}`);
145
+ const rows = records.slice();
146
+ rows[index] = {
147
+ ...rows[index],
148
+ ...(patch.tier !== undefined ? { tier: normalizeTier(patch.tier) } : {}),
149
+ ...(patch.enabled !== undefined ? { enabled: patch.enabled === true } : {}),
150
+ ...(patch.note !== undefined ? { note: String(patch.note).slice(0, 200) } : {}),
151
+ };
152
+ await this.store.patch({ service: { apiKeys: rows } });
153
+ return rows;
154
+ });
155
+ return { ok: true, apiKeys: next.map(publicRecord) };
156
+ }
157
+
158
+ async remove(name) {
159
+ const target = String(name ?? '').toLowerCase();
160
+ const next = await this.enqueue(async () => {
161
+ const records = this.records();
162
+ const rows = records.filter((entry) => entry.name !== target && entry.id !== target);
163
+ if (rows.length === records.length) throw new Error(`没有 apikey ${name}`);
164
+ await this.store.patch({ service: { apiKeys: rows } });
165
+ return rows;
166
+ });
167
+ return { ok: true, apiKeys: next.map(publicRecord) };
168
+ }
169
+
170
+ /** 轮换:同一个名字换一把新 key,旧 key 立刻失效。 */
171
+ async rotate(name) {
172
+ const target = String(name ?? '').toLowerCase();
173
+ const key = generateKey();
174
+ const entry = await this.enqueue(async () => {
175
+ const records = this.records();
176
+ const index = records.findIndex((row) => row.name === target || row.id === target);
177
+ if (index < 0) throw new Error(`没有 apikey ${name}`);
178
+ const rows = records.slice();
179
+ rows[index] = { ...rows[index], keyHash: hashKey(key), prefix: key.slice(0, PREFIX_SHOWN), createdAt: new Date().toISOString() };
180
+ await this.store.patch({ service: { apiKeys: rows } });
181
+ return rows[index];
182
+ });
183
+ return { ok: true, key, record: publicRecord(entry), apiKeys: this.list() };
184
+ }
185
+
186
+ /**
187
+ * 校验一次调用。
188
+ *
189
+ * 返回值里 `checked: false` 表示「这个端口没有在管凭据」——调用方据此决定放行,
190
+ * 而不是把「没开校验」误当成「校验通过」。两者在日志和审计里必须能分开。
191
+ */
192
+ verify(raw) {
193
+ if (this.requireKey() !== true) {
194
+ return { ok: true, checked: false, key: null, tier: 'nsfw' };
195
+ }
196
+ const text = String(raw ?? '').trim();
197
+ if (text === '') return { ok: false, checked: true, key: null, error: '缺少 apikey' };
198
+ const digest = Buffer.from(hashKey(text), 'hex');
199
+ for (const entry of this.records()) {
200
+ if (entry.enabled === false) continue;
201
+ if (typeof entry.keyHash !== 'string' || entry.keyHash.length !== 64) continue;
202
+ const expected = Buffer.from(entry.keyHash, 'hex');
203
+ if (expected.length !== digest.length) continue;
204
+ if (!timingSafeEqual(digest, expected)) continue;
205
+ void this.touch(entry.id);
206
+ return {
207
+ ok: true,
208
+ checked: true,
209
+ key: publicRecord(entry),
210
+ tier: normalizeTier(entry.tier),
211
+ name: entry.name,
212
+ };
213
+ }
214
+ return { ok: false, checked: true, key: null, error: 'apikey 无效或已停用' };
215
+ }
216
+
217
+ /**
218
+ * 记一次使用时间。
219
+ *
220
+ * 进串行链之后再判断节流:判断写在入队之前是错的,因为同一毫秒里进来的
221
+ * 十个请求会全部看到"上次使用在一分钟前",然后一起排进去写十次。
222
+ */
223
+ touch(id) {
224
+ return this.enqueue(async () => {
225
+ const now = new Date().toISOString();
226
+ // 入队后重读,不能信入队前的快照
227
+ const records = this.records();
228
+ const index = records.findIndex((entry) => entry.id === id);
229
+ if (index < 0) return;
230
+ const last = records[index].lastUsedAt;
231
+ if (typeof last === 'string' && Date.now() - Date.parse(last) < 60_000) return;
232
+ const next = records.slice();
233
+ next[index] = { ...records[index], lastUsedAt: now };
234
+ await this.store.patch({ service: { apiKeys: next } });
235
+ });
236
+ }
237
+
238
+ /** 从 Authorization / X-API-Key / 查询串里取 key,三种写法都认。 */
239
+ static extract(headers, url) {
240
+ const auth = headers?.authorization;
241
+ if (typeof auth === 'string' && /^bearer\s+/i.test(auth)) return auth.replace(/^bearer\s+/i, '').trim();
242
+ const direct = headers?.['x-api-key'];
243
+ if (typeof direct === 'string' && direct !== '') return direct.trim();
244
+ const query = url?.searchParams?.get('apikey');
245
+ if (typeof query === 'string' && query !== '') return query.trim();
246
+ return '';
247
+ }
248
+ }
249
+
250
+ export { KEY_PREFIX, PREFIX_SHOWN };
package/lib/assets.js ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The asset library: every image an account has produced, indexed for browsing.
3
+ *
4
+ * The run index answers "what did I submit"; this answers "show me my pictures"
5
+ * — one entry per file, with the pixel size the console prints under the
6
+ * thumbnail, and enough of the prompt to recognise what produced it. Sizes are
7
+ * filled in lazily by whatever route has a connection to the owning instance,
8
+ * because only the instance holds the bytes.
9
+ */
10
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+
13
+ const MAX_PER_ACCOUNT = 1200;
14
+
15
+ export class AssetLibrary {
16
+ constructor(dataDir) {
17
+ this.file = join(dataDir, 'assets.json');
18
+ /** @type {Map<string, object[]>} owner -> assets, newest first */
19
+ this.byOwner = new Map();
20
+ /**
21
+ * Deletions, kept as tombstones rather than just removing a row.
22
+ *
23
+ * The library is rebuilt from `runs.json` by a periodic sweep, so "the row is gone
24
+ * from the array" is not enough: the next pass finds a finished run, notices its
25
+ * file list does not match, and puts the picture straight back. That is exactly the
26
+ * reported "删除记录还会出来". A tombstone says "this file was deliberately
27
+ * removed", which the sweep can honour.
28
+ *
29
+ * @type {Map<string, object[]>} owner -> [{promptId, filename}]
30
+ */
31
+ this.deleted = new Map();
32
+ this.writing = Promise.resolve();
33
+ this.counter = 0;
34
+ }
35
+
36
+ async init() {
37
+ await mkdir(join(this.file, '..'), { recursive: true });
38
+ try {
39
+ const parsed = JSON.parse(await readFile(this.file, 'utf8'));
40
+ for (const [owner, rows] of Object.entries(parsed?.byOwner ?? {})) {
41
+ if (Array.isArray(rows)) this.byOwner.set(owner, rows);
42
+ }
43
+ for (const [owner, rows] of Object.entries(parsed?.deleted ?? {})) {
44
+ if (Array.isArray(rows)) this.deleted.set(owner, rows);
45
+ }
46
+ } catch {
47
+ await this.save();
48
+ }
49
+ }
50
+
51
+ save() {
52
+ this.writing = this.writing
53
+ .catch(() => {})
54
+ .then(async () => {
55
+ this.counter += 1;
56
+ const temp = `${this.file}.${this.counter}.tmp`;
57
+ const body = {
58
+ byOwner: Object.fromEntries(this.byOwner),
59
+ deleted: Object.fromEntries(this.deleted),
60
+ };
61
+ await writeFile(temp, `${JSON.stringify(body, null, 2)}\n`, 'utf8');
62
+ await rename(temp, this.file);
63
+ })
64
+ .catch(() => {});
65
+ return this.writing;
66
+ }
67
+
68
+ /**
69
+ * Record every file one finished run produced.
70
+ *
71
+ * Idempotent per (owner, promptId, filename): the sweeper re-reads finished
72
+ * runs, so a second pass must not duplicate a picture.
73
+ */
74
+ async recordRun(owner, record, files) {
75
+ const existing = this.byOwner.get(owner) ?? [];
76
+ const kept = existing.filter((asset) => asset.promptId !== record.promptId);
77
+ /**
78
+ * A file the operator deleted stays deleted.
79
+ *
80
+ * Filtered here rather than in the sweep, because every path that rebuilds a run's
81
+ * rows goes through this function. Without it the tombstone would be advisory and
82
+ * the picture would reappear the next time anything re-read the run.
83
+ */
84
+ const wanted = files.filter((file) => !this.isDeleted(owner, record.promptId, file.filename));
85
+ const fresh = wanted.map((file) => ({
86
+ owner,
87
+ promptId: record.promptId,
88
+ name: record.name ?? '',
89
+ filename: file.filename,
90
+ subfolder: file.subfolder ?? '',
91
+ type: file.type ?? 'output',
92
+ endpointId: record.endpointId ?? null,
93
+ submittedAt: record.submittedAt ?? Date.now(),
94
+ /**
95
+ * The sampling seed this picture came from, when the run recorded one.
96
+ *
97
+ * Carried onto the asset row for the same reason it is stored on the run: it is
98
+ * the only way to reproduce that exact image later. null means "this run had no
99
+ * seed to record" (a pure upscale, or a sampler without one), which the console
100
+ * prints as a dash — not as 0, which is a real seed.
101
+ */
102
+ seed: typeof record.seed === 'number' && Number.isSafeInteger(record.seed) ? record.seed : null,
103
+ /**
104
+ * Which half of the library this picture belongs to.
105
+ *
106
+ * Recorded at run time from the run's own classification, because that is the
107
+ * only moment the answer is cheap: the workflow name, the declared tier and the
108
+ * prompt are all in hand. After the fact it would mean re-reading runs.json for
109
+ * every browse. The admin eye filters on this column.
110
+ */
111
+ tier: record.tier === 'nsfw' ? 'nsfw' : 'sfw',
112
+ // Filled on first browse; null means "not measured yet", which is
113
+ // different from "measured and unreadable".
114
+ width: null,
115
+ height: null,
116
+ }));
117
+ // Preserve sizes already learned for files that survive this pass, and the seed when
118
+ // a later pass arrives without one (a run recorded before seeds were stored).
119
+ const sizes = new Map(existing.map((asset) => [`${asset.promptId}|${asset.filename}`, asset]));
120
+ for (const asset of fresh) {
121
+ const known = sizes.get(`${asset.promptId}|${asset.filename}`);
122
+ if (known !== undefined) {
123
+ asset.width = known.width ?? null;
124
+ asset.height = known.height ?? null;
125
+ if (asset.seed === null && typeof known.seed === 'number') asset.seed = known.seed;
126
+ if (known.tier !== undefined) asset.tier = known.tier;
127
+ }
128
+ }
129
+ const next = [...fresh, ...kept].slice(0, MAX_PER_ACCOUNT);
130
+ this.byOwner.set(owner, next);
131
+ await this.save();
132
+ return fresh.length;
133
+ }
134
+
135
+ /** Record a size once it is known. */
136
+ async setSize(owner, promptId, filename, size) {
137
+ const rows = this.byOwner.get(owner);
138
+ if (rows === undefined) return;
139
+ let changed = false;
140
+ for (const asset of rows) {
141
+ if (asset.promptId !== promptId || asset.filename !== filename) continue;
142
+ if (asset.width === size.width && asset.height === size.height) continue;
143
+ asset.width = size.width;
144
+ asset.height = size.height;
145
+ changed = true;
146
+ }
147
+ if (changed) await this.save();
148
+ }
149
+
150
+ list(owner, limit = 80) {
151
+ return (this.byOwner.get(owner) ?? []).slice(0, limit);
152
+ }
153
+
154
+ get(owner, promptId, filename) {
155
+ return (this.byOwner.get(owner) ?? []).find((asset) => asset.promptId === promptId && asset.filename === filename);
156
+ }
157
+
158
+ owns(owner, promptId, filename) {
159
+ return this.get(owner, promptId, filename) !== undefined;
160
+ }
161
+
162
+ /**
163
+ * Does this run already have exactly this many asset rows?
164
+ *
165
+ * The sync uses it as a cheap "already current" test so a periodic sweep over a healthy
166
+ * library does no work. Counting rows rather than checking presence matters: a run that
167
+ * produced three images and has one row is stale, not current.
168
+ *
169
+ * Rows the operator deleted are counted. Without that, a deleted picture made the count
170
+ * permanently short, the sweeper re-added it on its next pass, and "删除" looked broken —
171
+ * the row came back seconds later. A deletion is a decision, so it has to be part of
172
+ * "current".
173
+ */
174
+ hasRun(owner, promptId, expected) {
175
+ const rows = (this.byOwner.get(owner) ?? []).filter((asset) => asset.promptId === promptId);
176
+ const removed = (this.deleted?.get(owner) ?? []).filter((row) => row.promptId === promptId).length;
177
+ return rows.length + removed === expected;
178
+ }
179
+
180
+ /** Was this exact picture deleted by its owner? */
181
+ isDeleted(owner, promptId, filename) {
182
+ return (this.deleted?.get(owner) ?? []).some((row) => row.promptId === promptId && row.filename === filename);
183
+ }
184
+
185
+ /** Every file this owner has under one prompt id — the console's "assets of a run". */
186
+ ofRun(owner, promptId) {
187
+ return (this.byOwner.get(owner) ?? []).filter((asset) => asset.promptId === promptId);
188
+ }
189
+
190
+ /**
191
+ * Drop one picture from this owner's library.
192
+ *
193
+ * Only the index row goes away: the bytes live on the ComfyUI instance and this
194
+ * library never had the authority to delete them. That is the honest scope of a
195
+ * "delete from library" button — the picture stops appearing here, and the run it
196
+ * came from stays in history with its file list intact, so nothing silently
197
+ * disagrees with the instance.
198
+ *
199
+ * A tombstone is written as well as the row being removed. Removing the row alone made
200
+ * the delete undo itself: the sweep rebuilds every finished run's rows from runs.json,
201
+ * saw one row fewer than the run produced, and re-added the picture. The tombstone is
202
+ * what makes the decision stick, and it is why the answer to "删除记录还是彻底删除" is
203
+ * "neither — it stops being listed, and it stays unlisted".
204
+ *
205
+ * Returns whether a row actually matched, so the route can answer 404 rather than
206
+ * reporting success for a name that was never there.
207
+ */
208
+ async remove(owner, promptId, filename) {
209
+ const rows = this.byOwner.get(owner) ?? [];
210
+ const next = rows.filter((asset) => !(asset.promptId === promptId && asset.filename === filename));
211
+ if (next.length === rows.length) return false;
212
+ this.byOwner.set(owner, next);
213
+ const tombstones = this.deleted.get(owner) ?? [];
214
+ if (!tombstones.some((row) => row.promptId === promptId && row.filename === filename)) {
215
+ tombstones.push({ promptId, filename, at: Date.now() });
216
+ // Bounded: a library keeps at most MAX_PER_ACCOUNT rows, so the tombstone list
217
+ // never needs to be longer than that to stay useful.
218
+ this.deleted.set(owner, tombstones.slice(-MAX_PER_ACCOUNT));
219
+ }
220
+ await this.save();
221
+ return true;
222
+ }
223
+
224
+ /** How many pictures this owner has deliberately removed (for the diagnostics line). */
225
+ removedCount(owner) {
226
+ return (this.deleted.get(owner) ?? []).length;
227
+ }
228
+
229
+ async dropOwner(owner) {
230
+ this.byOwner.delete(owner);
231
+ this.deleted.delete(owner);
232
+ await this.save();
233
+ }
234
+ }