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/README.md +504 -2
- package/client.js +2817 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +9 -0
- package/index.js +960 -0
- package/lib/apikeys.js +250 -0
- package/lib/assets.js +234 -0
- package/lib/auth.js +315 -0
- package/lib/blender.js +644 -0
- package/lib/chain.js +161 -0
- package/lib/comfy.js +204 -0
- package/lib/config.js +377 -0
- package/lib/gateway.js +974 -0
- package/lib/http.js +119 -0
- package/lib/imagesize.js +92 -0
- package/lib/instances.js +554 -0
- package/lib/library.js +1209 -0
- package/lib/models.js +322 -0
- package/lib/nodeapi.js +491 -0
- package/lib/nodeclient.js +143 -0
- package/lib/pages.js +5788 -0
- package/lib/pool.js +591 -0
- package/lib/promptassist.js +1077 -0
- package/lib/promptlibrary.js +92 -0
- package/lib/remote.js +3348 -0
- package/lib/runindex.js +126 -0
- package/lib/seed.js +118 -0
- package/lib/tagdata.json +1 -0
- package/lib/tiers.js +283 -0
- package/lib/tools.js +230 -0
- package/lib/viewport.js +134 -0
- package/lib/workflows.js +472 -0
- package/lib/ws.js +106 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -4
package/lib/auth.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accounts, the rotating pairing code, and sessions for the remote port.
|
|
3
|
+
*
|
|
4
|
+
* Three separate things, deliberately:
|
|
5
|
+
*
|
|
6
|
+
* password — what a person knows. Stored as scrypt(password, salt); the
|
|
7
|
+
* plaintext never exists outside the request that set it.
|
|
8
|
+
* pairing — what the machine proves. A 6-digit code derived from a per-install
|
|
9
|
+
* secret and a 60-second time step, so it can be shown on the
|
|
10
|
+
* desktop and read out loud without ever being stored, and an
|
|
11
|
+
* attacker who only has the password still cannot log in.
|
|
12
|
+
* session — what the browser keeps. A random token in an HttpOnly cookie,
|
|
13
|
+
* with idle + absolute expiry, revocable one by one.
|
|
14
|
+
*
|
|
15
|
+
* The code is derived per step and never persisted, so a leaked settings file
|
|
16
|
+
* does not hand out a second factor.
|
|
17
|
+
*/
|
|
18
|
+
import { createHmac, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
import { normalizeTier } from './tiers.js';
|
|
21
|
+
|
|
22
|
+
const scrypt = promisify(scryptCallback);
|
|
23
|
+
|
|
24
|
+
const STEP_MS = 60_000;
|
|
25
|
+
const CODE_DIGITS = 6;
|
|
26
|
+
const SESSION_IDLE_MS = 30 * 60_000;
|
|
27
|
+
const SESSION_MAX_MS = 12 * 60 * 60_000;
|
|
28
|
+
const MAX_FAILURES = 8;
|
|
29
|
+
const FAILURE_WINDOW_MS = 10 * 60_000;
|
|
30
|
+
|
|
31
|
+
export function hashPassword(password, salt = randomBytes(16).toString('hex')) {
|
|
32
|
+
return scrypt(password, salt, 64).then((derived) => ({
|
|
33
|
+
salt,
|
|
34
|
+
hash: Buffer.from(derived).toString('hex'),
|
|
35
|
+
algo: 'scrypt',
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function verifyPassword(password, record) {
|
|
40
|
+
if (record === undefined || record === null || typeof record.hash !== 'string') return false;
|
|
41
|
+
if (record.algo !== 'scrypt' || typeof record.salt !== 'string') return false;
|
|
42
|
+
const derived = Buffer.from(await scrypt(password, record.salt, 64));
|
|
43
|
+
const expected = Buffer.from(record.hash, 'hex');
|
|
44
|
+
if (expected.length !== derived.length) return false;
|
|
45
|
+
return timingSafeEqual(derived, expected);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One code per 60s step, plus the next step so a slow clock still works. */
|
|
49
|
+
export function pairingCodes(secret, at = Date.now()) {
|
|
50
|
+
const step = Math.floor(at / STEP_MS);
|
|
51
|
+
return [step, step + 1].map((value) => ({
|
|
52
|
+
code: deriveCode(secret, value),
|
|
53
|
+
step: value,
|
|
54
|
+
expiresAt: (value + 1) * STEP_MS,
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function deriveCode(secret, step) {
|
|
59
|
+
const mac = createHmac('sha256', secret).update(`pair:${step}`).digest();
|
|
60
|
+
const offset = mac[mac.length - 1] & 0x0f;
|
|
61
|
+
const number = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3];
|
|
62
|
+
return String(number % 10 ** CODE_DIGITS).padStart(CODE_DIGITS, '0');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function matchesPairingCode(secret, input, at = Date.now()) {
|
|
66
|
+
const text = String(input ?? '').replace(/\D/g, '');
|
|
67
|
+
if (text.length !== CODE_DIGITS) return false;
|
|
68
|
+
return pairingCodes(secret, at).some((entry) => entry.code === text);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class AuthStore {
|
|
72
|
+
constructor(store) {
|
|
73
|
+
this.store = store;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get settings() {
|
|
77
|
+
return this.store.get().remote ?? {};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The per-install secret, created on first use and kept in settings. */
|
|
81
|
+
async ensureSecret() {
|
|
82
|
+
const remote = this.store.get().remote ?? {};
|
|
83
|
+
if (typeof remote.secret === 'string' && remote.secret.length >= 32) return remote.secret;
|
|
84
|
+
const secret = randomBytes(32).toString('hex');
|
|
85
|
+
await this.store.patch({ remote: { secret } });
|
|
86
|
+
return secret;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Face value for the desktop panel: "read this to the person joining". */
|
|
90
|
+
async currentCode() {
|
|
91
|
+
const required = this.settings.requireCode !== false;
|
|
92
|
+
if (!required) return { code: null, expiresAt: 0, secondsLeft: 0, next: null, required: false };
|
|
93
|
+
const secret = await this.ensureSecret();
|
|
94
|
+
const [now, next] = pairingCodes(secret);
|
|
95
|
+
return {
|
|
96
|
+
code: now.code,
|
|
97
|
+
expiresAt: now.expiresAt,
|
|
98
|
+
secondsLeft: Math.max(0, Math.round((now.expiresAt - Date.now()) / 1000)),
|
|
99
|
+
next: next.code,
|
|
100
|
+
required: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Fill in tiers for accounts created before tiers existed.
|
|
106
|
+
*
|
|
107
|
+
* An administrator is the person who runs the machine and already has adult
|
|
108
|
+
* workflows in their library, so defaulting them to SFW would hide their own
|
|
109
|
+
* work from them; a viewer stays SFW, which is the safe direction.
|
|
110
|
+
*/
|
|
111
|
+
async migrateTiers() {
|
|
112
|
+
const records = this.accounts();
|
|
113
|
+
let changed = 0;
|
|
114
|
+
const accounts = records.map((entry) => {
|
|
115
|
+
if (entry.tier === 'sfw' || entry.tier === 'nsfw') return entry;
|
|
116
|
+
changed += 1;
|
|
117
|
+
return { ...entry, tier: entry.role === 'admin' ? 'nsfw' : 'sfw' };
|
|
118
|
+
});
|
|
119
|
+
if (changed > 0) await this.store.patch({ remote: { accounts } });
|
|
120
|
+
return changed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Turn the second factor on or off. Off means password-only. */
|
|
124
|
+
async setRequireCode(required) {
|
|
125
|
+
await this.store.patch({ remote: { requireCode: required === true } });
|
|
126
|
+
return { ok: true, requireCode: this.settings.requireCode !== false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
accounts() {
|
|
130
|
+
return Array.isArray(this.settings.accounts) ? this.settings.accounts : [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async addAccount(name, password, role = 'viewer', tier = 'sfw') {
|
|
134
|
+
const user = String(name ?? '').trim().toLowerCase();
|
|
135
|
+
if (!/^[a-z0-9_.-]{2,32}$/.test(user)) throw new Error('用户名需为 2–32 位字母、数字、下划线、点或减号');
|
|
136
|
+
if (String(password ?? '').length < 8) throw new Error('密码至少 8 位');
|
|
137
|
+
if (this.accounts().some((entry) => entry.name === user)) throw new Error(`账号 ${user} 已存在`);
|
|
138
|
+
const record = await hashPassword(String(password));
|
|
139
|
+
const accounts = [
|
|
140
|
+
...this.accounts(),
|
|
141
|
+
{ name: user, role, tier: normalizeTier(tier), ...record, createdAt: new Date().toISOString() },
|
|
142
|
+
];
|
|
143
|
+
await this.store.patch({ remote: { accounts } });
|
|
144
|
+
return { ok: true, accounts: this.accounts().map(publicAccount) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Change an account's content tier. Sessions keep working; the ceiling moves. */
|
|
148
|
+
async setTier(name, tier) {
|
|
149
|
+
const user = String(name ?? '').toLowerCase();
|
|
150
|
+
const records = this.accounts();
|
|
151
|
+
const index = records.findIndex((entry) => entry.name === user);
|
|
152
|
+
if (index < 0) throw new Error(`没有账号 ${user}`);
|
|
153
|
+
const accounts = records.slice();
|
|
154
|
+
accounts[index] = { ...records[index], tier: normalizeTier(tier) };
|
|
155
|
+
await this.store.patch({ remote: { accounts } });
|
|
156
|
+
return { ok: true, accounts: accounts.map(publicAccount) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async removeAccount(name) {
|
|
160
|
+
const user = String(name ?? '').toLowerCase();
|
|
161
|
+
const accounts = this.accounts().filter((entry) => entry.name !== user);
|
|
162
|
+
if (accounts.length === this.accounts().length) throw new Error(`没有账号 ${user}`);
|
|
163
|
+
const sessions = (this.settings.sessions ?? []).filter((entry) => entry.user !== user);
|
|
164
|
+
await this.store.patch({ remote: { accounts, sessions } });
|
|
165
|
+
return { ok: true, accounts: accounts.map(publicAccount) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async setPassword(name, password) {
|
|
169
|
+
const user = String(name ?? '').toLowerCase();
|
|
170
|
+
if (String(password ?? '').length < 8) throw new Error('密码至少 8 位');
|
|
171
|
+
const records = this.accounts();
|
|
172
|
+
const index = records.findIndex((entry) => entry.name === user);
|
|
173
|
+
if (index < 0) throw new Error(`没有账号 ${user}`);
|
|
174
|
+
const hashed = await hashPassword(String(password));
|
|
175
|
+
const accounts = records.slice();
|
|
176
|
+
accounts[index] = { ...records[index], ...hashed };
|
|
177
|
+
// A password change invalidates that user's sessions: the reason to change
|
|
178
|
+
// it is usually that somebody else has the old one.
|
|
179
|
+
const sessions = (this.settings.sessions ?? []).filter((entry) => entry.user !== user);
|
|
180
|
+
await this.store.patch({ remote: { accounts, sessions } });
|
|
181
|
+
return { ok: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Verify a login: password first, then the pairing code. Failures are
|
|
186
|
+
* throttled per user so the port cannot be used as a password oracle.
|
|
187
|
+
*/
|
|
188
|
+
async login(name, password, code, meta = {}) {
|
|
189
|
+
const user = String(name ?? '').trim().toLowerCase();
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
const failures = (this.settings.failures ?? []).filter((entry) => now - entry.at < FAILURE_WINDOW_MS);
|
|
192
|
+
const recent = failures.filter((entry) => entry.user === user);
|
|
193
|
+
if (recent.length >= MAX_FAILURES) {
|
|
194
|
+
return { ok: false, error: `失败次数过多,请 ${Math.ceil((FAILURE_WINDOW_MS - (now - recent[0].at)) / 60000)} 分钟后再试` };
|
|
195
|
+
}
|
|
196
|
+
const record = this.accounts().find((entry) => entry.name === user);
|
|
197
|
+
const passwordOk = record !== undefined && (await verifyPassword(String(password ?? ''), record));
|
|
198
|
+
// The second factor is a switch, not a fact of life: on a home LAN the
|
|
199
|
+
// owner may want password-only access, and then the code field is not
|
|
200
|
+
// merely optional, it is not checked at all.
|
|
201
|
+
const requireCode = this.settings.requireCode !== false;
|
|
202
|
+
let codeOk = true;
|
|
203
|
+
if (requireCode) {
|
|
204
|
+
const secret = await this.ensureSecret();
|
|
205
|
+
codeOk = matchesPairingCode(secret, code, now);
|
|
206
|
+
}
|
|
207
|
+
if (!passwordOk || !codeOk) {
|
|
208
|
+
failures.push({ user, at: now });
|
|
209
|
+
await this.store.patch({ remote: { failures } });
|
|
210
|
+
// One message for both halves: telling a caller which factor was wrong
|
|
211
|
+
// turns the pairing code into a search target.
|
|
212
|
+
if (!passwordOk) return { ok: false, error: '账号或密码不正确' };
|
|
213
|
+
return { ok: false, error: '配对码不正确或已过期' };
|
|
214
|
+
}
|
|
215
|
+
const token = randomBytes(32).toString('base64url');
|
|
216
|
+
const session = {
|
|
217
|
+
token,
|
|
218
|
+
user,
|
|
219
|
+
role: record.role ?? 'viewer',
|
|
220
|
+
// The tier is copied onto the session so a running browser keeps the
|
|
221
|
+
// ceiling it logged in with even if an admin edits the account; the
|
|
222
|
+
// next login picks up the new one.
|
|
223
|
+
tier: normalizeTier(record.tier),
|
|
224
|
+
createdAt: now,
|
|
225
|
+
seenAt: now,
|
|
226
|
+
ip: meta.ip ?? '',
|
|
227
|
+
agent: String(meta.agent ?? '').slice(0, 120),
|
|
228
|
+
};
|
|
229
|
+
const sessions = [...(this.settings.sessions ?? []), session].filter(
|
|
230
|
+
(entry) => now - entry.createdAt < SESSION_MAX_MS,
|
|
231
|
+
);
|
|
232
|
+
await this.store.patch({ remote: { sessions, failures: failures.filter((entry) => entry.user !== user) } });
|
|
233
|
+
return { ok: true, token, session: publicSession(session) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
sessions() {
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
return (this.settings.sessions ?? []).filter(
|
|
239
|
+
(entry) => now - entry.seenAt < SESSION_IDLE_MS && now - entry.createdAt < SESSION_MAX_MS,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Persist one flag on a session record.
|
|
245
|
+
*
|
|
246
|
+
* The admin eye used to live in an in-process Map keyed by token, which meant "刷新了就
|
|
247
|
+
* 没了" whenever the page's read-back raced the write, and it vanished entirely on a host
|
|
248
|
+
* restart. The session record is already written to settings.json for the sliding
|
|
249
|
+
* `seenAt`, so storing the flag there costs one small object and survives everything a
|
|
250
|
+
* session survives — which is exactly the lifetime an elevated view should have.
|
|
251
|
+
*
|
|
252
|
+
* A logout still drops it, because logout deletes the session row.
|
|
253
|
+
*/
|
|
254
|
+
async patchSession(token, patch) {
|
|
255
|
+
if (typeof token !== 'string' || token === '') return undefined;
|
|
256
|
+
const sessions = (this.settings.sessions ?? []).map((entry) =>
|
|
257
|
+
entry.token === token ? { ...entry, ...patch } : entry,
|
|
258
|
+
);
|
|
259
|
+
await this.store.patch({ remote: { sessions } });
|
|
260
|
+
return sessions.find((entry) => entry.token === token);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Resolve a cookie token; refreshes the idle timer at most once a minute. */
|
|
264
|
+
async touch(token) {
|
|
265
|
+
if (typeof token !== 'string' || token === '') return undefined;
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
const session = this.sessions().find((entry) => entry.token === token);
|
|
268
|
+
if (session === undefined) return undefined;
|
|
269
|
+
if (now - session.seenAt > 60_000) {
|
|
270
|
+
const sessions = (this.settings.sessions ?? []).map((entry) =>
|
|
271
|
+
entry.token === token ? { ...entry, seenAt: now } : entry,
|
|
272
|
+
);
|
|
273
|
+
await this.store.patch({ remote: { sessions } });
|
|
274
|
+
}
|
|
275
|
+
return session;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async logout(token) {
|
|
279
|
+
const sessions = (this.settings.sessions ?? []).filter((entry) => entry.token !== token);
|
|
280
|
+
await this.store.patch({ remote: { sessions } });
|
|
281
|
+
return { ok: true };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async revokeAll() {
|
|
285
|
+
await this.store.patch({ remote: { sessions: [] } });
|
|
286
|
+
return { ok: true };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function publicAccount(record) {
|
|
291
|
+
return { name: record.name, role: record.role ?? 'viewer', tier: normalizeTier(record.tier), createdAt: record.createdAt ?? null };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function publicSession(record) {
|
|
295
|
+
return { user: record.user, role: record.role, tier: normalizeTier(record.tier), createdAt: record.createdAt, ip: record.ip };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export { SESSION_IDLE_MS, SESSION_MAX_MS, CODE_DIGITS };
|
|
299
|
+
|
|
300
|
+
/** Parse a Cookie header without a dependency. */
|
|
301
|
+
export function readCookie(header, name) {
|
|
302
|
+
if (typeof header !== 'string') return undefined;
|
|
303
|
+
for (const part of header.split(';')) {
|
|
304
|
+
const index = part.indexOf('=');
|
|
305
|
+
if (index < 0) continue;
|
|
306
|
+
if (part.slice(0, index).trim() === name) return decodeURIComponent(part.slice(index + 1).trim());
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function cookieHeader(name, value, maxAgeSec) {
|
|
312
|
+
const bits = [`${name}=${encodeURIComponent(value)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
|
|
313
|
+
if (maxAgeSec !== undefined) bits.push(`Max-Age=${maxAgeSec}`);
|
|
314
|
+
return bits.join('; ');
|
|
315
|
+
}
|