@mangoc/dsh-sub2api-sync 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +149 -2
- package/README.zh.md +120 -0
- package/cordis.patch.yml +10 -0
- package/data/models-dev.snapshot.json +1 -0
- package/lib/client.cjs +2416 -0
- package/lib/client.d.ts +49 -0
- package/lib/client.js +2425 -0
- package/lib/config.d.ts +111 -0
- package/lib/engine.d.ts +128 -0
- package/lib/enrich.d.ts +104 -0
- package/lib/icon.d.ts +50 -0
- package/lib/index.d.ts +31 -0
- package/lib/index.js +2872 -0
- package/lib/oauth.d.ts +128 -0
- package/lib/panel.d.ts +203 -0
- package/lib/protocols.d.ts +125 -0
- package/lib/routes.d.ts +256 -0
- package/lib/types.d.ts +151 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +124 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,2872 @@
|
|
|
1
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
//#region src/types.ts
|
|
8
|
+
const PLATFORMS = [
|
|
9
|
+
"anthropic",
|
|
10
|
+
"openai",
|
|
11
|
+
"gemini",
|
|
12
|
+
"antigravity",
|
|
13
|
+
"grok",
|
|
14
|
+
"kimi",
|
|
15
|
+
"zhipu",
|
|
16
|
+
"deepseek",
|
|
17
|
+
"composite"
|
|
18
|
+
];
|
|
19
|
+
const DEFAULT_PROVIDER_NAME_TEMPLATE = "{siteName}({keyName})";
|
|
20
|
+
const API_PROTOCOLS = [
|
|
21
|
+
"openai-completions",
|
|
22
|
+
"openai-responses",
|
|
23
|
+
"anthropic-messages"
|
|
24
|
+
];
|
|
25
|
+
const ROUTE_PREFIX = "sub2api-";
|
|
26
|
+
/** A new-shape route suffix: 10 lowercase hex chars (40-bit SHA-256 truncation). */
|
|
27
|
+
const SLUG_ROUTE = /^sub2api-[0-9a-f]{10}$/;
|
|
28
|
+
/** A legacy route suffix: the raw panel key id (pre-rev.5 deployments). */
|
|
29
|
+
const LEGACY_ROUTE = /^sub2api-\d+$/;
|
|
30
|
+
const OWNED_ROUTE = /^(?:sub2api-[0-9a-f]{10}|sub2api-\d+)$/;
|
|
31
|
+
function isOwnedRoute(route) {
|
|
32
|
+
return OWNED_ROUTE.test(route);
|
|
33
|
+
}
|
|
34
|
+
function sha256Hex(input) {
|
|
35
|
+
const data = new TextEncoder().encode(input);
|
|
36
|
+
const bitLength = data.length * 8;
|
|
37
|
+
const padded = new Uint8Array((data.length + 8 >> 6) + 1 << 6);
|
|
38
|
+
padded.set(data);
|
|
39
|
+
padded[data.length] = 128;
|
|
40
|
+
const view = new DataView(padded.buffer);
|
|
41
|
+
view.setUint32(padded.length - 8, Math.floor(bitLength / 4294967296));
|
|
42
|
+
view.setUint32(padded.length - 4, bitLength >>> 0);
|
|
43
|
+
let h0 = 1779033703, h1 = 3144134277, h2 = 1013904242, h3 = 2773480762;
|
|
44
|
+
let h4 = 1359893119, h5 = 2600822924, h6 = 528734635, h7 = 1541459225;
|
|
45
|
+
const w = new Uint32Array(64);
|
|
46
|
+
const rotr = (x, n) => (x >>> n | x << 32 - n) >>> 0;
|
|
47
|
+
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
48
|
+
for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
|
|
49
|
+
for (let i = 16; i < 64; i++) {
|
|
50
|
+
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ w[i - 15] >>> 3;
|
|
51
|
+
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ w[i - 2] >>> 10;
|
|
52
|
+
w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
|
|
53
|
+
}
|
|
54
|
+
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
|
|
55
|
+
for (let i = 0; i < 64; i++) {
|
|
56
|
+
const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
57
|
+
const ch = e & f ^ ~e & g;
|
|
58
|
+
const t1 = h + s1 + ch + SHA256_K[i] + w[i] >>> 0;
|
|
59
|
+
const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
60
|
+
const maj = a & b ^ a & c ^ b & c;
|
|
61
|
+
const t2 = s0 + maj >>> 0;
|
|
62
|
+
h = g;
|
|
63
|
+
g = f;
|
|
64
|
+
f = e;
|
|
65
|
+
e = d + t1 >>> 0;
|
|
66
|
+
d = c;
|
|
67
|
+
c = b;
|
|
68
|
+
b = a;
|
|
69
|
+
a = t1 + t2 >>> 0;
|
|
70
|
+
}
|
|
71
|
+
h0 = h0 + a >>> 0;
|
|
72
|
+
h1 = h1 + b >>> 0;
|
|
73
|
+
h2 = h2 + c >>> 0;
|
|
74
|
+
h3 = h3 + d >>> 0;
|
|
75
|
+
h4 = h4 + e >>> 0;
|
|
76
|
+
h5 = h5 + f >>> 0;
|
|
77
|
+
h6 = h6 + g >>> 0;
|
|
78
|
+
h7 = h7 + h >>> 0;
|
|
79
|
+
}
|
|
80
|
+
return [
|
|
81
|
+
h0,
|
|
82
|
+
h1,
|
|
83
|
+
h2,
|
|
84
|
+
h3,
|
|
85
|
+
h4,
|
|
86
|
+
h5,
|
|
87
|
+
h6,
|
|
88
|
+
h7
|
|
89
|
+
].map((word) => word.toString(16).padStart(8, "0")).join("");
|
|
90
|
+
}
|
|
91
|
+
/** Round constants for SHA-256 (FIPS 180-4 §4.2.2). */
|
|
92
|
+
const SHA256_K = new Uint32Array([
|
|
93
|
+
1116352408,
|
|
94
|
+
1899447441,
|
|
95
|
+
3049323471,
|
|
96
|
+
3921009573,
|
|
97
|
+
961987163,
|
|
98
|
+
1508970993,
|
|
99
|
+
2453635748,
|
|
100
|
+
2870763221,
|
|
101
|
+
3624381080,
|
|
102
|
+
310598401,
|
|
103
|
+
607225278,
|
|
104
|
+
1426881987,
|
|
105
|
+
1925078388,
|
|
106
|
+
2162078206,
|
|
107
|
+
2614888103,
|
|
108
|
+
3248222580,
|
|
109
|
+
3835390401,
|
|
110
|
+
4022224774,
|
|
111
|
+
264347078,
|
|
112
|
+
604807628,
|
|
113
|
+
770255983,
|
|
114
|
+
1249150122,
|
|
115
|
+
1555081692,
|
|
116
|
+
1996064986,
|
|
117
|
+
2554220882,
|
|
118
|
+
2821834349,
|
|
119
|
+
2952996808,
|
|
120
|
+
3210313671,
|
|
121
|
+
3336571891,
|
|
122
|
+
3584528711,
|
|
123
|
+
113926993,
|
|
124
|
+
338241895,
|
|
125
|
+
666307205,
|
|
126
|
+
773529912,
|
|
127
|
+
1294757372,
|
|
128
|
+
1396182291,
|
|
129
|
+
1695183700,
|
|
130
|
+
1986661051,
|
|
131
|
+
2177026350,
|
|
132
|
+
2456956037,
|
|
133
|
+
2730485921,
|
|
134
|
+
2820302411,
|
|
135
|
+
3259730800,
|
|
136
|
+
3345764771,
|
|
137
|
+
3516065817,
|
|
138
|
+
3600352804,
|
|
139
|
+
4094571909,
|
|
140
|
+
275423344,
|
|
141
|
+
430227734,
|
|
142
|
+
506948616,
|
|
143
|
+
659060556,
|
|
144
|
+
883997877,
|
|
145
|
+
958139571,
|
|
146
|
+
1322822218,
|
|
147
|
+
1537002063,
|
|
148
|
+
1747873779,
|
|
149
|
+
1955562222,
|
|
150
|
+
2024104815,
|
|
151
|
+
2227730452,
|
|
152
|
+
2361852424,
|
|
153
|
+
2428436474,
|
|
154
|
+
2756734187,
|
|
155
|
+
3204031479,
|
|
156
|
+
3329325298
|
|
157
|
+
]);
|
|
158
|
+
function keyRouteSlug(baseURL, keyId) {
|
|
159
|
+
return sha256Hex(`${instanceKey(baseURL)}\n${keyId}`).slice(0, 10);
|
|
160
|
+
}
|
|
161
|
+
function routeForKey(baseURL, keyId) {
|
|
162
|
+
return `${ROUTE_PREFIX}${keyRouteSlug(baseURL, keyId)}`;
|
|
163
|
+
}
|
|
164
|
+
function suffixOfRoute(route) {
|
|
165
|
+
if (SLUG_ROUTE.test(route) || LEGACY_ROUTE.test(route)) return route.slice(ROUTE_PREFIX.length);
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
function legacyKeyIdOfRoute(route) {
|
|
169
|
+
if (!LEGACY_ROUTE.test(route)) return undefined;
|
|
170
|
+
const id = Number(route.slice(ROUTE_PREFIX.length));
|
|
171
|
+
return Number.isSafeInteger(id) && id > 0 ? id : undefined;
|
|
172
|
+
}
|
|
173
|
+
function hashString(input) {
|
|
174
|
+
let hash = 2166136261;
|
|
175
|
+
for (let index = 0; index < input.length; index++) {
|
|
176
|
+
hash ^= input.charCodeAt(index);
|
|
177
|
+
hash = Math.imul(hash, 16777619);
|
|
178
|
+
}
|
|
179
|
+
return hash >>> 0 || 1;
|
|
180
|
+
}
|
|
181
|
+
function instanceKey(baseURL) {
|
|
182
|
+
return baseURL.trim().replace(/\/+$/, "").toLowerCase();
|
|
183
|
+
}
|
|
184
|
+
function panelTokenRef(baseURL) {
|
|
185
|
+
return `DSH_SUB2API_TOKEN_${hashString(instanceKey(baseURL))}`;
|
|
186
|
+
}
|
|
187
|
+
function panelRefreshRef(baseURL) {
|
|
188
|
+
return `DSH_SUB2API_REFRESH_${hashString(instanceKey(baseURL))}`;
|
|
189
|
+
}
|
|
190
|
+
function panelPasswordRef(baseURL) {
|
|
191
|
+
return `DSH_SUB2API_PASSWORD_${hashString(instanceKey(baseURL))}`;
|
|
192
|
+
}
|
|
193
|
+
function oauthTokenRef(baseURL) {
|
|
194
|
+
return `DSH_SUB2API_OAUTH_TOKEN_${hashString(instanceKey(baseURL))}`;
|
|
195
|
+
}
|
|
196
|
+
function oauthRefreshRef(baseURL) {
|
|
197
|
+
return `DSH_SUB2API_OAUTH_REFRESH_${hashString(instanceKey(baseURL))}`;
|
|
198
|
+
}
|
|
199
|
+
function routeKeyRef(route) {
|
|
200
|
+
const suffix = suffixOfRoute(route);
|
|
201
|
+
if (suffix === undefined) throw new Error(`sub2api-sync: "${route}" 不是本插件的路由名`);
|
|
202
|
+
return `DSH_SUB2API_KEY_${suffix}`;
|
|
203
|
+
}
|
|
204
|
+
function legacyKeyRef(keyId) {
|
|
205
|
+
return `DSH_SUB2API_KEY_${keyId}`;
|
|
206
|
+
}
|
|
207
|
+
function parseGroupId(input) {
|
|
208
|
+
if (typeof input === "number") return Number.isSafeInteger(input) && input > 0 ? input : undefined;
|
|
209
|
+
if (typeof input !== "string") return undefined;
|
|
210
|
+
if (!/^\d+$/.test(input)) return undefined;
|
|
211
|
+
const id = Number(input);
|
|
212
|
+
return Number.isSafeInteger(id) && id > 0 ? id : undefined;
|
|
213
|
+
}
|
|
214
|
+
function isSettingsConflict(error) {
|
|
215
|
+
const shape = error;
|
|
216
|
+
return shape?.code === "SETTINGS_CONFLICT" || shape?.name === "SettingsConflictError";
|
|
217
|
+
}
|
|
218
|
+
async function mutateWithRetry(settings, ns, buildOps, options = {}) {
|
|
219
|
+
const maxAttempts = options.attempts ?? 3;
|
|
220
|
+
for (let attempt = 1;; attempt++) {
|
|
221
|
+
const ops = await buildOps();
|
|
222
|
+
const descriptor = settings.describe().find((candidate) => candidate.ns === ns);
|
|
223
|
+
if (descriptor === undefined) throw new Error(options.missingError ?? `sub2api-sync: 设置命名空间 "${ns}" 未注册`);
|
|
224
|
+
if (ops.length === 0) return;
|
|
225
|
+
try {
|
|
226
|
+
await settings.mutate(ns, ops, descriptor.revision);
|
|
227
|
+
return;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (attempt < maxAttempts && isSettingsConflict(error)) continue;
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/protocols.ts
|
|
237
|
+
const SUB2API_RETRY_POLICY = {
|
|
238
|
+
mode: "normal",
|
|
239
|
+
maxRetries: 5,
|
|
240
|
+
retryableCodes: [
|
|
241
|
+
"RATE_LIMIT",
|
|
242
|
+
"SERVER",
|
|
243
|
+
"TIMEOUT",
|
|
244
|
+
"TRANSPORT",
|
|
245
|
+
"EMPTY_RESPONSE"
|
|
246
|
+
],
|
|
247
|
+
backoff: {
|
|
248
|
+
initialDelayMs: 1e3,
|
|
249
|
+
maxDelayMs: 12e4,
|
|
250
|
+
jitterRatio: .2
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
function guessPlatformFromModels(modelIds) {
|
|
254
|
+
const families = new Set();
|
|
255
|
+
for (const raw of modelIds) {
|
|
256
|
+
const id = raw.toLowerCase();
|
|
257
|
+
if (/(^|[/:.-])claude/.test(id)) families.add("anthropic");
|
|
258
|
+
else if (/(^|[/:.-])(gpt|o[1345]|codex|chatgpt)/.test(id)) families.add("openai");
|
|
259
|
+
else if (/(^|[/:.-])grok/.test(id)) families.add("grok");
|
|
260
|
+
else if (/(^|[/:.-])gemini/.test(id)) families.add("gemini");
|
|
261
|
+
else if (/(^|[/:.-])(kimi|moonshot)/.test(id) || /(^|[/:.-])k[23]($|[.:.-])/.test(id)) families.add("kimi");
|
|
262
|
+
else if (/(^|[/:.-])(glm|chatglm|cogview|cogvideo)/.test(id)) families.add("zhipu");
|
|
263
|
+
else if (/(^|[/:.-])deepseek/.test(id)) families.add("deepseek");
|
|
264
|
+
else families.add("unknown");
|
|
265
|
+
}
|
|
266
|
+
const known = [...families].filter((family) => family !== "unknown");
|
|
267
|
+
if (known.length === 0) return undefined;
|
|
268
|
+
return known.length === 1 ? known[0] : "composite";
|
|
269
|
+
}
|
|
270
|
+
function protocolForPlatform(platform) {
|
|
271
|
+
switch (platform) {
|
|
272
|
+
case "anthropic": return "anthropic-messages";
|
|
273
|
+
case "openai":
|
|
274
|
+
case "grok":
|
|
275
|
+
case "kimi":
|
|
276
|
+
case "zhipu":
|
|
277
|
+
case "deepseek": return "openai-responses";
|
|
278
|
+
case "gemini":
|
|
279
|
+
case "antigravity":
|
|
280
|
+
case "composite": return "openai-completions";
|
|
281
|
+
default: return undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function renderProviderName(template, keyName, siteName) {
|
|
285
|
+
const site = siteName !== undefined && siteName.length > 0 ? siteName : "sub2api";
|
|
286
|
+
if (template !== undefined && template.length > 0 && (template.includes("{keyName}") || template.includes("{siteName}"))) return template.replaceAll("{siteName}", site).replaceAll("{keyName}", keyName);
|
|
287
|
+
return `${site}(${keyName})`;
|
|
288
|
+
}
|
|
289
|
+
function baseURLForProtocol(instance, protocol) {
|
|
290
|
+
const cleaned = instance.trim().replace(/\/+$/, "");
|
|
291
|
+
if (protocol === "anthropic-messages") return cleaned.replace(/\/v1$/i, "");
|
|
292
|
+
return /\/v1$/i.test(cleaned) ? cleaned : `${cleaned}/v1`;
|
|
293
|
+
}
|
|
294
|
+
const THINKING_LEVELS = [
|
|
295
|
+
"off",
|
|
296
|
+
"minimal",
|
|
297
|
+
"low",
|
|
298
|
+
"medium",
|
|
299
|
+
"high",
|
|
300
|
+
"xhigh",
|
|
301
|
+
"max"
|
|
302
|
+
];
|
|
303
|
+
function buildProviderProfile(input) {
|
|
304
|
+
const compat = input.template?.compat;
|
|
305
|
+
const reasoning = input.template?.reasoningEfforts;
|
|
306
|
+
const override = input.retryPolicy ?? {};
|
|
307
|
+
const retryPolicy = {
|
|
308
|
+
...SUB2API_RETRY_POLICY,
|
|
309
|
+
...override,
|
|
310
|
+
retryableCodes: [...override.retryableCodes ?? SUB2API_RETRY_POLICY.retryableCodes],
|
|
311
|
+
backoff: {
|
|
312
|
+
...SUB2API_RETRY_POLICY.backoff,
|
|
313
|
+
...override.backoff
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
return {
|
|
317
|
+
displayName: input.displayName.length > 0 ? input.displayName : `Group ${input.groupId}`,
|
|
318
|
+
apiKeyEnv: input.apiKeyEnv,
|
|
319
|
+
api: input.protocol,
|
|
320
|
+
baseURL: baseURLForProtocol(input.instance, input.protocol),
|
|
321
|
+
models: input.models.map((model) => ({
|
|
322
|
+
id: model.id,
|
|
323
|
+
...model.name !== undefined ? { name: model.name } : {},
|
|
324
|
+
...model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {},
|
|
325
|
+
...model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {},
|
|
326
|
+
...model.imageInput === true ? { input: ["text", "image"] } : {},
|
|
327
|
+
...compat !== undefined ? { compat: { ...compat } } : {},
|
|
328
|
+
...reasoning === false ? { reasoningEfforts: false } : reasoning !== undefined ? { reasoningEfforts: { ...reasoning } } : model.reasoningEfforts !== undefined ? { reasoningEfforts: { ...model.reasoningEfforts } } : {}
|
|
329
|
+
})),
|
|
330
|
+
...input.template?.reasoning !== undefined ? { reasoning: input.template.reasoning } : {},
|
|
331
|
+
defaultContextWindow: input.defaultContextWindow ?? 128e3,
|
|
332
|
+
defaultMaxTokens: input.defaultMaxTokens ?? 8192,
|
|
333
|
+
defaultInput: ["text"],
|
|
334
|
+
retryPolicy
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region src/config.ts
|
|
340
|
+
const apiProtocolSchema = z.union([
|
|
341
|
+
z.const("openai-completions"),
|
|
342
|
+
z.const("openai-responses"),
|
|
343
|
+
z.const("anthropic-messages")
|
|
344
|
+
]);
|
|
345
|
+
const Config = z.object({
|
|
346
|
+
baseURL: z.string().volatile(),
|
|
347
|
+
email: z.string().volatile(),
|
|
348
|
+
providerNameTemplate: z.string().default(DEFAULT_PROVIDER_NAME_TEMPLATE).volatile(),
|
|
349
|
+
selectedKeys: z.array(z.number().step(1)).default([]).volatile(),
|
|
350
|
+
modelListOverrides: z.dict(z.array(z.string())).default({}).volatile(),
|
|
351
|
+
protocolOverrides: z.dict(apiProtocolSchema).default({}).volatile(),
|
|
352
|
+
routeKeyIds: z.dict(z.number().step(1)).default({}).volatile(),
|
|
353
|
+
compatTemplate: z.object({
|
|
354
|
+
compat: z.dict(z.any()),
|
|
355
|
+
reasoningEfforts: z.union([z.const(false), z.dict(z.union([z.string(), z.const(null)]))]),
|
|
356
|
+
reasoning: z.union([
|
|
357
|
+
z.const("off"),
|
|
358
|
+
z.const("minimal"),
|
|
359
|
+
z.const("low"),
|
|
360
|
+
z.const("medium"),
|
|
361
|
+
z.const("high"),
|
|
362
|
+
z.const("xhigh"),
|
|
363
|
+
z.const("max")
|
|
364
|
+
])
|
|
365
|
+
}).volatile(),
|
|
366
|
+
enrichModelMeta: z.boolean().default(true).volatile(),
|
|
367
|
+
keyQuota: z.number().volatile(),
|
|
368
|
+
keyExpiresInDays: z.number().step(1).volatile(),
|
|
369
|
+
deleteOrphanKeys: z.boolean().default(false).volatile(),
|
|
370
|
+
syncIntervalMs: z.number().step(1).volatile(),
|
|
371
|
+
retryPolicy: z.object({
|
|
372
|
+
mode: z.union([z.const("normal"), z.const("always")]),
|
|
373
|
+
maxRetries: z.number().step(1),
|
|
374
|
+
retryableCodes: z.array(z.string()),
|
|
375
|
+
backoff: z.object({
|
|
376
|
+
initialDelayMs: z.number(),
|
|
377
|
+
maxDelayMs: z.number(),
|
|
378
|
+
jitterRatio: z.number()
|
|
379
|
+
})
|
|
380
|
+
}).volatile(),
|
|
381
|
+
defaultContextWindow: z.number().step(1).volatile(),
|
|
382
|
+
defaultMaxTokens: z.number().step(1).volatile(),
|
|
383
|
+
allowRemoteActions: z.boolean().default(false).volatile()
|
|
384
|
+
});
|
|
385
|
+
/** Unwrap one live cell into its current snapshot; plain values pass through. */
|
|
386
|
+
function unwrap$1(field) {
|
|
387
|
+
if (field !== null && typeof field === "object" && typeof field.get === "function") return field.get();
|
|
388
|
+
return field;
|
|
389
|
+
}
|
|
390
|
+
function readConfig(config) {
|
|
391
|
+
return Object.fromEntries(Object.entries(config).map(([key, field]) => [key, unwrap$1(field)]));
|
|
392
|
+
}
|
|
393
|
+
function validateConfig(config) {
|
|
394
|
+
if (config.baseURL !== undefined && config.baseURL.length > 0 && !/^https?:\/\//i.test(config.baseURL.trim())) throw new Error("sub2api-sync: baseURL 必须以 http(s):// 开头");
|
|
395
|
+
const compat = config.compatTemplate;
|
|
396
|
+
if (compat?.reasoning !== undefined && !THINKING_LEVELS.includes(compat.reasoning)) throw new Error(`sub2api-sync: compatTemplate.reasoning 必须是 ${THINKING_LEVELS.join("/")} 之一`);
|
|
397
|
+
const template = config.providerNameTemplate;
|
|
398
|
+
if (template !== undefined && template.length > 0 && !template.includes("{keyName}") && !template.includes("{siteName}")) throw new Error("sub2api-sync: providerNameTemplate 必须包含 {keyName} 或 {siteName} 占位符");
|
|
399
|
+
for (const id of config.selectedKeys ?? []) if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`sub2api-sync: selectedKeys 含非法 key id ${String(id)}`);
|
|
400
|
+
if (config.syncIntervalMs !== undefined && config.syncIntervalMs < 6e4) throw new Error("sub2api-sync: syncIntervalMs 定时同步间隔不能小于 60000ms(删除该字段即关闭定时同步)");
|
|
401
|
+
if (config.keyQuota !== undefined && config.keyQuota < 0) throw new Error("sub2api-sync: keyQuota 不能为负数");
|
|
402
|
+
if (config.keyExpiresInDays !== undefined && (!Number.isSafeInteger(config.keyExpiresInDays) || config.keyExpiresInDays < 1)) throw new Error("sub2api-sync: keyExpiresInDays 必须是 ≥1 的整数");
|
|
403
|
+
if (config.defaultContextWindow !== undefined && (!Number.isSafeInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) throw new Error("sub2api-sync: defaultContextWindow 必须是正整数");
|
|
404
|
+
if (config.defaultMaxTokens !== undefined && (!Number.isSafeInteger(config.defaultMaxTokens) || config.defaultMaxTokens <= 0)) throw new Error("sub2api-sync: defaultMaxTokens 必须是正整数");
|
|
405
|
+
const retry = config.retryPolicy;
|
|
406
|
+
if (retry !== undefined) {
|
|
407
|
+
if (retry.mode !== undefined && retry.mode !== "normal" && retry.mode !== "always") throw new Error("sub2api-sync: retryPolicy.mode 必须是 normal 或 always");
|
|
408
|
+
if (retry.maxRetries !== undefined && (!Number.isSafeInteger(retry.maxRetries) || retry.maxRetries < 0)) throw new Error("sub2api-sync: retryPolicy.maxRetries 必须是非负整数");
|
|
409
|
+
const backoff = retry.backoff;
|
|
410
|
+
if (backoff !== undefined) {
|
|
411
|
+
if (backoff.initialDelayMs !== undefined && (!Number.isSafeInteger(backoff.initialDelayMs) || backoff.initialDelayMs < 0)) throw new Error("sub2api-sync: retryPolicy.backoff.initialDelayMs 必须是非负整数");
|
|
412
|
+
if (backoff.maxDelayMs !== undefined && (!Number.isSafeInteger(backoff.maxDelayMs) || backoff.maxDelayMs < 0)) throw new Error("sub2api-sync: retryPolicy.backoff.maxDelayMs 必须是非负整数");
|
|
413
|
+
if (backoff.jitterRatio !== undefined && (backoff.jitterRatio < 0 || backoff.jitterRatio > 1)) throw new Error("sub2api-sync: retryPolicy.backoff.jitterRatio 必须在 [0, 1] 之间");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region src/client.ts
|
|
420
|
+
function gatewayApiRoot(baseURL) {
|
|
421
|
+
const cleaned = (baseURL ?? "").trim().replace(/\/+$/, "");
|
|
422
|
+
if (cleaned.length === 0) return "";
|
|
423
|
+
return /\/v1$/i.test(cleaned) ? cleaned : `${cleaned}/v1`;
|
|
424
|
+
}
|
|
425
|
+
var Sub2apiClient = class {
|
|
426
|
+
root;
|
|
427
|
+
fetchImpl;
|
|
428
|
+
constructor(options) {
|
|
429
|
+
const base = options.baseURL?.trim() ?? "";
|
|
430
|
+
if (!/^https?:\/\//i.test(base)) throw new Error("sub2api: baseURL 必须是 http(s) 地址");
|
|
431
|
+
this.root = gatewayApiRoot(base);
|
|
432
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
433
|
+
if (fetchImpl === undefined) throw new Error("sub2api: 当前环境没有 fetch 可用");
|
|
434
|
+
this.fetchImpl = fetchImpl.bind(globalThis);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Discover the model ids one relay key can serve (`GET /v1/models`). The
|
|
438
|
+
* gateway answers an OpenAI-style list whose element shape varies per
|
|
439
|
+
* platform — grok embeds reasoning fields, a `client_version` query
|
|
440
|
+
* switches to the Codex manifest — so the reader takes `id` and tolerates
|
|
441
|
+
* everything else (verified: `handler/gateway_handler.go`).
|
|
442
|
+
*/
|
|
443
|
+
async listModels(relayKey, options) {
|
|
444
|
+
const response = await this.fetchImpl(`${this.root}/models`, {
|
|
445
|
+
headers: { authorization: `Bearer ${relayKey}` },
|
|
446
|
+
signal: options?.signal
|
|
447
|
+
});
|
|
448
|
+
if (!response.ok) throw new Error(`sub2api 网关 GET ${this.root}/models 返回 HTTP ${response.status}${await refusalDetail(response)}`);
|
|
449
|
+
const payload = await response.json();
|
|
450
|
+
return readModelList(payload);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
async function refusalDetail(response) {
|
|
454
|
+
let payload;
|
|
455
|
+
try {
|
|
456
|
+
payload = await response.json();
|
|
457
|
+
} catch {
|
|
458
|
+
return "";
|
|
459
|
+
}
|
|
460
|
+
if (typeof payload !== "object" || payload === null) return "";
|
|
461
|
+
const record = payload;
|
|
462
|
+
const nested = typeof record.error === "object" && record.error !== null ? record.error : undefined;
|
|
463
|
+
const message = [nested?.message, record.message].find((candidate) => typeof candidate === "string" && candidate.length > 0);
|
|
464
|
+
const code = [nested?.code, record.code].find((candidate) => typeof candidate === "string" && candidate.length > 0);
|
|
465
|
+
if (message === undefined && code === undefined) return "";
|
|
466
|
+
const label = code !== undefined && message !== undefined && message !== code ? `${code}: ${message}` : code ?? message ?? "";
|
|
467
|
+
return label.length > 0 ? `(${label})` : "";
|
|
468
|
+
}
|
|
469
|
+
function readModelList(payload) {
|
|
470
|
+
const data = payload?.data;
|
|
471
|
+
if (!Array.isArray(data)) throw new Error("sub2api 网关 /models 响应不是模型列表(缺少 data 数组)");
|
|
472
|
+
const models = [];
|
|
473
|
+
for (const item of data) {
|
|
474
|
+
if (typeof item !== "object" || item === null) continue;
|
|
475
|
+
const id = item.id;
|
|
476
|
+
if (typeof id !== "string" || id.length === 0) continue;
|
|
477
|
+
const displayName = item.display_name;
|
|
478
|
+
models.push({
|
|
479
|
+
id,
|
|
480
|
+
...typeof displayName === "string" && displayName.length > 0 && displayName !== id ? { displayName } : {}
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return models;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
//#endregion
|
|
487
|
+
//#region src/enrich.ts
|
|
488
|
+
const SNAPSHOT_PATH = join(dirname(fileURLToPath(import.meta.url)), "../data/models-dev.snapshot.json");
|
|
489
|
+
const SOURCE_URL = "https://models.dev/api.json";
|
|
490
|
+
/** How long a successful catalog fetch is trusted. */
|
|
491
|
+
const TTL = 864e5;
|
|
492
|
+
/**
|
|
493
|
+
* How long a FAILED fetch defers the next attempt. Much shorter than the
|
|
494
|
+
* success TTL on purpose: a negative cache exists so an unreachable endpoint
|
|
495
|
+
* is not hammered per sync, but a models.dev outage (or a local network
|
|
496
|
+
* blip) should self-heal within the hour instead of freezing the catalog
|
|
497
|
+
* metadata for a full day.
|
|
498
|
+
*/
|
|
499
|
+
const NEGATIVE_TTL = 36e5;
|
|
500
|
+
const EFFORT_VOCABULARY = [
|
|
501
|
+
"none",
|
|
502
|
+
"minimal",
|
|
503
|
+
"low",
|
|
504
|
+
"medium",
|
|
505
|
+
"high",
|
|
506
|
+
"xhigh",
|
|
507
|
+
"max"
|
|
508
|
+
];
|
|
509
|
+
const FIRST_PARTY_PROVIDERS = new Set([
|
|
510
|
+
"openai",
|
|
511
|
+
"anthropic",
|
|
512
|
+
"google",
|
|
513
|
+
"deepseek",
|
|
514
|
+
"xai",
|
|
515
|
+
"moonshotai",
|
|
516
|
+
"moonshotai-cn",
|
|
517
|
+
"kimi-code-plan-cn",
|
|
518
|
+
"kimi-code-plan-global",
|
|
519
|
+
"zhipuai",
|
|
520
|
+
"zhipuai-coding-plan",
|
|
521
|
+
"zai",
|
|
522
|
+
"zai-coding-plan",
|
|
523
|
+
"alibaba",
|
|
524
|
+
"alibaba-cn",
|
|
525
|
+
"alibaba-coding-plan",
|
|
526
|
+
"alibaba-coding-plan-cn",
|
|
527
|
+
"alibaba-token-plan",
|
|
528
|
+
"alibaba-token-plan-cn",
|
|
529
|
+
"minimax",
|
|
530
|
+
"minimax-cn",
|
|
531
|
+
"minimax-coding-plan",
|
|
532
|
+
"minimax-cn-coding-plan",
|
|
533
|
+
"stepfun",
|
|
534
|
+
"stepfun-ai",
|
|
535
|
+
"stepfun-step-plan",
|
|
536
|
+
"stepfun-ai-step-plan",
|
|
537
|
+
"xiaomi",
|
|
538
|
+
"xiaomi-token-plan-ams",
|
|
539
|
+
"xiaomi-token-plan-cn",
|
|
540
|
+
"xiaomi-token-plan-sgp",
|
|
541
|
+
"longcat",
|
|
542
|
+
"volcengine",
|
|
543
|
+
"volcengine-coding-plan",
|
|
544
|
+
"tencent-coding-plan",
|
|
545
|
+
"tencent-token-plan",
|
|
546
|
+
"tencent-tokenhub",
|
|
547
|
+
"mistral",
|
|
548
|
+
"cohere",
|
|
549
|
+
"ai21",
|
|
550
|
+
"upstage",
|
|
551
|
+
"sarvam",
|
|
552
|
+
"google-vertex",
|
|
553
|
+
"google-vertex-anthropic",
|
|
554
|
+
"amazon-bedrock",
|
|
555
|
+
"azure",
|
|
556
|
+
"azure-cognitive-services",
|
|
557
|
+
"snowflake-cortex",
|
|
558
|
+
"watsonx",
|
|
559
|
+
"github-copilot"
|
|
560
|
+
]);
|
|
561
|
+
/**
|
|
562
|
+
* One model's effort levels from `reasoning_options`, normalized to the
|
|
563
|
+
* vocabulary in canonical order (`none` kept — it becomes pi-ai's `off`).
|
|
564
|
+
* `undefined` when the entry carries no effort options: toggle/budget-only
|
|
565
|
+
* reasoning cannot travel through `reasoningEfforts` (its wire shape is a
|
|
566
|
+
* boolean flag or a numeric budget, not an effort string).
|
|
567
|
+
*/
|
|
568
|
+
function effortLevelsOf(model) {
|
|
569
|
+
const options = model.reasoning_options;
|
|
570
|
+
if (!Array.isArray(options)) return undefined;
|
|
571
|
+
const seen = new Set();
|
|
572
|
+
for (const option of options) {
|
|
573
|
+
if (option === null || typeof option !== "object") continue;
|
|
574
|
+
const record = option;
|
|
575
|
+
if (record.type !== "effort" || !Array.isArray(record.values)) continue;
|
|
576
|
+
for (const value of record.values) if (typeof value === "string" && EFFORT_VOCABULARY.includes(value)) seen.add(value);
|
|
577
|
+
}
|
|
578
|
+
if (seen.size === 0) return undefined;
|
|
579
|
+
return EFFORT_VOCABULARY.filter((level) => seen.has(level));
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Build the pi-ai `reasoningEfforts` map from normalized levels: same-name
|
|
583
|
+
* passthrough (pi-ai's default wire spelling), `none` → `off: null` (dispatch
|
|
584
|
+
* sends nothing). `undefined` when nothing remains beyond `off` — pi-ai rejects
|
|
585
|
+
* a map that offers no thinking level.
|
|
586
|
+
*/
|
|
587
|
+
function reasoningEffortsOf(levels) {
|
|
588
|
+
const map = {};
|
|
589
|
+
let beyondOff = false;
|
|
590
|
+
for (const level of levels) if (level === "none") map.off = null;
|
|
591
|
+
else {
|
|
592
|
+
map[level] = level;
|
|
593
|
+
beyondOff = true;
|
|
594
|
+
}
|
|
595
|
+
return beyondOff ? map : undefined;
|
|
596
|
+
}
|
|
597
|
+
function displayNameOf(raw, id) {
|
|
598
|
+
if (typeof raw !== "string") return undefined;
|
|
599
|
+
const name$1 = raw.trim().replace(/\s*\(latest\)$/i, "").trim();
|
|
600
|
+
if (name$1.length === 0) return undefined;
|
|
601
|
+
if (id !== undefined && name$1.toLowerCase() === id.toLowerCase()) return undefined;
|
|
602
|
+
return name$1;
|
|
603
|
+
}
|
|
604
|
+
/** Defensive snapshot re-normalization (already normalized by the generator). */
|
|
605
|
+
function effortsMetaOf(levels) {
|
|
606
|
+
if (!Array.isArray(levels)) return undefined;
|
|
607
|
+
const normalized = EFFORT_VOCABULARY.filter((level) => levels.includes(level));
|
|
608
|
+
return reasoningEffortsOf(normalized);
|
|
609
|
+
}
|
|
610
|
+
/** Levels every source claims, in canonical order; empty when they disagree. */
|
|
611
|
+
function intersectLevels(sources) {
|
|
612
|
+
if (sources.length === 0) return [];
|
|
613
|
+
const [first, ...rest] = sources;
|
|
614
|
+
return first.filter((level) => rest.every((source) => source.includes(level)));
|
|
615
|
+
}
|
|
616
|
+
/** Attach the resolved efforts map to an id's meta, creating the entry if new. */
|
|
617
|
+
function attachEfforts(index, key, sources) {
|
|
618
|
+
const map = reasoningEffortsOf(intersectLevels(sources));
|
|
619
|
+
if (map === undefined) return;
|
|
620
|
+
const meta = index.get(key) ?? {};
|
|
621
|
+
meta.reasoningEfforts = map;
|
|
622
|
+
index.set(key, meta);
|
|
623
|
+
}
|
|
624
|
+
let networkCache;
|
|
625
|
+
let bundledCache;
|
|
626
|
+
function parseSnapshot(raw) {
|
|
627
|
+
const index = new Map();
|
|
628
|
+
let parsed;
|
|
629
|
+
try {
|
|
630
|
+
parsed = JSON.parse(raw);
|
|
631
|
+
} catch {
|
|
632
|
+
return index;
|
|
633
|
+
}
|
|
634
|
+
const models = parsed?.models;
|
|
635
|
+
if (models === null || models === undefined || typeof models !== "object") return index;
|
|
636
|
+
for (const [id, tuple] of Object.entries(models)) {
|
|
637
|
+
if (!Array.isArray(tuple)) continue;
|
|
638
|
+
const [context, output, image, reasoning, levels, name$1] = tuple;
|
|
639
|
+
const efforts = effortsMetaOf(levels);
|
|
640
|
+
index.set(id.toLowerCase(), {
|
|
641
|
+
...typeof context === "number" && context > 0 ? { contextWindow: context } : {},
|
|
642
|
+
...typeof output === "number" && output > 0 ? { maxTokens: output } : {},
|
|
643
|
+
...image === 1 ? { imageInput: true } : {},
|
|
644
|
+
...reasoning === 1 ? { reasoningModel: true } : {},
|
|
645
|
+
...efforts !== undefined ? { reasoningEfforts: efforts } : {},
|
|
646
|
+
...typeof name$1 === "string" && name$1.length > 0 ? { name: name$1 } : {}
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
return index;
|
|
650
|
+
}
|
|
651
|
+
function parseCatalog(catalog) {
|
|
652
|
+
const index = new Map();
|
|
653
|
+
if (catalog === null || typeof catalog !== "object") return index;
|
|
654
|
+
const firstPartySources = new Map();
|
|
655
|
+
const aggregatorSources = new Map();
|
|
656
|
+
const firstPartyNames = new Map();
|
|
657
|
+
const aggregatorNames = new Map();
|
|
658
|
+
const firstPartyIds = new Set();
|
|
659
|
+
for (const [providerName, provider] of Object.entries(catalog)) {
|
|
660
|
+
if (provider === null || typeof provider !== "object") continue;
|
|
661
|
+
const models = provider.models;
|
|
662
|
+
if (models === null || typeof models !== "object") continue;
|
|
663
|
+
const firstParty = FIRST_PARTY_PROVIDERS.has(providerName);
|
|
664
|
+
for (const [id, raw] of Object.entries(models)) {
|
|
665
|
+
if (raw === null || typeof raw !== "object") continue;
|
|
666
|
+
const model = raw;
|
|
667
|
+
const limit = model.limit ?? undefined;
|
|
668
|
+
const modalities = model.modalities ?? undefined;
|
|
669
|
+
const context = limit?.context;
|
|
670
|
+
const output = limit?.output;
|
|
671
|
+
const input = modalities?.input;
|
|
672
|
+
const image = Array.isArray(input) && input.includes("image") || model.attachment === true;
|
|
673
|
+
const key = id.toLowerCase();
|
|
674
|
+
if (firstParty) firstPartyIds.add(key);
|
|
675
|
+
const levels = effortLevelsOf(model);
|
|
676
|
+
if (levels !== undefined) {
|
|
677
|
+
const sources = firstParty ? firstPartySources : aggregatorSources;
|
|
678
|
+
const group = sources.get(key);
|
|
679
|
+
if (group === undefined) sources.set(key, [levels]);
|
|
680
|
+
else group.push(levels);
|
|
681
|
+
}
|
|
682
|
+
const displayName = displayNameOf(model.name, key);
|
|
683
|
+
if (displayName !== undefined) {
|
|
684
|
+
const names = firstParty ? firstPartyNames : aggregatorNames;
|
|
685
|
+
if (!names.has(key)) names.set(key, displayName);
|
|
686
|
+
}
|
|
687
|
+
const current = index.get(key);
|
|
688
|
+
const next = {
|
|
689
|
+
...typeof context === "number" && context > 0 ? { contextWindow: context } : {},
|
|
690
|
+
...typeof output === "number" && output > 0 ? { maxTokens: output } : {},
|
|
691
|
+
...image ? { imageInput: true } : {},
|
|
692
|
+
...model.reasoning === true ? { reasoningModel: true } : {}
|
|
693
|
+
};
|
|
694
|
+
if (current === undefined) {
|
|
695
|
+
if (next.contextWindow !== undefined || next.maxTokens !== undefined || next.imageInput === true || next.reasoningModel === true) index.set(key, next);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
index.set(key, {
|
|
699
|
+
contextWindow: pickGreater(current.contextWindow, next.contextWindow),
|
|
700
|
+
maxTokens: pickGreater(current.maxTokens, next.maxTokens),
|
|
701
|
+
imageInput: current.imageInput === true || next.imageInput === true,
|
|
702
|
+
reasoningModel: current.reasoningModel === true || next.reasoningModel === true
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
for (const [key, sources] of firstPartySources) attachEfforts(index, key, sources);
|
|
707
|
+
for (const [key, sources] of aggregatorSources) if (!firstPartyIds.has(key)) attachEfforts(index, key, sources);
|
|
708
|
+
const attachName = (key, name$1, override) => {
|
|
709
|
+
const meta = index.get(key);
|
|
710
|
+
if (meta === undefined) {
|
|
711
|
+
index.set(key, { name: name$1 });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (meta.name !== undefined && !override) return;
|
|
715
|
+
meta.name = name$1;
|
|
716
|
+
index.set(key, meta);
|
|
717
|
+
};
|
|
718
|
+
for (const [key, name$1] of aggregatorNames) attachName(key, name$1, false);
|
|
719
|
+
for (const [key, name$1] of firstPartyNames) attachName(key, name$1, true);
|
|
720
|
+
return index;
|
|
721
|
+
}
|
|
722
|
+
function pickGreater(a, b) {
|
|
723
|
+
if (a === undefined) return b;
|
|
724
|
+
if (b === undefined) return a;
|
|
725
|
+
return Math.max(a, b);
|
|
726
|
+
}
|
|
727
|
+
function bundledIndex() {
|
|
728
|
+
bundledCache ??= (() => {
|
|
729
|
+
try {
|
|
730
|
+
return parseSnapshot(readFileSync(SNAPSHOT_PATH, "utf8"));
|
|
731
|
+
} catch {
|
|
732
|
+
return new Map();
|
|
733
|
+
}
|
|
734
|
+
})();
|
|
735
|
+
return bundledCache;
|
|
736
|
+
}
|
|
737
|
+
async function loadModelIndex(options) {
|
|
738
|
+
const now = (options.now ?? Date.now)();
|
|
739
|
+
if (networkCache !== undefined && now - networkCache.at < (networkCache.ok ? TTL : NEGATIVE_TTL)) return networkCache.index;
|
|
740
|
+
if (options.enrich) try {
|
|
741
|
+
const doFetch = options.fetchImpl ?? globalThis.fetch;
|
|
742
|
+
if (doFetch === undefined) throw new Error("no fetch available");
|
|
743
|
+
const response = await doFetch(SOURCE_URL);
|
|
744
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
745
|
+
const catalog = await response.json();
|
|
746
|
+
const index = parseCatalog(catalog);
|
|
747
|
+
networkCache = {
|
|
748
|
+
index,
|
|
749
|
+
at: now,
|
|
750
|
+
ok: true
|
|
751
|
+
};
|
|
752
|
+
return index;
|
|
753
|
+
} catch {}
|
|
754
|
+
const bundled = bundledIndex();
|
|
755
|
+
if (options.enrich) networkCache = {
|
|
756
|
+
index: bundled,
|
|
757
|
+
at: now,
|
|
758
|
+
ok: false
|
|
759
|
+
};
|
|
760
|
+
return bundled;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
//#endregion
|
|
764
|
+
//#region src/engine.ts
|
|
765
|
+
const PI_AI_NS = "llm-pi-ai";
|
|
766
|
+
const OWN_NS = "sub2api-sync";
|
|
767
|
+
function createSyncReportStore() {
|
|
768
|
+
let report;
|
|
769
|
+
let at;
|
|
770
|
+
return {
|
|
771
|
+
record: (value) => {
|
|
772
|
+
report = value;
|
|
773
|
+
at = Date.now();
|
|
774
|
+
},
|
|
775
|
+
clear: () => {
|
|
776
|
+
report = undefined;
|
|
777
|
+
at = undefined;
|
|
778
|
+
},
|
|
779
|
+
report: () => report,
|
|
780
|
+
at: () => at
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
var Sub2apiSyncError = class extends Error {
|
|
784
|
+
constructor(message) {
|
|
785
|
+
super(message);
|
|
786
|
+
this.name = "Sub2apiSyncError";
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
function messageOf(error) {
|
|
790
|
+
return error instanceof Error ? error.message : String(error);
|
|
791
|
+
}
|
|
792
|
+
/** Order-insensitive JSON comparison — profiles are plain JSON values. */
|
|
793
|
+
function deepEqual(a, b) {
|
|
794
|
+
if (a === b) return true;
|
|
795
|
+
if (typeof a !== typeof b) return false;
|
|
796
|
+
if (a === null || b === null) return false;
|
|
797
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
798
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
799
|
+
return a.every((item, index) => deepEqual(item, b[index]));
|
|
800
|
+
}
|
|
801
|
+
if (typeof a === "object") {
|
|
802
|
+
const left = a;
|
|
803
|
+
const right = b;
|
|
804
|
+
const leftKeys = Object.keys(left);
|
|
805
|
+
const rightKeys = Object.keys(right);
|
|
806
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
807
|
+
return leftKeys.every((key) => key in right && deepEqual(left[key], right[key]));
|
|
808
|
+
}
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
function resolveTargets(config, keyRows) {
|
|
812
|
+
const byId = new Map(keyRows.map((key) => [key.id, key]));
|
|
813
|
+
const targets = [];
|
|
814
|
+
for (const id of config.selectedKeys ?? []) {
|
|
815
|
+
const key = byId.get(id);
|
|
816
|
+
if (key === undefined) {
|
|
817
|
+
targets.push({
|
|
818
|
+
invalid: id,
|
|
819
|
+
error: `key #${id} 在面板不存在(可能已删除)——请在 Keys 卡刷新后重新勾选`
|
|
820
|
+
});
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
targets.push({ key });
|
|
824
|
+
}
|
|
825
|
+
return targets;
|
|
826
|
+
}
|
|
827
|
+
var Sub2apiSyncEngine = class {
|
|
828
|
+
settings;
|
|
829
|
+
credentials;
|
|
830
|
+
gateway;
|
|
831
|
+
fetchImpl;
|
|
832
|
+
modelIndexLoader;
|
|
833
|
+
siteName;
|
|
834
|
+
queue = Promise.resolve();
|
|
835
|
+
running = false;
|
|
836
|
+
constructor(options) {
|
|
837
|
+
this.settings = options.settings;
|
|
838
|
+
this.credentials = options.credentials;
|
|
839
|
+
this.gateway = options.gateway;
|
|
840
|
+
this.fetchImpl = options.fetchImpl;
|
|
841
|
+
this.modelIndexLoader = options.loadModelIndex ?? loadModelIndex;
|
|
842
|
+
this.siteName = options.siteName;
|
|
843
|
+
}
|
|
844
|
+
/** Whether a sync is currently executing (UI busy marker). */
|
|
845
|
+
isRunning() {
|
|
846
|
+
return this.running;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Run one sync. At most one runs at a time — later callers queue behind
|
|
850
|
+
* the previous one (the settings-card button and the timer share this
|
|
851
|
+
* queue).
|
|
852
|
+
*/
|
|
853
|
+
sync(config, keyIds) {
|
|
854
|
+
const run = this.queue.then(() => this.runSync(config, keyIds), () => this.runSync(config, keyIds));
|
|
855
|
+
this.queue = run.then(() => undefined, () => undefined);
|
|
856
|
+
return run;
|
|
857
|
+
}
|
|
858
|
+
async runSync(config, keyIds) {
|
|
859
|
+
this.running = true;
|
|
860
|
+
try {
|
|
861
|
+
return await this.syncNow(config, keyIds);
|
|
862
|
+
} finally {
|
|
863
|
+
this.running = false;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
async syncNow(config, selection) {
|
|
867
|
+
const instance = config.baseURL?.trim() ?? "";
|
|
868
|
+
if (!/^https?:\/\//i.test(instance)) throw new Sub2apiSyncError("sub2api-sync: baseURL 未配置或不是 http(s) 地址");
|
|
869
|
+
const entries = [];
|
|
870
|
+
const desired = new Map();
|
|
871
|
+
let keyWalkTruncated = false;
|
|
872
|
+
const { groups, keyRows } = await this.gateway.use(async (panel, token) => {
|
|
873
|
+
const groupList = await panel.listAvailableGroups(token);
|
|
874
|
+
const rows = [];
|
|
875
|
+
for (let page = 1; page <= 10; page++) {
|
|
876
|
+
const result = await panel.listKeys(token, {
|
|
877
|
+
page,
|
|
878
|
+
pageSize: 100
|
|
879
|
+
});
|
|
880
|
+
rows.push(...result.items);
|
|
881
|
+
if (result.pages > 10) keyWalkTruncated = true;
|
|
882
|
+
if (page >= result.pages || result.items.length === 0) break;
|
|
883
|
+
}
|
|
884
|
+
return {
|
|
885
|
+
groups: groupList,
|
|
886
|
+
keyRows: rows
|
|
887
|
+
};
|
|
888
|
+
});
|
|
889
|
+
const groupById = new Map(groups.map((group) => [group.id, group]));
|
|
890
|
+
const modelIndex = await this.modelIndexLoader({
|
|
891
|
+
enrich: config.enrichModelMeta !== false,
|
|
892
|
+
...this.fetchImpl !== undefined ? { fetchImpl: this.fetchImpl } : {}
|
|
893
|
+
}).catch(() => new Map());
|
|
894
|
+
const site = await this.siteName?.().catch(() => undefined);
|
|
895
|
+
const syncWarnings = [];
|
|
896
|
+
if (keyWalkTruncated) syncWarnings.push("面板 key 数量超过单次同步的读取上限(10 页 × 100 = 1000 把)——超出部分未参与同步,也不会被移除");
|
|
897
|
+
const effective = {
|
|
898
|
+
...config,
|
|
899
|
+
selectedKeys: selection ?? config.selectedKeys
|
|
900
|
+
};
|
|
901
|
+
for (const target of resolveTargets(effective, keyRows)) {
|
|
902
|
+
if ("invalid" in target) {
|
|
903
|
+
entries.push({
|
|
904
|
+
groupId: target.invalid,
|
|
905
|
+
route: `${target.invalid}`,
|
|
906
|
+
action: "failed",
|
|
907
|
+
error: target.error,
|
|
908
|
+
invalid: true
|
|
909
|
+
});
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
const key = target.key;
|
|
913
|
+
const route = routeForKey(instance, key.id);
|
|
914
|
+
try {
|
|
915
|
+
const group = groupById.get(key.groupId);
|
|
916
|
+
if (group === undefined) throw new Sub2apiSyncError(`key #${key.id}(${key.name})的分组 ${key.groupId} 在面板不可用`);
|
|
917
|
+
const { profile, warnings: profileWarnings } = await this.buildKeyProfile(config, key, group, instance, modelIndex, site);
|
|
918
|
+
desired.set(route, profile);
|
|
919
|
+
syncWarnings.push(...profileWarnings);
|
|
920
|
+
entries.push({
|
|
921
|
+
groupId: group.id,
|
|
922
|
+
route,
|
|
923
|
+
keyId: key.id,
|
|
924
|
+
displayName: profile.displayName,
|
|
925
|
+
action: "unchanged",
|
|
926
|
+
models: profile.models?.length ?? 0
|
|
927
|
+
});
|
|
928
|
+
} catch (error) {
|
|
929
|
+
entries.push({
|
|
930
|
+
groupId: key.groupId,
|
|
931
|
+
route,
|
|
932
|
+
keyId: key.id,
|
|
933
|
+
action: "failed",
|
|
934
|
+
error: messageOf(error)
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
let attempts = 0;
|
|
939
|
+
const removed = [];
|
|
940
|
+
let ops = [];
|
|
941
|
+
let routeActions = new Map();
|
|
942
|
+
await mutateWithRetry(this.settings, PI_AI_NS, () => {
|
|
943
|
+
attempts++;
|
|
944
|
+
const now = this.readCurrentProviders();
|
|
945
|
+
const plan = planOps(now, desired);
|
|
946
|
+
ops = plan.ops;
|
|
947
|
+
routeActions = plan.actions;
|
|
948
|
+
removed.length = 0;
|
|
949
|
+
for (const op of ops) if (op.op === "unset") removed.push(op.path[1]);
|
|
950
|
+
return ops;
|
|
951
|
+
});
|
|
952
|
+
for (const entry of entries) {
|
|
953
|
+
const action = routeActions.get(entry.route);
|
|
954
|
+
if (action !== undefined) entry.action = action;
|
|
955
|
+
}
|
|
956
|
+
const keyIdByRoute = new Map(keyRows.map((row) => [routeForKey(instance, row.id), row.id]));
|
|
957
|
+
const warnings = [...syncWarnings];
|
|
958
|
+
for (const route of removed) {
|
|
959
|
+
try {
|
|
960
|
+
await this.credentials.unset(routeKeyRef(route));
|
|
961
|
+
} catch (error) {
|
|
962
|
+
warnings.push(`key ${route} 的凭据清理失败:${messageOf(error)}`);
|
|
963
|
+
}
|
|
964
|
+
const keyId = keyIdByRoute.get(route) ?? legacyKeyIdOfRoute(route);
|
|
965
|
+
if (config.deleteOrphanKeys === true && keyId !== undefined) try {
|
|
966
|
+
await this.gateway.use((panel, token) => panel.deleteKey(token, keyId));
|
|
967
|
+
} catch (error) {
|
|
968
|
+
warnings.push(`key ${route} 的远端撤销失败:${messageOf(error)}`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
try {
|
|
972
|
+
const mapOps = [];
|
|
973
|
+
for (const entry of entries) if (entry.keyId !== undefined && entry.action !== "failed") mapOps.push({
|
|
974
|
+
op: "set",
|
|
975
|
+
path: ["routeKeyIds", entry.route],
|
|
976
|
+
value: entry.keyId
|
|
977
|
+
});
|
|
978
|
+
for (const route of removed) mapOps.push({
|
|
979
|
+
op: "unset",
|
|
980
|
+
path: ["routeKeyIds", route]
|
|
981
|
+
});
|
|
982
|
+
if (mapOps.length > 0) await mutateWithRetry(this.settings, OWN_NS, () => mapOps, { missingError: "sub2api-sync: 自身 settings 命名空间未注册" });
|
|
983
|
+
} catch {}
|
|
984
|
+
return {
|
|
985
|
+
entries,
|
|
986
|
+
removed,
|
|
987
|
+
writes: ops.length,
|
|
988
|
+
attempts,
|
|
989
|
+
warnings
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
/** Protocol + models + naming for one selected key; the plaintext comes
|
|
993
|
+
* from the panel row when present, else from the stored credential. */
|
|
994
|
+
async buildKeyProfile(config, key, group, instance, modelIndex, site) {
|
|
995
|
+
const protocol = protocolForPlatformWithOverride(group.platform, config.protocolOverrides?.[String(group.id)]);
|
|
996
|
+
if (protocol === undefined) throw new Sub2apiSyncError(`key #${key.id}(${key.name})所属分组 ${group.id} 的平台 "${group.platform}" 没有协议映射(可用 protocolOverrides 显式指定)`);
|
|
997
|
+
const route = routeForKey(instance, key.id);
|
|
998
|
+
const ref = routeKeyRef(route);
|
|
999
|
+
const usable = (value) => value !== undefined && value.length > 0 && !value.includes("****");
|
|
1000
|
+
let source = "panel";
|
|
1001
|
+
let revealFailure;
|
|
1002
|
+
let plaintext = key.key;
|
|
1003
|
+
if (!usable(plaintext)) {
|
|
1004
|
+
plaintext = undefined;
|
|
1005
|
+
const stored = await this.credentials.resolve(ref);
|
|
1006
|
+
if (usable(stored?.value)) {
|
|
1007
|
+
plaintext = stored?.value;
|
|
1008
|
+
source = "stored";
|
|
1009
|
+
} else if (stored?.value !== undefined) await this.credentials.unset(ref).catch(() => undefined);
|
|
1010
|
+
}
|
|
1011
|
+
if (!usable(plaintext) && ref !== legacyKeyRef(key.id)) {
|
|
1012
|
+
const legacy = await this.credentials.resolve(legacyKeyRef(key.id));
|
|
1013
|
+
if (usable(legacy?.value)) {
|
|
1014
|
+
plaintext = legacy?.value;
|
|
1015
|
+
source = "legacy";
|
|
1016
|
+
} else if (legacy?.value !== undefined) await this.credentials.unset(legacyKeyRef(key.id)).catch(() => undefined);
|
|
1017
|
+
}
|
|
1018
|
+
if (!usable(plaintext)) try {
|
|
1019
|
+
const revealed = await this.gateway.use((panel, token) => panel.revealKey(token, key.id));
|
|
1020
|
+
plaintext = !usable(revealed.key) ? undefined : revealed.key;
|
|
1021
|
+
if (plaintext === undefined) revealFailure = "reveal 未返回可用明文";
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
revealFailure = messageOf(error);
|
|
1024
|
+
}
|
|
1025
|
+
if (!usable(plaintext)) {
|
|
1026
|
+
const revealNote = revealFailure !== undefined ? `;reveal 接口也失败:${revealFailure}` : "";
|
|
1027
|
+
throw new Sub2apiSyncError(`key #${key.id}(${key.name})拿不到明文(面板列表不回明文且凭据库没有可用存底${revealNote})——请在面板删除这把 key 后用插件「创建 key」重建,重建时会自动保存明文`);
|
|
1028
|
+
}
|
|
1029
|
+
let models;
|
|
1030
|
+
try {
|
|
1031
|
+
models = await this.discoverModels(config, group.id, instance, plaintext);
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
if (source !== "stored" && source !== "legacy" || !/HTTP 401(?![0-9])/.test(messageOf(error))) throw error;
|
|
1034
|
+
await this.credentials.unset(ref).catch(() => undefined);
|
|
1035
|
+
if (ref !== legacyKeyRef(key.id)) await this.credentials.unset(legacyKeyRef(key.id)).catch(() => undefined);
|
|
1036
|
+
let revealed;
|
|
1037
|
+
try {
|
|
1038
|
+
revealed = (await this.gateway.use((panel, token) => panel.revealKey(token, key.id)))?.key;
|
|
1039
|
+
} catch (revealError) {
|
|
1040
|
+
throw new Sub2apiSyncError(`${messageOf(error)};本地存底已作废,但 reveal 也失败:${messageOf(revealError)}——请在面板删除这把 key 后用插件「创建 key」重建`);
|
|
1041
|
+
}
|
|
1042
|
+
if (revealed === undefined || revealed.length === 0 || revealed.includes("****")) throw new Sub2apiSyncError(`${messageOf(error)};本地存底已作废,但 reveal 未返回明文——请在面板删除这把 key 后用插件「创建 key」重建`);
|
|
1043
|
+
plaintext = revealed;
|
|
1044
|
+
models = await this.discoverModels(config, group.id, instance, plaintext);
|
|
1045
|
+
}
|
|
1046
|
+
if (this.credentials.resolve !== undefined) {
|
|
1047
|
+
const stored = await this.credentials.resolve(ref);
|
|
1048
|
+
if (stored?.value !== plaintext) await this.credentials.set(ref, plaintext);
|
|
1049
|
+
}
|
|
1050
|
+
const enriched = models.map((model) => enrichModel(model, modelIndex));
|
|
1051
|
+
let template = config.compatTemplate;
|
|
1052
|
+
const warnings = [];
|
|
1053
|
+
const requestedDefault = template?.reasoning;
|
|
1054
|
+
if (requestedDefault !== undefined) {
|
|
1055
|
+
const lacking = enriched.filter((model) => {
|
|
1056
|
+
const wire = model.reasoningEfforts?.[requestedDefault];
|
|
1057
|
+
return model.reasoningEfforts === undefined || wire === undefined || wire === null;
|
|
1058
|
+
}).map((model) => model.id);
|
|
1059
|
+
if (lacking.length > 0) {
|
|
1060
|
+
template = {
|
|
1061
|
+
...template,
|
|
1062
|
+
reasoning: undefined
|
|
1063
|
+
};
|
|
1064
|
+
const preview = lacking.length > 5 ? `${lacking.slice(0, 5).join("、")} 等` : lacking.join("、");
|
|
1065
|
+
warnings.push(`compatTemplate.reasoning=${requestedDefault} 未写入默认档位:${lacking.length} 个模型不支持该档位(${preview})——未手动选择档位的请求会被拒绝,故整组回退为不设默认`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return {
|
|
1069
|
+
profile: buildProviderProfile({
|
|
1070
|
+
groupId: group.id,
|
|
1071
|
+
displayName: renderProviderName(config.providerNameTemplate, key.name, site),
|
|
1072
|
+
protocol,
|
|
1073
|
+
instance,
|
|
1074
|
+
models: enriched,
|
|
1075
|
+
apiKeyEnv: ref,
|
|
1076
|
+
template,
|
|
1077
|
+
retryPolicy: config.retryPolicy,
|
|
1078
|
+
defaultContextWindow: config.defaultContextWindow,
|
|
1079
|
+
defaultMaxTokens: config.defaultMaxTokens
|
|
1080
|
+
}),
|
|
1081
|
+
warnings
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Model source precedence: a non-empty `modelListOverrides` entry is the
|
|
1086
|
+
* user's explicit selection from the card picker and wins outright (no
|
|
1087
|
+
* fetch — the entry contains exactly the chosen ids). Otherwise discover
|
|
1088
|
+
* via `/v1/models`; discovery failure falls back to the overrides loudly.
|
|
1089
|
+
*/
|
|
1090
|
+
async discoverModels(config, groupId, instance, relayKey) {
|
|
1091
|
+
const selection = config.modelListOverrides?.[String(groupId)] ?? [];
|
|
1092
|
+
if (selection.length > 0) return selection.map((id) => ({ id }));
|
|
1093
|
+
try {
|
|
1094
|
+
const client = new Sub2apiClient({
|
|
1095
|
+
baseURL: instance,
|
|
1096
|
+
fetchImpl: this.fetchImpl
|
|
1097
|
+
});
|
|
1098
|
+
return await client.listModels(relayKey);
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
throw new Sub2apiSyncError(`分组 ${groupId} 模型发现失败:${messageOf(error)};且未配置 modelListOverrides 兜底`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
readCurrentProviders() {
|
|
1104
|
+
const descriptor = this.settings.describe().find((candidate) => candidate.ns === PI_AI_NS);
|
|
1105
|
+
if (descriptor === undefined) throw new Sub2apiSyncError("llm-pi-ai 设置条目未激活——需要 dsh-llm-pi-ai 挂载后才能写入 provider 路由");
|
|
1106
|
+
const providers = descriptor.value?.providers;
|
|
1107
|
+
if (providers === undefined || providers === null) return {};
|
|
1108
|
+
if (typeof providers !== "object" || Array.isArray(providers)) throw new Sub2apiSyncError("llm-pi-ai.providers 形状异常(不是对象)");
|
|
1109
|
+
return providers;
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
/** Attach the enriched facts (display name, capacity, efforts) to one discovered model. */
|
|
1113
|
+
function enrichModel(model, index) {
|
|
1114
|
+
const meta = index.get(model.id.toLowerCase());
|
|
1115
|
+
if (meta === undefined) return { id: model.id };
|
|
1116
|
+
return {
|
|
1117
|
+
id: model.id,
|
|
1118
|
+
...meta.name !== undefined ? { name: meta.name } : {},
|
|
1119
|
+
...meta.contextWindow !== undefined ? { contextWindow: meta.contextWindow } : {},
|
|
1120
|
+
...meta.maxTokens !== undefined ? { maxTokens: meta.maxTokens } : {},
|
|
1121
|
+
...meta.imageInput === true ? { imageInput: true } : {},
|
|
1122
|
+
...meta.reasoningEfforts !== undefined ? { reasoningEfforts: { ...meta.reasoningEfforts } } : {}
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
/** Platform mapping with a per-group override applied last. */
|
|
1126
|
+
function protocolForPlatformWithOverride(platform, override) {
|
|
1127
|
+
return override ?? protocolForPlatform(platform);
|
|
1128
|
+
}
|
|
1129
|
+
/** Pure diff of one current/desired snapshot → ops plus per-route actions. */
|
|
1130
|
+
function planOps(current, desired) {
|
|
1131
|
+
const ops = [];
|
|
1132
|
+
const actions = new Map();
|
|
1133
|
+
const routes = new Set([...Object.keys(current).filter(isOwnedRoute), ...desired.keys()]);
|
|
1134
|
+
for (const route of routes) {
|
|
1135
|
+
const want = desired.get(route);
|
|
1136
|
+
const have = current[route];
|
|
1137
|
+
if (want !== undefined) if (have === undefined) {
|
|
1138
|
+
ops.push({
|
|
1139
|
+
op: "set",
|
|
1140
|
+
path: ["providers", route],
|
|
1141
|
+
value: want
|
|
1142
|
+
});
|
|
1143
|
+
actions.set(route, "created");
|
|
1144
|
+
} else if (!deepEqual(have, want)) {
|
|
1145
|
+
ops.push({
|
|
1146
|
+
op: "set",
|
|
1147
|
+
path: ["providers", route],
|
|
1148
|
+
value: want
|
|
1149
|
+
});
|
|
1150
|
+
actions.set(route, "updated");
|
|
1151
|
+
} else actions.set(route, "unchanged");
|
|
1152
|
+
else if (isOwnedRoute(route)) {
|
|
1153
|
+
ops.push({
|
|
1154
|
+
op: "unset",
|
|
1155
|
+
path: ["providers", route]
|
|
1156
|
+
});
|
|
1157
|
+
actions.set(route, "unchanged");
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return {
|
|
1161
|
+
ops,
|
|
1162
|
+
actions
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
//#endregion
|
|
1167
|
+
//#region src/icon.ts
|
|
1168
|
+
/** How long a resolved icon URL is trusted before re-reading the HTML. */
|
|
1169
|
+
const URL_TTL_MS = 6e5;
|
|
1170
|
+
/** Discovery + byte fetches cap, so an offline instance cannot stall callers. */
|
|
1171
|
+
const TIMEOUT_MS = 2500;
|
|
1172
|
+
/** Refuse to proxy absurd payloads. */
|
|
1173
|
+
const MAX_BYTES = 1e6;
|
|
1174
|
+
/** Sub2api's backend always appends this suffix to the rewritten title. */
|
|
1175
|
+
const TITLE_SUFFIX = " - AI API Gateway";
|
|
1176
|
+
/** Header layout guard: site names beyond this render truncated anyway. */
|
|
1177
|
+
const MAX_TITLE_LENGTH = 60;
|
|
1178
|
+
function pickIconHref(html$1) {
|
|
1179
|
+
for (const match of html$1.matchAll(/<link\b[^>]*>/gi)) {
|
|
1180
|
+
const tag = match[0];
|
|
1181
|
+
const rel = attr(tag, "rel")?.toLowerCase() ?? "";
|
|
1182
|
+
if (!rel.split(/\s+/).includes("icon") || rel.includes("mask")) continue;
|
|
1183
|
+
const href = attr(tag, "href");
|
|
1184
|
+
if (href === undefined || href.length === 0) continue;
|
|
1185
|
+
return href.replace(/&/gi, "&");
|
|
1186
|
+
}
|
|
1187
|
+
return undefined;
|
|
1188
|
+
}
|
|
1189
|
+
function parseSiteTitle(html$1) {
|
|
1190
|
+
const match = /<title\b[^>]*>([^<]*)<\/title>/i.exec(html$1);
|
|
1191
|
+
if (match === null) return undefined;
|
|
1192
|
+
const decoded = match[1].replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, "\"").replace(/'/gi, "'").trim();
|
|
1193
|
+
const stripped = decoded.toLowerCase().endsWith(TITLE_SUFFIX.toLowerCase()) ? decoded.slice(0, decoded.length - TITLE_SUFFIX.length).trim() : decoded;
|
|
1194
|
+
if (stripped.length === 0) return undefined;
|
|
1195
|
+
return stripped.length > MAX_TITLE_LENGTH ? `${stripped.slice(0, MAX_TITLE_LENGTH)}…` : stripped;
|
|
1196
|
+
}
|
|
1197
|
+
/** One attribute value out of a tag string (double/single/unquoted). */
|
|
1198
|
+
function attr(tag, name$1) {
|
|
1199
|
+
const pattern = new RegExp(`\\b${name$1}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
|
|
1200
|
+
const match = pattern.exec(tag);
|
|
1201
|
+
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
1202
|
+
}
|
|
1203
|
+
/** Fetch with a hard timeout; doubles (tests) may ignore the signal. */
|
|
1204
|
+
async function fetchCapped(url, fetchImpl) {
|
|
1205
|
+
const controller = new AbortController();
|
|
1206
|
+
const timer = setTimeout(() => {
|
|
1207
|
+
controller.abort();
|
|
1208
|
+
}, TIMEOUT_MS);
|
|
1209
|
+
try {
|
|
1210
|
+
return await fetchImpl(url, {
|
|
1211
|
+
signal: controller.signal,
|
|
1212
|
+
redirect: "follow"
|
|
1213
|
+
});
|
|
1214
|
+
} finally {
|
|
1215
|
+
clearTimeout(timer);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
/** Resolve the icon URL + site title from one instance's root HTML. */
|
|
1219
|
+
async function probeInstance(base, fetchImpl) {
|
|
1220
|
+
const fallback = { iconUrl: `${base}/favicon.ico` };
|
|
1221
|
+
try {
|
|
1222
|
+
const response = await fetchCapped(`${base}/`, fetchImpl);
|
|
1223
|
+
const type = response.headers.get("content-type") ?? "";
|
|
1224
|
+
if (!response.ok || !(/text\/html/i.test(type) || type.length === 0)) return fallback;
|
|
1225
|
+
const html$1 = await response.text();
|
|
1226
|
+
const href = pickIconHref(html$1);
|
|
1227
|
+
const iconUrl = href !== undefined ? (() => {
|
|
1228
|
+
try {
|
|
1229
|
+
return new URL(href, `${base}/`).toString();
|
|
1230
|
+
} catch {
|
|
1231
|
+
return fallback.iconUrl;
|
|
1232
|
+
}
|
|
1233
|
+
})() : fallback.iconUrl;
|
|
1234
|
+
return {
|
|
1235
|
+
iconUrl,
|
|
1236
|
+
title: parseSiteTitle(html$1)
|
|
1237
|
+
};
|
|
1238
|
+
} catch {
|
|
1239
|
+
return fallback;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
/** Guess a content type from the URL extension when upstream omits it. */
|
|
1243
|
+
function guessContentType(url) {
|
|
1244
|
+
const path = new URL(url).pathname.toLowerCase();
|
|
1245
|
+
if (path.endsWith(".svg")) return "image/svg+xml";
|
|
1246
|
+
if (path.endsWith(".png")) return "image/png";
|
|
1247
|
+
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
|
|
1248
|
+
if (path.endsWith(".webp")) return "image/webp";
|
|
1249
|
+
if (path.endsWith(".gif")) return "image/gif";
|
|
1250
|
+
return "image/x-icon";
|
|
1251
|
+
}
|
|
1252
|
+
function createInstanceProbe(options = {}) {
|
|
1253
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1254
|
+
const cache = new Map();
|
|
1255
|
+
const metaOf = async (instance) => {
|
|
1256
|
+
const base = instance.trim().replace(/\/+$/, "");
|
|
1257
|
+
if (!/^https?:\/\//i.test(base)) return undefined;
|
|
1258
|
+
const now = Date.now();
|
|
1259
|
+
const hit = cache.get(base);
|
|
1260
|
+
if (hit !== undefined && now - hit.at < URL_TTL_MS) return hit.meta;
|
|
1261
|
+
const meta = await probeInstance(base, fetchImpl);
|
|
1262
|
+
cache.set(base, {
|
|
1263
|
+
meta,
|
|
1264
|
+
at: now
|
|
1265
|
+
});
|
|
1266
|
+
return meta;
|
|
1267
|
+
};
|
|
1268
|
+
return {
|
|
1269
|
+
icon: async (instance) => {
|
|
1270
|
+
const meta = await metaOf(instance);
|
|
1271
|
+
if (meta === undefined) return undefined;
|
|
1272
|
+
try {
|
|
1273
|
+
const response = await fetchCapped(meta.iconUrl, fetchImpl);
|
|
1274
|
+
if (!response.ok) return undefined;
|
|
1275
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
1276
|
+
if (body.length === 0 || body.length > MAX_BYTES) return undefined;
|
|
1277
|
+
const upstream = response.headers.get("content-type")?.split(";")[0]?.trim() ?? "";
|
|
1278
|
+
return {
|
|
1279
|
+
contentType: upstream.length > 0 ? upstream : guessContentType(meta.iconUrl),
|
|
1280
|
+
body
|
|
1281
|
+
};
|
|
1282
|
+
} catch {
|
|
1283
|
+
return undefined;
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
1286
|
+
siteName: async (instance) => (await metaOf(instance))?.title
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
//#endregion
|
|
1291
|
+
//#region src/oauth.ts
|
|
1292
|
+
const OAUTH_CLIENT_ID = "dsh-sub2api-sync";
|
|
1293
|
+
const OAUTH_CALLBACK_PATH = "/plugins/dsh-sub2api-sync/oauth/callback";
|
|
1294
|
+
const OAUTH_ATTEMPT_TTL_MS = 6e5;
|
|
1295
|
+
function oauthScopesFor(deleteOrphanKeys) {
|
|
1296
|
+
return deleteOrphanKeys === true ? "openid profile groups:read keys:read keys:create keys:revoke" : "openid profile groups:read keys:read keys:create";
|
|
1297
|
+
}
|
|
1298
|
+
var OAuthError = class extends Error {
|
|
1299
|
+
code;
|
|
1300
|
+
constructor(code, message) {
|
|
1301
|
+
super(message);
|
|
1302
|
+
this.name = "OAuthError";
|
|
1303
|
+
this.code = code;
|
|
1304
|
+
}
|
|
1305
|
+
};
|
|
1306
|
+
const discoveryCache = new Map();
|
|
1307
|
+
const DISCOVERY_TTL_MS = 3e5;
|
|
1308
|
+
function absoluteHttpUrl(value) {
|
|
1309
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
1310
|
+
try {
|
|
1311
|
+
const url = new URL(value);
|
|
1312
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.toString().replace(/\/$/, "") : undefined;
|
|
1313
|
+
} catch {
|
|
1314
|
+
return undefined;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
async function discoverOAuthEndpoints(instance, fetchImpl = globalThis.fetch, now = Date.now()) {
|
|
1318
|
+
const key = instance.trim().replace(/\/+$/, "").toLowerCase();
|
|
1319
|
+
const cached = discoveryCache.get(key);
|
|
1320
|
+
if (cached !== undefined && now - cached.fetchedAt < DISCOVERY_TTL_MS) {
|
|
1321
|
+
if (cached.endpoints !== undefined) return cached.endpoints;
|
|
1322
|
+
throw new OAuthError("discovery_failed", "sub2api OAuth 发现失败(此前已失败,稍后重试)");
|
|
1323
|
+
}
|
|
1324
|
+
const base = instance.trim().replace(/\/+$/, "");
|
|
1325
|
+
const candidates = [`${base}/.well-known/oauth-authorization-server`, `${base}/api/v1/oauth/.well-known`];
|
|
1326
|
+
let endpoints;
|
|
1327
|
+
let lastError = "";
|
|
1328
|
+
for (const candidate of candidates) try {
|
|
1329
|
+
const response = await fetchImpl(candidate, { headers: { accept: "application/json" } });
|
|
1330
|
+
if (!response.ok) {
|
|
1331
|
+
lastError = `HTTP ${response.status} at ${candidate}`;
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
const payload = await response.json();
|
|
1335
|
+
const authorizationEndpoint = absoluteHttpUrl(payload.authorization_endpoint);
|
|
1336
|
+
const tokenEndpoint = absoluteHttpUrl(payload.token_endpoint);
|
|
1337
|
+
if (authorizationEndpoint === undefined || tokenEndpoint === undefined) {
|
|
1338
|
+
lastError = `incomplete metadata at ${candidate}`;
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
const revocationEndpoint = absoluteHttpUrl(payload.revocation_endpoint);
|
|
1342
|
+
endpoints = {
|
|
1343
|
+
authorizationEndpoint,
|
|
1344
|
+
tokenEndpoint,
|
|
1345
|
+
...revocationEndpoint !== undefined ? { revocationEndpoint } : {}
|
|
1346
|
+
};
|
|
1347
|
+
break;
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
lastError = `${candidate}: ${error instanceof Error ? error.message : String(error)}`;
|
|
1350
|
+
}
|
|
1351
|
+
discoveryCache.set(key, {
|
|
1352
|
+
endpoints,
|
|
1353
|
+
fetchedAt: now
|
|
1354
|
+
});
|
|
1355
|
+
if (endpoints === undefined) throw new OAuthError("discovery_failed", `sub2api OAuth 发现失败:${lastError}`);
|
|
1356
|
+
return endpoints;
|
|
1357
|
+
}
|
|
1358
|
+
function resetDiscoveryCache() {
|
|
1359
|
+
discoveryCache.clear();
|
|
1360
|
+
}
|
|
1361
|
+
function randomBase64url(bytes) {
|
|
1362
|
+
const buffer = new Uint8Array(bytes);
|
|
1363
|
+
crypto.getRandomValues(buffer);
|
|
1364
|
+
let binary = "";
|
|
1365
|
+
for (const byte of buffer) binary += String.fromCharCode(byte);
|
|
1366
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1367
|
+
}
|
|
1368
|
+
async function createPkcePair() {
|
|
1369
|
+
const verifier = randomBase64url(48);
|
|
1370
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
1371
|
+
let binary = "";
|
|
1372
|
+
for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
|
|
1373
|
+
const challenge = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1374
|
+
return {
|
|
1375
|
+
verifier,
|
|
1376
|
+
challenge
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
function loopbackCallbackOrigin(origin) {
|
|
1380
|
+
let parsed;
|
|
1381
|
+
try {
|
|
1382
|
+
parsed = new URL(origin);
|
|
1383
|
+
} catch {
|
|
1384
|
+
throw new OAuthError("invalid_request", "无法解析回调地址 origin");
|
|
1385
|
+
}
|
|
1386
|
+
const host = parsed.hostname.toLowerCase();
|
|
1387
|
+
if (host !== "127.0.0.1" && host !== "::1" && host !== "[::1]") throw new OAuthError("invalid_request", `OAuth 回调只支持本机回环地址(127.0.0.1 / ::1),当前 origin 是 ${parsed.host} —— 请在本机页面上发起网页授权`);
|
|
1388
|
+
return `${parsed.protocol}//${parsed.host}${OAUTH_CALLBACK_PATH}`;
|
|
1389
|
+
}
|
|
1390
|
+
function buildAuthorizeUrl(endpoints, input) {
|
|
1391
|
+
const url = new URL(endpoints.authorizationEndpoint);
|
|
1392
|
+
url.searchParams.set("response_type", "code");
|
|
1393
|
+
url.searchParams.set("client_id", OAUTH_CLIENT_ID);
|
|
1394
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
1395
|
+
url.searchParams.set("scope", input.scope);
|
|
1396
|
+
url.searchParams.set("state", input.state);
|
|
1397
|
+
url.searchParams.set("code_challenge", input.challenge);
|
|
1398
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
1399
|
+
return url.toString();
|
|
1400
|
+
}
|
|
1401
|
+
function readTokenPair$1(payload) {
|
|
1402
|
+
if (typeof payload !== "object" || payload === null) throw new OAuthError("server_error", "token 端点响应形状异常");
|
|
1403
|
+
const record = payload;
|
|
1404
|
+
if (typeof record.error === "string" && record.error.length > 0) throw new OAuthError(record.error, `token 端点返回错误:${record.error}`);
|
|
1405
|
+
const accessToken = record.access_token;
|
|
1406
|
+
if (typeof accessToken !== "string" || accessToken.length === 0) throw new OAuthError("server_error", "token 端点响应缺少 access_token");
|
|
1407
|
+
const refreshToken = record.refresh_token;
|
|
1408
|
+
const expiresIn = record.expires_in;
|
|
1409
|
+
const scope = record.scope;
|
|
1410
|
+
return {
|
|
1411
|
+
accessToken,
|
|
1412
|
+
...typeof refreshToken === "string" && refreshToken.length > 0 ? { refreshToken } : {},
|
|
1413
|
+
...typeof expiresIn === "number" ? { expiresIn } : {},
|
|
1414
|
+
...typeof scope === "string" && scope.length > 0 ? { scope } : {}
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
async function tokenRequest(tokenEndpoint, params, fetchImpl) {
|
|
1418
|
+
const response = await fetchImpl(tokenEndpoint, {
|
|
1419
|
+
method: "POST",
|
|
1420
|
+
headers: {
|
|
1421
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1422
|
+
accept: "application/json"
|
|
1423
|
+
},
|
|
1424
|
+
body: new URLSearchParams(params).toString()
|
|
1425
|
+
});
|
|
1426
|
+
let payload;
|
|
1427
|
+
try {
|
|
1428
|
+
payload = await response.json();
|
|
1429
|
+
} catch {
|
|
1430
|
+
throw new OAuthError("server_error", `token 端点响应不是 JSON(HTTP ${response.status})`);
|
|
1431
|
+
}
|
|
1432
|
+
if (!response.ok) {
|
|
1433
|
+
const code = payload?.error;
|
|
1434
|
+
throw new OAuthError(typeof code === "string" ? code : "server_error", `token 端点失败(HTTP ${response.status})`);
|
|
1435
|
+
}
|
|
1436
|
+
return readTokenPair$1(payload);
|
|
1437
|
+
}
|
|
1438
|
+
function exchangeAuthorizationCode(endpoints, input, fetchImpl = globalThis.fetch) {
|
|
1439
|
+
return tokenRequest(endpoints.tokenEndpoint, {
|
|
1440
|
+
grant_type: "authorization_code",
|
|
1441
|
+
client_id: OAUTH_CLIENT_ID,
|
|
1442
|
+
code: input.code,
|
|
1443
|
+
redirect_uri: input.redirectUri,
|
|
1444
|
+
code_verifier: input.verifier
|
|
1445
|
+
}, fetchImpl);
|
|
1446
|
+
}
|
|
1447
|
+
function refreshAccessToken(endpoints, refreshToken, fetchImpl = globalThis.fetch) {
|
|
1448
|
+
return tokenRequest(endpoints.tokenEndpoint, {
|
|
1449
|
+
grant_type: "refresh_token",
|
|
1450
|
+
client_id: OAUTH_CLIENT_ID,
|
|
1451
|
+
refresh_token: refreshToken
|
|
1452
|
+
}, fetchImpl);
|
|
1453
|
+
}
|
|
1454
|
+
async function revokeToken(endpoints, token, fetchImpl = globalThis.fetch) {
|
|
1455
|
+
if (endpoints.revocationEndpoint === undefined) return;
|
|
1456
|
+
await fetchImpl(endpoints.revocationEndpoint, {
|
|
1457
|
+
method: "POST",
|
|
1458
|
+
headers: {
|
|
1459
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1460
|
+
accept: "application/json"
|
|
1461
|
+
},
|
|
1462
|
+
body: new URLSearchParams({ token }).toString()
|
|
1463
|
+
}).catch(() => undefined);
|
|
1464
|
+
}
|
|
1465
|
+
var OAuthAttemptStore = class {
|
|
1466
|
+
attempts = new Map();
|
|
1467
|
+
byState = new Map();
|
|
1468
|
+
constructor(ttlMs = OAUTH_ATTEMPT_TTL_MS) {
|
|
1469
|
+
this.ttlMs = ttlMs;
|
|
1470
|
+
}
|
|
1471
|
+
create(input, now = Date.now()) {
|
|
1472
|
+
for (const [id, attempt$1] of this.attempts) if (this.expired(attempt$1, now)) this.drop(attempt$1);
|
|
1473
|
+
const attempt = {
|
|
1474
|
+
id: randomBase64url(24),
|
|
1475
|
+
state: randomBase64url(32),
|
|
1476
|
+
verifier: input.verifier,
|
|
1477
|
+
redirectUri: input.redirectUri,
|
|
1478
|
+
scope: input.scope,
|
|
1479
|
+
phase: "pending",
|
|
1480
|
+
createdAt: now
|
|
1481
|
+
};
|
|
1482
|
+
this.attempts.set(attempt.id, attempt);
|
|
1483
|
+
this.byState.set(attempt.state, attempt.id);
|
|
1484
|
+
return attempt;
|
|
1485
|
+
}
|
|
1486
|
+
expired(attempt, now) {
|
|
1487
|
+
return now - attempt.createdAt > this.ttlMs;
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Take the attempt for one browser redirect — single-use: the `state`
|
|
1491
|
+
* binding is consumed by this call, so a replayed redirect finds nothing.
|
|
1492
|
+
* The attempt itself stays in the registry for status polls by id until
|
|
1493
|
+
* it settles or expires.
|
|
1494
|
+
*/
|
|
1495
|
+
takeByState(state, now = Date.now()) {
|
|
1496
|
+
const id = this.byState.get(state);
|
|
1497
|
+
if (id === undefined) return undefined;
|
|
1498
|
+
this.byState.delete(state);
|
|
1499
|
+
const attempt = this.attempts.get(id);
|
|
1500
|
+
if (attempt === undefined) return undefined;
|
|
1501
|
+
if (attempt.phase !== "pending" || this.expired(attempt, now)) {
|
|
1502
|
+
this.drop(attempt);
|
|
1503
|
+
return undefined;
|
|
1504
|
+
}
|
|
1505
|
+
return attempt;
|
|
1506
|
+
}
|
|
1507
|
+
/** Mark a consumed attempt terminal (keeps it readable for status polls). */
|
|
1508
|
+
settle(id, phase, error) {
|
|
1509
|
+
const attempt = this.attempts.get(id);
|
|
1510
|
+
if (attempt === undefined) return;
|
|
1511
|
+
attempt.phase = phase;
|
|
1512
|
+
if (error !== undefined) attempt.error = error;
|
|
1513
|
+
}
|
|
1514
|
+
/** Read-only projection for the dialog's poll; unknown id → undefined. */
|
|
1515
|
+
peek(id, now = Date.now()) {
|
|
1516
|
+
const attempt = this.attempts.get(id);
|
|
1517
|
+
if (attempt === undefined) return undefined;
|
|
1518
|
+
if (this.expired(attempt, now)) {
|
|
1519
|
+
this.drop(attempt);
|
|
1520
|
+
return undefined;
|
|
1521
|
+
}
|
|
1522
|
+
return {
|
|
1523
|
+
phase: attempt.phase,
|
|
1524
|
+
...attempt.error !== undefined ? { error: attempt.error } : {}
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
cancel(id) {
|
|
1528
|
+
const attempt = this.attempts.get(id);
|
|
1529
|
+
if (attempt === undefined) return false;
|
|
1530
|
+
this.drop(attempt);
|
|
1531
|
+
return true;
|
|
1532
|
+
}
|
|
1533
|
+
drop(attempt) {
|
|
1534
|
+
this.attempts.delete(attempt.id);
|
|
1535
|
+
this.byState.delete(attempt.state);
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
function callbackPageHtml(outcome, message, lang = "zh") {
|
|
1539
|
+
const copy = {
|
|
1540
|
+
zh: {
|
|
1541
|
+
successTitle: "授权成功",
|
|
1542
|
+
failureTitle: "授权失败",
|
|
1543
|
+
openApp: "打开应用",
|
|
1544
|
+
htmlLang: "zh"
|
|
1545
|
+
},
|
|
1546
|
+
en: {
|
|
1547
|
+
successTitle: "Authorization complete",
|
|
1548
|
+
failureTitle: "Authorization failed",
|
|
1549
|
+
openApp: "Open app",
|
|
1550
|
+
htmlLang: "en"
|
|
1551
|
+
}
|
|
1552
|
+
}[lang];
|
|
1553
|
+
const title = outcome === "success" ? copy.successTitle : copy.failureTitle;
|
|
1554
|
+
const detail = message.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
|
|
1555
|
+
const styles = `
|
|
1556
|
+
body { font-family: system-ui, -apple-system, "PingFang SC", sans-serif; display: flex; min-height: 100vh;
|
|
1557
|
+
margin: 0; background: #fff; color: #1a1a1e; }
|
|
1558
|
+
.side { flex: 1 1 65%; display: flex; align-items: center; justify-content: center; min-width: 0; }
|
|
1559
|
+
main { text-align: center; max-width: 30rem; padding: 2.5rem 1.5rem; }
|
|
1560
|
+
.brand { display: flex; align-items: center; justify-content: center; gap: 0.55rem; margin-bottom: 4rem; }
|
|
1561
|
+
.wordmark { font-size: 1.65rem; font-weight: 700; letter-spacing: -0.02em; }
|
|
1562
|
+
.badge { background: #1a1a1e; color: #fff; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em;
|
|
1563
|
+
padding: 0.3rem 0.55rem; border-radius: 0.4rem; }
|
|
1564
|
+
h1 { font-size: 1.5rem; font-weight: 600; margin: 0 0 1rem; }
|
|
1565
|
+
p { font-size: 0.95rem; color: #8a8f9c; line-height: 1.7; margin: 0; }
|
|
1566
|
+
.fail { color: #d54941; }
|
|
1567
|
+
.art { flex: 1 1 35%; min-width: 0; display: flex; align-items: center; color: #fff;
|
|
1568
|
+
background: radial-gradient(120% 90% at 78% 18%, rgba(255,255,255,0.26) 0%, rgba(255,255,255,0) 55%),
|
|
1569
|
+
linear-gradient(155deg, #6b7c92 0%, #46586e 38%, #2c3b4f 72%, #1f2b3a 100%); }
|
|
1570
|
+
.art p { color: #fff; font-size: 1.75rem; line-height: 1.8; font-weight: 500; margin: 0; padding: 0 4.5rem;
|
|
1571
|
+
max-width: 34rem; text-align: left; }
|
|
1572
|
+
@media (max-width: 56rem) { .art { display: none; } }`;
|
|
1573
|
+
const brand = `<div class="brand" aria-hidden="true"><span class="wordmark">Sub2API</span><span class="badge">SYNC</span></div>`;
|
|
1574
|
+
const art = `<div class="art" aria-hidden="true"><p>欢迎对话 DeepSeek Harness,<br>共探智能上限</p></div>`;
|
|
1575
|
+
if (outcome !== "success") return `<!doctype html>
|
|
1576
|
+
<html lang="${copy.htmlLang}">
|
|
1577
|
+
<head>
|
|
1578
|
+
<meta charset="utf-8">
|
|
1579
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1580
|
+
<title>${title}</title>
|
|
1581
|
+
<style>${styles}</style>
|
|
1582
|
+
</head>
|
|
1583
|
+
<body>
|
|
1584
|
+
<div class="side">
|
|
1585
|
+
<main>
|
|
1586
|
+
${brand}
|
|
1587
|
+
<h1>${title}</h1>
|
|
1588
|
+
<p class="fail">${detail}</p>
|
|
1589
|
+
</main>
|
|
1590
|
+
</div>
|
|
1591
|
+
${art}
|
|
1592
|
+
<script>try { setTimeout(function () { window.close() }, 1500) } catch (e) {}</script>
|
|
1593
|
+
</body>
|
|
1594
|
+
</html>`;
|
|
1595
|
+
return `<!doctype html>
|
|
1596
|
+
<html lang="${copy.htmlLang}">
|
|
1597
|
+
<head>
|
|
1598
|
+
<meta charset="utf-8">
|
|
1599
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1600
|
+
<title>${title}</title>
|
|
1601
|
+
<style>${styles}
|
|
1602
|
+
button { margin-top: 3rem; font: inherit; font-size: 0.95rem; padding: 0.85rem 2.4rem; border-radius: 0.75rem;
|
|
1603
|
+
border: 0; background: #1a1a1e; color: #fff; cursor: pointer; }
|
|
1604
|
+
button:hover { background: #33343a; }
|
|
1605
|
+
</style>
|
|
1606
|
+
</head>
|
|
1607
|
+
<body>
|
|
1608
|
+
<div class="side">
|
|
1609
|
+
<main>
|
|
1610
|
+
${brand}
|
|
1611
|
+
<h1>${title}</h1>
|
|
1612
|
+
<p>${detail}</p>
|
|
1613
|
+
<button type="button" id="s2s-back">${copy.openApp}</button>
|
|
1614
|
+
</main>
|
|
1615
|
+
</div>
|
|
1616
|
+
${art}
|
|
1617
|
+
<script>
|
|
1618
|
+
(function () {
|
|
1619
|
+
function pullApp() {
|
|
1620
|
+
try {
|
|
1621
|
+
var frame = document.createElement('iframe');
|
|
1622
|
+
frame.style.display = 'none';
|
|
1623
|
+
frame.src = 'dsh://open';
|
|
1624
|
+
document.body.appendChild(frame);
|
|
1625
|
+
setTimeout(function () { frame.remove() }, 4000);
|
|
1626
|
+
} catch (e) {}
|
|
1627
|
+
}
|
|
1628
|
+
setTimeout(pullApp, 800);
|
|
1629
|
+
var button = document.getElementById('s2s-back');
|
|
1630
|
+
if (button) button.addEventListener('click', function () { location.href = 'dsh://open' });
|
|
1631
|
+
try { setTimeout(function () { window.close() }, 30000) } catch (e) {}
|
|
1632
|
+
})();
|
|
1633
|
+
</script>
|
|
1634
|
+
</body>
|
|
1635
|
+
</html>`;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
//#endregion
|
|
1639
|
+
//#region src/panel.ts
|
|
1640
|
+
var PanelError = class extends Error {
|
|
1641
|
+
status;
|
|
1642
|
+
code;
|
|
1643
|
+
constructor(status, message, code) {
|
|
1644
|
+
super(message);
|
|
1645
|
+
this.name = "PanelError";
|
|
1646
|
+
this.status = status;
|
|
1647
|
+
this.code = code;
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
/** The deployment's display timezone for the panel's list endpoints. */
|
|
1651
|
+
function timeZoneOf() {
|
|
1652
|
+
try {
|
|
1653
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
1654
|
+
} catch {
|
|
1655
|
+
return "UTC";
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
/** Extract the envelope payload or throw a loud PanelError. */
|
|
1659
|
+
function unwrap(response, payload) {
|
|
1660
|
+
const envelope = payload;
|
|
1661
|
+
if (envelope === null || envelope === undefined || typeof envelope !== "object") throw new PanelError(response.status, `面板响应不是标准信封(HTTP ${response.status})`);
|
|
1662
|
+
const code = envelope.code;
|
|
1663
|
+
if (code !== 0 && code !== "0") {
|
|
1664
|
+
const message = typeof envelope.message === "string" && envelope.message.length > 0 ? envelope.message : "未知错误";
|
|
1665
|
+
throw new PanelError(response.status, `面板错误:${message}(code=${String(code)})`, code);
|
|
1666
|
+
}
|
|
1667
|
+
return envelope.data;
|
|
1668
|
+
}
|
|
1669
|
+
/** Best-effort captcha interception check (A4: contract still unverified). */
|
|
1670
|
+
function looksLikeCaptcha(status, payload) {
|
|
1671
|
+
if (status !== 403 && status !== 400) return false;
|
|
1672
|
+
const text = JSON.stringify(payload ?? "").toLowerCase();
|
|
1673
|
+
return text.includes("turnstile") || text.includes("captcha");
|
|
1674
|
+
}
|
|
1675
|
+
async function readBody(response) {
|
|
1676
|
+
const text = await response.text().catch(() => "");
|
|
1677
|
+
if (text.length === 0) return undefined;
|
|
1678
|
+
try {
|
|
1679
|
+
return JSON.parse(text);
|
|
1680
|
+
} catch {
|
|
1681
|
+
return text;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
/** Loud failure for non-envelope HTTP failures (Cloudflare pages, 500s, …). */
|
|
1685
|
+
async function fail(response, what) {
|
|
1686
|
+
const payload = await readBody(response);
|
|
1687
|
+
const message = typeof payload === "string" && payload.length > 0 ? payload.slice(0, 200) : payload?.message;
|
|
1688
|
+
const hint = response.status === 403 ? "(若实例前置 Cloudflare,检查 /api/v1/* 的 WAF 放行规则)" : "";
|
|
1689
|
+
throw new PanelError(response.status, `${what} 失败:HTTP ${response.status} ${typeof message === "string" ? message : ""}${hint}`);
|
|
1690
|
+
}
|
|
1691
|
+
/** Tolerant group reader. */
|
|
1692
|
+
function readGroup(item) {
|
|
1693
|
+
if (typeof item !== "object" || item === null) return undefined;
|
|
1694
|
+
const record = item;
|
|
1695
|
+
const id = record.id;
|
|
1696
|
+
const name$1 = record.name;
|
|
1697
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || typeof name$1 !== "string") return undefined;
|
|
1698
|
+
return {
|
|
1699
|
+
id,
|
|
1700
|
+
name: name$1,
|
|
1701
|
+
...typeof record.description === "string" ? { description: record.description } : {},
|
|
1702
|
+
platform: typeof record.platform === "string" ? record.platform : "",
|
|
1703
|
+
...typeof record.rate_multiplier === "number" ? { rateMultiplier: record.rate_multiplier } : {},
|
|
1704
|
+
...typeof record.status === "string" ? { status: record.status } : {},
|
|
1705
|
+
...typeof record.is_exclusive === "boolean" ? { isExclusive: record.is_exclusive } : {},
|
|
1706
|
+
...typeof record.claude_code_only === "boolean" ? { claudeCodeOnly: record.claude_code_only } : {}
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
/** Tolerant key reader; plaintext kept verbatim when present. */
|
|
1710
|
+
function readKey(item) {
|
|
1711
|
+
if (typeof item !== "object" || item === null) return undefined;
|
|
1712
|
+
const record = item;
|
|
1713
|
+
const id = record.id;
|
|
1714
|
+
const name$1 = record.name;
|
|
1715
|
+
const groupId = record.group_id;
|
|
1716
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || typeof name$1 !== "string") return undefined;
|
|
1717
|
+
if (typeof groupId !== "number" || !Number.isSafeInteger(groupId)) return undefined;
|
|
1718
|
+
return {
|
|
1719
|
+
id,
|
|
1720
|
+
name: name$1,
|
|
1721
|
+
groupId,
|
|
1722
|
+
...typeof record.key === "string" ? { key: record.key } : {},
|
|
1723
|
+
...typeof record.status === "string" ? { status: record.status } : {},
|
|
1724
|
+
...typeof record.quota === "number" ? { quota: record.quota } : {},
|
|
1725
|
+
...typeof record.expires_at === "string" ? { expiresAt: record.expires_at } : {}
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
function readTokenPair(data) {
|
|
1729
|
+
if (typeof data !== "object" || data === null) throw new PanelError(500, "登录响应缺少 data");
|
|
1730
|
+
const record = data;
|
|
1731
|
+
const accessToken = record.access_token;
|
|
1732
|
+
if (typeof accessToken !== "string" || accessToken.length === 0) throw new PanelError(500, "登录响应缺少 access_token");
|
|
1733
|
+
const refreshToken = record.refresh_token;
|
|
1734
|
+
return {
|
|
1735
|
+
accessToken,
|
|
1736
|
+
...typeof refreshToken === "string" && refreshToken.length > 0 ? { refreshToken } : {}
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
var PanelClient = class {
|
|
1740
|
+
baseURL;
|
|
1741
|
+
fetchImpl;
|
|
1742
|
+
constructor(options) {
|
|
1743
|
+
const base = options.baseURL.trim().replace(/\/+$/, "");
|
|
1744
|
+
if (!/^https?:\/\//i.test(base)) throw new Error("sub2api: baseURL 必须是 http(s) 地址");
|
|
1745
|
+
this.baseURL = base;
|
|
1746
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1747
|
+
if (fetchImpl === undefined) throw new Error("sub2api: 当前环境没有 fetch 可用");
|
|
1748
|
+
this.fetchImpl = fetchImpl.bind(globalThis);
|
|
1749
|
+
}
|
|
1750
|
+
async call(token, method, path, body) {
|
|
1751
|
+
const response = await this.fetchImpl(`${this.baseURL}/api/v1${path}`, {
|
|
1752
|
+
method,
|
|
1753
|
+
headers: {
|
|
1754
|
+
"content-type": "application/json",
|
|
1755
|
+
accept: "application/json",
|
|
1756
|
+
"x-user-ui-request": "1",
|
|
1757
|
+
...token !== undefined ? { authorization: `Bearer ${token}` } : {}
|
|
1758
|
+
},
|
|
1759
|
+
...body !== undefined ? { body: JSON.stringify(body) } : {}
|
|
1760
|
+
});
|
|
1761
|
+
const payload = await readBody(response);
|
|
1762
|
+
if (!response.ok) {
|
|
1763
|
+
if (looksLikeCaptcha(response.status, payload)) {
|
|
1764
|
+
const message = payload?.message;
|
|
1765
|
+
throw Object.assign(new PanelError(response.status, `实例开启了人机验证(Turnstile/TCaptcha)——请改用「网页授权」通道`, "captcha"), {
|
|
1766
|
+
kind: "captcha",
|
|
1767
|
+
detail: typeof message === "string" ? message : undefined
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
if (typeof payload === "object" && payload !== null && payload.code !== undefined) unwrap(response, payload);
|
|
1771
|
+
await fail(response, `面板 ${method} ${path}`);
|
|
1772
|
+
}
|
|
1773
|
+
return unwrap(response, payload);
|
|
1774
|
+
}
|
|
1775
|
+
/** Password login; classifies 2FA and captcha outcomes. */
|
|
1776
|
+
async login(email, password) {
|
|
1777
|
+
const payload = await this.call(undefined, "POST", "/auth/login", {
|
|
1778
|
+
email,
|
|
1779
|
+
password
|
|
1780
|
+
});
|
|
1781
|
+
const data = payload;
|
|
1782
|
+
if (data !== null && typeof data === "object" && data.requires_2fa === true) {
|
|
1783
|
+
const tempToken = data.temp_token;
|
|
1784
|
+
if (typeof tempToken !== "string" || tempToken.length === 0) throw new PanelError(500, "2FA 响应缺少 temp_token");
|
|
1785
|
+
return {
|
|
1786
|
+
kind: "2fa",
|
|
1787
|
+
tempToken
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
const tokens = readTokenPair(payload);
|
|
1791
|
+
return {
|
|
1792
|
+
kind: "ok",
|
|
1793
|
+
accessToken: tokens.accessToken,
|
|
1794
|
+
refreshToken: tokens.refreshToken
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
/** Second factor for the pending login. */
|
|
1798
|
+
async login2FA(tempToken, totpCode) {
|
|
1799
|
+
const payload = await this.call(undefined, "POST", "/auth/login/2fa", {
|
|
1800
|
+
temp_token: tempToken,
|
|
1801
|
+
totp_code: totpCode
|
|
1802
|
+
});
|
|
1803
|
+
return readTokenPair(payload);
|
|
1804
|
+
}
|
|
1805
|
+
/** Exchange a refresh token for a fresh pair. */
|
|
1806
|
+
async refresh(refreshToken) {
|
|
1807
|
+
const payload = await this.call(undefined, "POST", "/auth/refresh", { refresh_token: refreshToken });
|
|
1808
|
+
return readTokenPair(payload);
|
|
1809
|
+
}
|
|
1810
|
+
/** Groups the account can bind; `data` is a bare array. */
|
|
1811
|
+
async listAvailableGroups(token) {
|
|
1812
|
+
const data = await this.call(token, "GET", "/groups/available");
|
|
1813
|
+
if (!Array.isArray(data)) throw new PanelError(500, "groups/available 响应不是数组");
|
|
1814
|
+
return data.map(readGroup).filter((group) => group !== undefined);
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* One page of the panel's native key listing (newest first upstream:
|
|
1818
|
+
* `sort_by=created_at&sort_order=desc`, mirroring the console call shape).
|
|
1819
|
+
* Callers that need every key walk `pages` themselves.
|
|
1820
|
+
*/
|
|
1821
|
+
async listKeys(token, options) {
|
|
1822
|
+
const page = options?.page ?? 1;
|
|
1823
|
+
const pageSize = options?.pageSize ?? 20;
|
|
1824
|
+
const query = new URLSearchParams({
|
|
1825
|
+
page: String(page),
|
|
1826
|
+
page_size: String(pageSize),
|
|
1827
|
+
sort_by: options?.sortBy ?? "created_at",
|
|
1828
|
+
sort_order: options?.sortOrder ?? "desc",
|
|
1829
|
+
timezone: timeZoneOf()
|
|
1830
|
+
});
|
|
1831
|
+
if (options?.groupId !== undefined) query.set("group_id", String(options.groupId));
|
|
1832
|
+
const data = await this.call(token, "GET", `/keys?${query.toString()}`);
|
|
1833
|
+
const record = data;
|
|
1834
|
+
const items = record?.items;
|
|
1835
|
+
if (!Array.isArray(items)) throw new PanelError(500, "keys 响应缺少 items 数组");
|
|
1836
|
+
const mapped = items.map(readKey).filter((key) => key !== undefined);
|
|
1837
|
+
const total = typeof record?.total === "number" ? record.total : mapped.length;
|
|
1838
|
+
const pages = typeof record?.pages === "number" && record.pages > 0 ? record.pages : Math.max(1, Math.ceil(total / pageSize));
|
|
1839
|
+
return {
|
|
1840
|
+
items: mapped,
|
|
1841
|
+
total,
|
|
1842
|
+
pages
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
/** Create one relay key. */
|
|
1846
|
+
async createKey(token, input) {
|
|
1847
|
+
const payload = await this.call(token, "POST", "/keys", {
|
|
1848
|
+
name: input.name,
|
|
1849
|
+
group_id: input.groupId,
|
|
1850
|
+
...input.quota !== undefined && input.quota > 0 ? { quota: input.quota } : {},
|
|
1851
|
+
...input.expiresinDays !== undefined && input.expiresinDays > 0 ? { expires_in_days: input.expiresinDays } : {}
|
|
1852
|
+
});
|
|
1853
|
+
const key = readKey(payload);
|
|
1854
|
+
if (key === undefined) throw new PanelError(500, "创建 key 响应形状异常");
|
|
1855
|
+
return key;
|
|
1856
|
+
}
|
|
1857
|
+
/** Revoke one relay key. */
|
|
1858
|
+
async deleteKey(token, id) {
|
|
1859
|
+
await this.call(token, "DELETE", `/keys/${id}`);
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Controlled plaintext reveal for one client-owned relay key (OAuth only):
|
|
1863
|
+
* the regular OAuth list/get answers are masked, this endpoint is the
|
|
1864
|
+
* documented exception — same ownership checks server-side, audited.
|
|
1865
|
+
*/
|
|
1866
|
+
async revealKey(token, id) {
|
|
1867
|
+
const payload = await this.call(token, "POST", `/keys/${id}/reveal`);
|
|
1868
|
+
const key = readKey(payload);
|
|
1869
|
+
if (key === undefined) throw new PanelError(500, "reveal 响应形状异常");
|
|
1870
|
+
return key;
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* The signed-in account's panel identity via the OAuth userinfo endpoint
|
|
1874
|
+
* (`GET /auth/me`, scope `profile` — the plugin always requests it). The
|
|
1875
|
+
* richer `/user/profile` sits OUTSIDE the OAuth token's path whitelist
|
|
1876
|
+
* (`requiredOAuthScope`: only /auth/me, /groups/*, /keys/*) and answers
|
|
1877
|
+
* 403 INSUFFICIENT_SCOPE for OAuth sessions. Fields: username, email,
|
|
1878
|
+
* balance — all optional.
|
|
1879
|
+
*/
|
|
1880
|
+
async getProfile(token) {
|
|
1881
|
+
const payload = await this.call(token, "GET", "/auth/me");
|
|
1882
|
+
if (payload === null || typeof payload !== "object") throw new PanelError(500, "profile 响应形状异常");
|
|
1883
|
+
const data = payload;
|
|
1884
|
+
const pick = (key) => {
|
|
1885
|
+
const value = data[key];
|
|
1886
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
1887
|
+
};
|
|
1888
|
+
const balance = data.balance;
|
|
1889
|
+
return {
|
|
1890
|
+
username: pick("username"),
|
|
1891
|
+
email: pick("email"),
|
|
1892
|
+
...typeof balance === "number" && Number.isFinite(balance) ? { balance } : {}
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
/** OAuth token exchange against the discovered provider (host-side only). */
|
|
1896
|
+
async oauthTokenExchange(endpoints, input) {
|
|
1897
|
+
return exchangeAuthorizationCode(endpoints, input, this.fetchImpl);
|
|
1898
|
+
}
|
|
1899
|
+
/** OAuth discovery passthrough for route handlers that need endpoints. */
|
|
1900
|
+
async discoverOAuth() {
|
|
1901
|
+
return discoverOAuthEndpoints(this.baseURL, this.fetchImpl);
|
|
1902
|
+
}
|
|
1903
|
+
};
|
|
1904
|
+
var PanelGateway = class {
|
|
1905
|
+
baseURLSource;
|
|
1906
|
+
credentials;
|
|
1907
|
+
fetchImpl;
|
|
1908
|
+
pending2FA;
|
|
1909
|
+
refreshInFlight;
|
|
1910
|
+
oauthRefreshInFlight;
|
|
1911
|
+
constructor(options) {
|
|
1912
|
+
this.baseURLSource = options.baseURL;
|
|
1913
|
+
this.credentials = options.credentials;
|
|
1914
|
+
this.fetchImpl = options.fetchImpl;
|
|
1915
|
+
this.onAuthLost = options.onAuthLost;
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Fired when the panel session is definitively gone — no refresh token, or
|
|
1919
|
+
* the refresh call itself was refused. Never on network failures. The host
|
|
1920
|
+
* hooks this to purge the site's sync state (logout/expiry policy).
|
|
1921
|
+
*/
|
|
1922
|
+
onAuthLost;
|
|
1923
|
+
/** The instance address, re-read per call so a baseURL edit takes effect. */
|
|
1924
|
+
get baseURL() {
|
|
1925
|
+
return typeof this.baseURLSource === "function" ? this.baseURLSource() : this.baseURLSource;
|
|
1926
|
+
}
|
|
1927
|
+
client() {
|
|
1928
|
+
return new PanelClient({
|
|
1929
|
+
baseURL: this.baseURL,
|
|
1930
|
+
fetchImpl: this.fetchImpl
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
tokenRef() {
|
|
1934
|
+
return panelTokenRef(this.baseURL);
|
|
1935
|
+
}
|
|
1936
|
+
refreshRef() {
|
|
1937
|
+
return panelRefreshRef(this.baseURL);
|
|
1938
|
+
}
|
|
1939
|
+
oauthAccessRef() {
|
|
1940
|
+
return oauthTokenRef(this.baseURL);
|
|
1941
|
+
}
|
|
1942
|
+
oauthRefresh() {
|
|
1943
|
+
return oauthRefreshRef(this.baseURL);
|
|
1944
|
+
}
|
|
1945
|
+
/** Current auth state; email rides the caller's config, not credentials. */
|
|
1946
|
+
async authState(email) {
|
|
1947
|
+
const oauthAccess = await this.credentials.resolve(this.oauthAccessRef());
|
|
1948
|
+
if (oauthAccess !== undefined && oauthAccess.value.length > 0) return {
|
|
1949
|
+
signedIn: true,
|
|
1950
|
+
kind: "oauth",
|
|
1951
|
+
...email !== undefined && email.length > 0 ? { email } : {}
|
|
1952
|
+
};
|
|
1953
|
+
const access = await this.credentials.resolve(this.tokenRef());
|
|
1954
|
+
if (access === undefined || access.value.length === 0) return {
|
|
1955
|
+
signedIn: false,
|
|
1956
|
+
kind: "none"
|
|
1957
|
+
};
|
|
1958
|
+
return {
|
|
1959
|
+
signedIn: true,
|
|
1960
|
+
kind: "panel",
|
|
1961
|
+
...email !== undefined && email.length > 0 ? { email } : {}
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
/** Password login; stores the pair and the password for silent re-login. */
|
|
1965
|
+
async login(email, password) {
|
|
1966
|
+
const outcome = await this.client().login(email, password);
|
|
1967
|
+
if (outcome.kind === "2fa") {
|
|
1968
|
+
this.pending2FA = {
|
|
1969
|
+
tempToken: outcome.tempToken,
|
|
1970
|
+
email
|
|
1971
|
+
};
|
|
1972
|
+
return outcome;
|
|
1973
|
+
}
|
|
1974
|
+
if (outcome.kind === "captcha") return outcome;
|
|
1975
|
+
await this.storeTokens(outcome.accessToken, outcome.refreshToken);
|
|
1976
|
+
return outcome;
|
|
1977
|
+
}
|
|
1978
|
+
/** Complete the pending 2FA login. */
|
|
1979
|
+
async login2FA(code) {
|
|
1980
|
+
const pending = this.pending2FA;
|
|
1981
|
+
if (pending === undefined) throw new Error("sub2api-sync: 没有待完成的 2FA 登录——请先发起登录");
|
|
1982
|
+
const tokens = await this.client().login2FA(pending.tempToken, code);
|
|
1983
|
+
await this.storeTokens(tokens.accessToken, tokens.refreshToken);
|
|
1984
|
+
this.pending2FA = undefined;
|
|
1985
|
+
}
|
|
1986
|
+
/** Sign out: revoke OAuth tokens server-side when present, then drop both channels' secrets. */
|
|
1987
|
+
async logout() {
|
|
1988
|
+
await this.oauthSignOut();
|
|
1989
|
+
await this.credentials.unset(this.tokenRef());
|
|
1990
|
+
await this.credentials.unset(this.refreshRef());
|
|
1991
|
+
this.pending2FA = undefined;
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Run one authed panel call. Tokens resolve per channel — OAuth access
|
|
1995
|
+
* token first, then the panel JWT — and a 401 triggers a single-flight
|
|
1996
|
+
* refresh (channel-appropriate) and exactly one retry before failing loud.
|
|
1997
|
+
*/
|
|
1998
|
+
async use(fn) {
|
|
1999
|
+
const oauthAccess = await this.credentials.resolve(this.oauthAccessRef());
|
|
2000
|
+
if (oauthAccess !== undefined && oauthAccess.value.length > 0) {
|
|
2001
|
+
const panel$1 = this.client();
|
|
2002
|
+
try {
|
|
2003
|
+
return await fn(panel$1, oauthAccess.value);
|
|
2004
|
+
} catch (error) {
|
|
2005
|
+
const status = error instanceof PanelError ? error.status : undefined;
|
|
2006
|
+
if (status !== 401) throw error;
|
|
2007
|
+
const token = await this.refreshOAuthSingleFlight();
|
|
2008
|
+
return await fn(panel$1, token);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
const access = await this.credentials.resolve(this.tokenRef());
|
|
2012
|
+
if (access === undefined || access.value.length === 0) throw new Error("sub2api-sync: 尚未登录 sub2api 面板——请先在设置卡完成登录");
|
|
2013
|
+
const panel = this.client();
|
|
2014
|
+
try {
|
|
2015
|
+
return await fn(panel, access.value);
|
|
2016
|
+
} catch (error) {
|
|
2017
|
+
const status = error instanceof PanelError ? error.status : undefined;
|
|
2018
|
+
if (status !== 401) throw error;
|
|
2019
|
+
const token = await this.refreshSingleFlight();
|
|
2020
|
+
return await fn(panel, token);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
/** Store the pair from a completed browser authorization (C2). */
|
|
2024
|
+
async storeOAuthTokens(pair) {
|
|
2025
|
+
await this.credentials.set(this.oauthAccessRef(), pair.accessToken);
|
|
2026
|
+
if (pair.refreshToken !== undefined) await this.credentials.set(this.oauthRefresh(), pair.refreshToken);
|
|
2027
|
+
else await this.credentials.unset(this.oauthRefresh());
|
|
2028
|
+
}
|
|
2029
|
+
/** Revoke OAuth tokens at the provider (best effort) and drop the stored secrets. */
|
|
2030
|
+
async oauthSignOut() {
|
|
2031
|
+
const access = await this.credentials.resolve(this.oauthAccessRef());
|
|
2032
|
+
const refresh = await this.credentials.resolve(this.oauthRefresh());
|
|
2033
|
+
if (access !== undefined || refresh !== undefined) try {
|
|
2034
|
+
const endpoints = await discoverOAuthEndpoints(this.baseURL, this.fetchImpl);
|
|
2035
|
+
if (refresh !== undefined) await revokeToken(endpoints, refresh.value, this.fetchImpl);
|
|
2036
|
+
if (access !== undefined) await revokeToken(endpoints, access.value, this.fetchImpl);
|
|
2037
|
+
} catch {}
|
|
2038
|
+
await this.credentials.unset(this.oauthAccessRef());
|
|
2039
|
+
await this.credentials.unset(this.oauthRefresh());
|
|
2040
|
+
}
|
|
2041
|
+
/** Single-flight OAuth refresh (rotating); concurrent 401s share one wire call. */
|
|
2042
|
+
refreshOAuthSingleFlight() {
|
|
2043
|
+
this.oauthRefreshInFlight ??= this.refreshOAuth().finally(() => {
|
|
2044
|
+
this.oauthRefreshInFlight = undefined;
|
|
2045
|
+
});
|
|
2046
|
+
return this.oauthRefreshInFlight;
|
|
2047
|
+
}
|
|
2048
|
+
async refreshOAuth() {
|
|
2049
|
+
const stored = await this.credentials.resolve(this.oauthRefresh());
|
|
2050
|
+
if (stored === undefined || stored.value.length === 0) {
|
|
2051
|
+
await this.credentials.unset(this.oauthAccessRef());
|
|
2052
|
+
this.onAuthLost?.();
|
|
2053
|
+
throw new Error("sub2api-sync: OAuth 登录已过期且无 refresh token——请重新进行网页授权");
|
|
2054
|
+
}
|
|
2055
|
+
try {
|
|
2056
|
+
const endpoints = await discoverOAuthEndpoints(this.baseURL, this.fetchImpl);
|
|
2057
|
+
const pair = await refreshAccessToken(endpoints, stored.value, this.fetchImpl);
|
|
2058
|
+
await this.storeOAuthTokens(pair);
|
|
2059
|
+
return pair.accessToken;
|
|
2060
|
+
} catch (error) {
|
|
2061
|
+
await this.credentials.unset(this.oauthAccessRef());
|
|
2062
|
+
await this.credentials.unset(this.oauthRefresh());
|
|
2063
|
+
this.onAuthLost?.();
|
|
2064
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2065
|
+
throw new Error(`sub2api-sync: OAuth 登录已过期且刷新失败——请重新进行网页授权(${reason})`);
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
/** Single-flight refresh; concurrent 401s share one wire call. */
|
|
2069
|
+
refreshSingleFlight() {
|
|
2070
|
+
this.refreshInFlight ??= this.doRefresh().finally(() => {
|
|
2071
|
+
this.refreshInFlight = undefined;
|
|
2072
|
+
});
|
|
2073
|
+
return this.refreshInFlight;
|
|
2074
|
+
}
|
|
2075
|
+
async doRefresh() {
|
|
2076
|
+
const stored = await this.credentials.resolve(this.refreshRef());
|
|
2077
|
+
if (stored === undefined || stored.value.length === 0) {
|
|
2078
|
+
await this.credentials.unset(this.tokenRef());
|
|
2079
|
+
this.onAuthLost?.();
|
|
2080
|
+
throw new Error("sub2api-sync: 登录已过期且无 refresh token——请重新登录");
|
|
2081
|
+
}
|
|
2082
|
+
try {
|
|
2083
|
+
const tokens = await this.client().refresh(stored.value);
|
|
2084
|
+
await this.storeTokens(tokens.accessToken, tokens.refreshToken);
|
|
2085
|
+
return tokens.accessToken;
|
|
2086
|
+
} catch (error) {
|
|
2087
|
+
await this.credentials.unset(this.tokenRef());
|
|
2088
|
+
await this.credentials.unset(this.refreshRef());
|
|
2089
|
+
this.onAuthLost?.();
|
|
2090
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2091
|
+
throw new Error(`sub2api-sync: 登录已过期且刷新失败——请重新登录(${reason})`);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
async storeTokens(accessToken, refreshToken) {
|
|
2095
|
+
await this.credentials.set(this.tokenRef(), accessToken);
|
|
2096
|
+
if (refreshToken !== undefined) await this.credentials.set(this.refreshRef(), refreshToken);
|
|
2097
|
+
else await this.credentials.unset(this.refreshRef());
|
|
2098
|
+
}
|
|
2099
|
+
};
|
|
2100
|
+
|
|
2101
|
+
//#endregion
|
|
2102
|
+
//#region src/routes.ts
|
|
2103
|
+
function callbackLangOf(acceptLanguage) {
|
|
2104
|
+
const first = acceptLanguage?.split(",")[0]?.trim().toLowerCase() ?? "";
|
|
2105
|
+
return first.startsWith("en") ? "en" : "zh";
|
|
2106
|
+
}
|
|
2107
|
+
const ROUTES = {
|
|
2108
|
+
sync: "/plugins/dsh-sub2api-sync/sync",
|
|
2109
|
+
status: "/plugins/dsh-sub2api-sync/status",
|
|
2110
|
+
login: "/plugins/dsh-sub2api-sync/login",
|
|
2111
|
+
login2fa: "/plugins/dsh-sub2api-sync/login/2fa",
|
|
2112
|
+
logout: "/plugins/dsh-sub2api-sync/logout",
|
|
2113
|
+
groups: "/plugins/dsh-sub2api-sync/groups",
|
|
2114
|
+
keys: "/plugins/dsh-sub2api-sync/keys",
|
|
2115
|
+
icon: "/plugins/dsh-sub2api-sync/icon",
|
|
2116
|
+
site: "/plugins/dsh-sub2api-sync/site",
|
|
2117
|
+
account: "/plugins/dsh-sub2api-sync/account",
|
|
2118
|
+
resetSync: "/plugins/dsh-sub2api-sync/reset-sync",
|
|
2119
|
+
removeKey: "/plugins/dsh-sub2api-sync/remove-key",
|
|
2120
|
+
oauthStart: "/plugins/dsh-sub2api-sync/oauth/start",
|
|
2121
|
+
oauthProbe: "/plugins/dsh-sub2api-sync/oauth/probe",
|
|
2122
|
+
oauthCallback: "/plugins/dsh-sub2api-sync/oauth/callback",
|
|
2123
|
+
oauthStatus: "/plugins/dsh-sub2api-sync/oauth/status",
|
|
2124
|
+
oauthCancel: "/plugins/dsh-sub2api-sync/oauth/cancel"
|
|
2125
|
+
};
|
|
2126
|
+
function trustedRequest(req, allowRemote = false) {
|
|
2127
|
+
const remote = req.socket.remoteAddress;
|
|
2128
|
+
if (remote === undefined) return false;
|
|
2129
|
+
const loopback = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
2130
|
+
const privatePeer = loopback || allowRemote && isPrivateAddress(remote);
|
|
2131
|
+
if (!privatePeer) return false;
|
|
2132
|
+
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
2133
|
+
const host = req.headers.host;
|
|
2134
|
+
if (host === undefined) return false;
|
|
2135
|
+
const origin = req.headers.origin;
|
|
2136
|
+
if (origin === undefined) return true;
|
|
2137
|
+
try {
|
|
2138
|
+
return new URL(origin).host === new URL(`http://${host}`).host;
|
|
2139
|
+
} catch {
|
|
2140
|
+
return false;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
/** RFC 1918 / ULA ranges only — never public addresses. */
|
|
2144
|
+
function isPrivateAddress(address) {
|
|
2145
|
+
const ipv4 = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(address)?.[1] ?? address;
|
|
2146
|
+
const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(ipv4);
|
|
2147
|
+
if (v4 !== null) {
|
|
2148
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
|
2149
|
+
if (a === 10 || a === 127) return true;
|
|
2150
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
2151
|
+
if (a === 192 && b === 168) return true;
|
|
2152
|
+
return false;
|
|
2153
|
+
}
|
|
2154
|
+
const lowered = address.toLowerCase();
|
|
2155
|
+
if (lowered === "::1") return true;
|
|
2156
|
+
return lowered.startsWith("fc") || lowered.startsWith("fd");
|
|
2157
|
+
}
|
|
2158
|
+
/** Request bodies here are tiny JSON documents; a larger stream is a broken
|
|
2159
|
+
* or hostile peer — refuse it before buffering (same posture as icon.ts's
|
|
2160
|
+
* MAX_BYTES). */
|
|
2161
|
+
const MAX_BODY_BYTES = 1e6;
|
|
2162
|
+
async function readJson(req) {
|
|
2163
|
+
const chunks = [];
|
|
2164
|
+
let total = 0;
|
|
2165
|
+
for await (const chunk of req) {
|
|
2166
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2167
|
+
total += buffer.length;
|
|
2168
|
+
if (total > MAX_BODY_BYTES) throw new Error(`request body 超过 ${MAX_BODY_BYTES} 字节上限`);
|
|
2169
|
+
chunks.push(buffer);
|
|
2170
|
+
}
|
|
2171
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
2172
|
+
if (text.length === 0) return {};
|
|
2173
|
+
const value = JSON.parse(text);
|
|
2174
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
2175
|
+
}
|
|
2176
|
+
function json(res, status, value) {
|
|
2177
|
+
res.writeHead(status, {
|
|
2178
|
+
"content-type": "application/json; charset=utf-8",
|
|
2179
|
+
"cache-control": "no-store",
|
|
2180
|
+
"x-content-type-options": "nosniff"
|
|
2181
|
+
});
|
|
2182
|
+
res.end(JSON.stringify(value));
|
|
2183
|
+
}
|
|
2184
|
+
/** The browser-facing hand-back page for the provider's redirect. */
|
|
2185
|
+
function html(res, body) {
|
|
2186
|
+
res.writeHead(200, {
|
|
2187
|
+
"content-type": "text/html; charset=utf-8",
|
|
2188
|
+
"cache-control": "no-store",
|
|
2189
|
+
"x-content-type-options": "nosniff"
|
|
2190
|
+
});
|
|
2191
|
+
res.end(body);
|
|
2192
|
+
}
|
|
2193
|
+
/** The harness origin this request arrived on (host + proto, no path). */
|
|
2194
|
+
function requestOrigin(req) {
|
|
2195
|
+
const forwarded = req.headers["x-forwarded-proto"];
|
|
2196
|
+
const proto = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? "http";
|
|
2197
|
+
const host = req.headers.host ?? "127.0.0.1";
|
|
2198
|
+
return `${proto}://${host}`;
|
|
2199
|
+
}
|
|
2200
|
+
function safeMessage(error) {
|
|
2201
|
+
return error instanceof Error ? error.message : String(error);
|
|
2202
|
+
}
|
|
2203
|
+
function conflictStatus(error) {
|
|
2204
|
+
return isSettingsConflict(error) ? 409 : undefined;
|
|
2205
|
+
}
|
|
2206
|
+
function registerRoutes(ctx, deps) {
|
|
2207
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
2208
|
+
const route = (path, handler) => {
|
|
2209
|
+
webCtx.webServer.register({
|
|
2210
|
+
kind: "exact",
|
|
2211
|
+
path,
|
|
2212
|
+
handler
|
|
2213
|
+
});
|
|
2214
|
+
};
|
|
2215
|
+
const guard = (req, res) => {
|
|
2216
|
+
if (!trustedRequest(req, deps.allowRemote())) {
|
|
2217
|
+
json(res, 403, { error: "forbidden" });
|
|
2218
|
+
return false;
|
|
2219
|
+
}
|
|
2220
|
+
return true;
|
|
2221
|
+
};
|
|
2222
|
+
const method = (expected, req, res) => {
|
|
2223
|
+
if (req.method !== expected) {
|
|
2224
|
+
json(res, 405, { error: "method not allowed" });
|
|
2225
|
+
return false;
|
|
2226
|
+
}
|
|
2227
|
+
return true;
|
|
2228
|
+
};
|
|
2229
|
+
route(ROUTES.status, async (req, res) => {
|
|
2230
|
+
if (!guard(req, res)) return;
|
|
2231
|
+
if (!method("GET", req, res)) return;
|
|
2232
|
+
const auth = await deps.authState();
|
|
2233
|
+
json(res, 200, {
|
|
2234
|
+
ok: true,
|
|
2235
|
+
auth,
|
|
2236
|
+
instance: deps.instance(),
|
|
2237
|
+
syncRunning: deps.isSyncRunning(),
|
|
2238
|
+
routes: deps.ownedRoutes(),
|
|
2239
|
+
lastReport: deps.lastReport(),
|
|
2240
|
+
lastReportAt: deps.lastReportAt()
|
|
2241
|
+
});
|
|
2242
|
+
});
|
|
2243
|
+
route(ROUTES.sync, async (req, res) => {
|
|
2244
|
+
if (!guard(req, res)) return;
|
|
2245
|
+
if (!method("POST", req, res)) return;
|
|
2246
|
+
try {
|
|
2247
|
+
const body = await readJson(req).catch(() => ({}));
|
|
2248
|
+
const keyIds = Array.isArray(body.keyIds) ? body.keyIds.filter((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0) : undefined;
|
|
2249
|
+
const report = await deps.sync(keyIds);
|
|
2250
|
+
json(res, 200, {
|
|
2251
|
+
ok: true,
|
|
2252
|
+
report
|
|
2253
|
+
});
|
|
2254
|
+
} catch (error) {
|
|
2255
|
+
json(res, conflictStatus(error) ?? 500, {
|
|
2256
|
+
ok: false,
|
|
2257
|
+
error: safeMessage(error)
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
});
|
|
2261
|
+
route(ROUTES.login, async (req, res) => {
|
|
2262
|
+
if (!guard(req, res)) return;
|
|
2263
|
+
if (!method("POST", req, res)) return;
|
|
2264
|
+
let body;
|
|
2265
|
+
try {
|
|
2266
|
+
body = await readJson(req);
|
|
2267
|
+
} catch {
|
|
2268
|
+
return json(res, 400, { error: "invalid JSON body" });
|
|
2269
|
+
}
|
|
2270
|
+
const email = typeof body.email === "string" ? body.email.trim() : "";
|
|
2271
|
+
const password = typeof body.password === "string" ? body.password : undefined;
|
|
2272
|
+
if (email.length === 0) return json(res, 400, { error: "email 必填" });
|
|
2273
|
+
try {
|
|
2274
|
+
json(res, 200, await deps.login(email, password));
|
|
2275
|
+
} catch (error) {
|
|
2276
|
+
const kind = error?.kind;
|
|
2277
|
+
json(res, 400, {
|
|
2278
|
+
ok: false,
|
|
2279
|
+
error: safeMessage(error),
|
|
2280
|
+
...kind === "captcha" ? { kind: "captcha" } : {}
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
});
|
|
2284
|
+
route(ROUTES.login2fa, async (req, res) => {
|
|
2285
|
+
if (!guard(req, res)) return;
|
|
2286
|
+
if (!method("POST", req, res)) return;
|
|
2287
|
+
let body;
|
|
2288
|
+
try {
|
|
2289
|
+
body = await readJson(req);
|
|
2290
|
+
} catch {
|
|
2291
|
+
return json(res, 400, { error: "invalid JSON body" });
|
|
2292
|
+
}
|
|
2293
|
+
const code = typeof body.code === "string" ? body.code.trim() : "";
|
|
2294
|
+
if (!/^\d{6}$/.test(code)) return json(res, 400, { error: "code 必须是 6 位数字" });
|
|
2295
|
+
try {
|
|
2296
|
+
await deps.login2FA(code);
|
|
2297
|
+
json(res, 200, {
|
|
2298
|
+
ok: true,
|
|
2299
|
+
kind: "ok"
|
|
2300
|
+
});
|
|
2301
|
+
} catch (error) {
|
|
2302
|
+
json(res, 400, {
|
|
2303
|
+
ok: false,
|
|
2304
|
+
error: safeMessage(error)
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
route(ROUTES.logout, async (req, res) => {
|
|
2309
|
+
if (!guard(req, res)) return;
|
|
2310
|
+
if (!method("POST", req, res)) return;
|
|
2311
|
+
try {
|
|
2312
|
+
await deps.logout();
|
|
2313
|
+
json(res, 200, { ok: true });
|
|
2314
|
+
} catch (error) {
|
|
2315
|
+
json(res, 500, {
|
|
2316
|
+
ok: false,
|
|
2317
|
+
error: safeMessage(error)
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
});
|
|
2321
|
+
route(ROUTES.groups, async (req, res) => {
|
|
2322
|
+
if (!guard(req, res)) return;
|
|
2323
|
+
if (!method("GET", req, res)) return;
|
|
2324
|
+
try {
|
|
2325
|
+
json(res, 200, {
|
|
2326
|
+
ok: true,
|
|
2327
|
+
groups: await deps.listGroups()
|
|
2328
|
+
});
|
|
2329
|
+
} catch (error) {
|
|
2330
|
+
json(res, 400, {
|
|
2331
|
+
ok: false,
|
|
2332
|
+
error: safeMessage(error)
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
});
|
|
2336
|
+
route(ROUTES.keys, async (req, res) => {
|
|
2337
|
+
if (!guard(req, res)) return;
|
|
2338
|
+
if (req.method === "GET") {
|
|
2339
|
+
try {
|
|
2340
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2341
|
+
const rawPage = Number(url.searchParams.get("page") ?? "1");
|
|
2342
|
+
const rawSize = Number(url.searchParams.get("pageSize") ?? "5");
|
|
2343
|
+
const page = Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1;
|
|
2344
|
+
const pageSize = Number.isSafeInteger(rawSize) && rawSize > 0 ? Math.min(rawSize, 50) : 5;
|
|
2345
|
+
const result = await deps.listKeys(page, pageSize);
|
|
2346
|
+
json(res, 200, {
|
|
2347
|
+
ok: true,
|
|
2348
|
+
keys: result.keys,
|
|
2349
|
+
total: result.total,
|
|
2350
|
+
pages: result.pages
|
|
2351
|
+
});
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
json(res, 400, {
|
|
2354
|
+
ok: false,
|
|
2355
|
+
error: safeMessage(error)
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
if (!method("POST", req, res)) return;
|
|
2361
|
+
try {
|
|
2362
|
+
const body = await readJson(req);
|
|
2363
|
+
const groupId = parseGroupIdFrom(body.groupId);
|
|
2364
|
+
const name$1 = typeof body.name === "string" ? body.name.trim() : "";
|
|
2365
|
+
if (groupId === undefined) return json(res, 400, {
|
|
2366
|
+
ok: false,
|
|
2367
|
+
error: "groupId 必须是正整数分组 id"
|
|
2368
|
+
});
|
|
2369
|
+
if (name$1.length === 0 || name$1.length > 128) return json(res, 400, {
|
|
2370
|
+
ok: false,
|
|
2371
|
+
error: "key 名称必须是 1-128 个字符"
|
|
2372
|
+
});
|
|
2373
|
+
json(res, 200, {
|
|
2374
|
+
ok: true,
|
|
2375
|
+
key: await deps.createKey({
|
|
2376
|
+
groupId,
|
|
2377
|
+
name: name$1
|
|
2378
|
+
})
|
|
2379
|
+
});
|
|
2380
|
+
} catch (error) {
|
|
2381
|
+
json(res, 400, {
|
|
2382
|
+
ok: false,
|
|
2383
|
+
error: safeMessage(error)
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
});
|
|
2387
|
+
route(ROUTES.icon, async (req, res) => {
|
|
2388
|
+
if (!guard(req, res)) return;
|
|
2389
|
+
if (!method("GET", req, res)) return;
|
|
2390
|
+
const icon = await deps.icon().catch(() => undefined);
|
|
2391
|
+
if (icon === undefined) return json(res, 404, { error: "icon unavailable" });
|
|
2392
|
+
res.writeHead(200, {
|
|
2393
|
+
"content-type": icon.contentType,
|
|
2394
|
+
"cache-control": "private, max-age=600",
|
|
2395
|
+
"x-content-type-options": "nosniff"
|
|
2396
|
+
});
|
|
2397
|
+
res.end(icon.body);
|
|
2398
|
+
});
|
|
2399
|
+
route(ROUTES.site, async (req, res) => {
|
|
2400
|
+
if (!guard(req, res)) return;
|
|
2401
|
+
if (!method("GET", req, res)) return;
|
|
2402
|
+
const name$1 = await deps.siteName().catch(() => undefined);
|
|
2403
|
+
json(res, 200, {
|
|
2404
|
+
ok: true,
|
|
2405
|
+
name: name$1
|
|
2406
|
+
});
|
|
2407
|
+
});
|
|
2408
|
+
route(ROUTES.account, async (req, res) => {
|
|
2409
|
+
if (!guard(req, res)) return;
|
|
2410
|
+
if (!method("GET", req, res)) return;
|
|
2411
|
+
try {
|
|
2412
|
+
const profile = await deps.account();
|
|
2413
|
+
json(res, 200, {
|
|
2414
|
+
ok: true,
|
|
2415
|
+
...profile?.username !== undefined ? { username: profile.username } : {},
|
|
2416
|
+
...profile?.email !== undefined ? { email: profile.email } : {},
|
|
2417
|
+
...profile?.balance !== undefined ? { balance: profile.balance } : {}
|
|
2418
|
+
});
|
|
2419
|
+
} catch (error) {
|
|
2420
|
+
json(res, 200, {
|
|
2421
|
+
ok: true,
|
|
2422
|
+
error: safeMessage(error)
|
|
2423
|
+
});
|
|
2424
|
+
}
|
|
2425
|
+
});
|
|
2426
|
+
route(ROUTES.resetSync, async (req, res) => {
|
|
2427
|
+
if (!guard(req, res)) return;
|
|
2428
|
+
if (!method("POST", req, res)) return;
|
|
2429
|
+
try {
|
|
2430
|
+
const body = await readJson(req);
|
|
2431
|
+
const previous = typeof body.previousBaseURL === "string" && body.previousBaseURL.length > 0 ? body.previousBaseURL : undefined;
|
|
2432
|
+
await deps.resetSync(previous);
|
|
2433
|
+
json(res, 200, { ok: true });
|
|
2434
|
+
} catch (error) {
|
|
2435
|
+
json(res, 500, {
|
|
2436
|
+
ok: false,
|
|
2437
|
+
error: safeMessage(error)
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
});
|
|
2441
|
+
route(ROUTES.removeKey, async (req, res) => {
|
|
2442
|
+
if (!guard(req, res)) return;
|
|
2443
|
+
if (!method("POST", req, res)) return;
|
|
2444
|
+
try {
|
|
2445
|
+
const body = await readJson(req);
|
|
2446
|
+
const keyId = parseGroupIdFrom(body.keyId);
|
|
2447
|
+
if (keyId === undefined) return json(res, 400, {
|
|
2448
|
+
ok: false,
|
|
2449
|
+
error: "keyId 必须是正整数 key id"
|
|
2450
|
+
});
|
|
2451
|
+
await deps.removeSyncedKey(keyId);
|
|
2452
|
+
json(res, 200, { ok: true });
|
|
2453
|
+
} catch (error) {
|
|
2454
|
+
json(res, 500, {
|
|
2455
|
+
ok: false,
|
|
2456
|
+
error: safeMessage(error)
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
});
|
|
2460
|
+
route(ROUTES.oauthStart, async (req, res) => {
|
|
2461
|
+
if (!guard(req, res)) return;
|
|
2462
|
+
if (!method("POST", req, res)) return;
|
|
2463
|
+
try {
|
|
2464
|
+
const started = await deps.oauthStart(requestOrigin(req));
|
|
2465
|
+
json(res, 200, {
|
|
2466
|
+
ok: true,
|
|
2467
|
+
attemptId: started.attemptId,
|
|
2468
|
+
authorizeUrl: started.authorizeUrl
|
|
2469
|
+
});
|
|
2470
|
+
} catch (error) {
|
|
2471
|
+
json(res, 400, {
|
|
2472
|
+
ok: false,
|
|
2473
|
+
error: safeMessage(error)
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
});
|
|
2477
|
+
route(ROUTES.oauthProbe, async (req, res) => {
|
|
2478
|
+
if (!guard(req, res)) return;
|
|
2479
|
+
if (!method("GET", req, res)) return;
|
|
2480
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2481
|
+
const address = url.searchParams.get("address") ?? "";
|
|
2482
|
+
let supported = false;
|
|
2483
|
+
if (/^https?:\/\//i.test(address.trim())) supported = await deps.oauthProbe(address).catch(() => false);
|
|
2484
|
+
json(res, 200, {
|
|
2485
|
+
ok: true,
|
|
2486
|
+
supported
|
|
2487
|
+
});
|
|
2488
|
+
});
|
|
2489
|
+
route(ROUTES.oauthCallback, async (req, res) => {
|
|
2490
|
+
if (!method("GET", req, res)) return;
|
|
2491
|
+
try {
|
|
2492
|
+
const query = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
2493
|
+
const answer = await deps.oauthCallback(query, callbackLangOf(req.headers["accept-language"]));
|
|
2494
|
+
html(res, answer.html);
|
|
2495
|
+
} catch (error) {
|
|
2496
|
+
html(res, oauthFailurePage(safeMessage(error)));
|
|
2497
|
+
}
|
|
2498
|
+
});
|
|
2499
|
+
route(ROUTES.oauthStatus, async (req, res) => {
|
|
2500
|
+
if (!guard(req, res)) return;
|
|
2501
|
+
if (!method("GET", req, res)) return;
|
|
2502
|
+
try {
|
|
2503
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2504
|
+
const attemptId = url.searchParams.get("attempt") ?? undefined;
|
|
2505
|
+
json(res, 200, await deps.oauthStatus(attemptId));
|
|
2506
|
+
} catch (error) {
|
|
2507
|
+
json(res, 400, {
|
|
2508
|
+
ok: false,
|
|
2509
|
+
error: safeMessage(error)
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
});
|
|
2513
|
+
route(ROUTES.oauthCancel, async (req, res) => {
|
|
2514
|
+
if (!guard(req, res)) return;
|
|
2515
|
+
if (!method("POST", req, res)) return;
|
|
2516
|
+
try {
|
|
2517
|
+
const body = await readJson(req);
|
|
2518
|
+
if (typeof body.attemptId !== "string" || body.attemptId.length === 0) return json(res, 400, {
|
|
2519
|
+
ok: false,
|
|
2520
|
+
error: "attemptId 必填"
|
|
2521
|
+
});
|
|
2522
|
+
await deps.oauthCancel(body.attemptId);
|
|
2523
|
+
json(res, 200, { ok: true });
|
|
2524
|
+
} catch (error) {
|
|
2525
|
+
json(res, 400, {
|
|
2526
|
+
ok: false,
|
|
2527
|
+
error: safeMessage(error)
|
|
2528
|
+
});
|
|
2529
|
+
}
|
|
2530
|
+
});
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
/** Standalone failure page when the callback handler itself throws. Inline on
|
|
2534
|
+
* purpose: routes.ts rides the client bundle, so it must not value-import
|
|
2535
|
+
* the host-only oauth module. */
|
|
2536
|
+
function oauthFailurePage(message) {
|
|
2537
|
+
const detail = message.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
|
|
2538
|
+
return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>授权失败 · Authorization failed</title></head><body><main><h1>授权失败 · Authorization failed</h1><p>${detail}</p></main></body></html>`;
|
|
2539
|
+
}
|
|
2540
|
+
/** Shared group-id parser over route input (number or decimal string). */
|
|
2541
|
+
function parseGroupIdFrom(input) {
|
|
2542
|
+
if (typeof input === "number") return Number.isSafeInteger(input) && input > 0 ? input : undefined;
|
|
2543
|
+
if (typeof input !== "string") return undefined;
|
|
2544
|
+
if (!/^\d+$/.test(input)) return undefined;
|
|
2545
|
+
const id = Number(input);
|
|
2546
|
+
return Number.isSafeInteger(id) && id > 0 ? id : undefined;
|
|
2547
|
+
}
|
|
2548
|
+
function describeOwnedRoutes(section) {
|
|
2549
|
+
const providers = section?.providers ?? {};
|
|
2550
|
+
const routes = [];
|
|
2551
|
+
for (const [route, profile] of Object.entries(providers)) {
|
|
2552
|
+
if (!isOwnedRoute(route)) continue;
|
|
2553
|
+
const models = profile?.models ?? [];
|
|
2554
|
+
routes.push({
|
|
2555
|
+
route,
|
|
2556
|
+
displayName: profile?.displayName,
|
|
2557
|
+
api: profile?.api,
|
|
2558
|
+
models: models.length,
|
|
2559
|
+
modelsPreview: models.slice(0, 20).map((model) => model.id)
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
return routes;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
//#endregion
|
|
2566
|
+
//#region src/index.ts
|
|
2567
|
+
const name = "sub2api-sync";
|
|
2568
|
+
const inject = ["settings", "credentials"];
|
|
2569
|
+
function apply(ctx, config) {
|
|
2570
|
+
const source = () => readConfig(config);
|
|
2571
|
+
const judge = () => {
|
|
2572
|
+
try {
|
|
2573
|
+
validateConfig(source());
|
|
2574
|
+
} catch (error) {
|
|
2575
|
+
ctx.logger.error(error);
|
|
2576
|
+
}
|
|
2577
|
+
};
|
|
2578
|
+
judge();
|
|
2579
|
+
/** Conflict-retried write into the llm-pi-ai section (purge path mirrors
|
|
2580
|
+
* the engine's conflict policy without touching its queue). */
|
|
2581
|
+
const mutatePiAi = (ops) => mutateWithRetry(ctx.settings, PI_AI_NS, () => ops, { missingError: "sub2api-sync: llm-pi-ai 设置条目未注册" });
|
|
2582
|
+
const credentials = {
|
|
2583
|
+
resolve: (ref) => ctx.credentials.resolve(credentialRef(ref)),
|
|
2584
|
+
set: (ref, value) => ctx.credentials.set(credentialRef(ref), value),
|
|
2585
|
+
unset: (ref) => ctx.credentials.unset(credentialRef(ref))
|
|
2586
|
+
};
|
|
2587
|
+
const reports = createSyncReportStore();
|
|
2588
|
+
/**
|
|
2589
|
+
* Purge this plugin's sync state: every owned provider route leaves the
|
|
2590
|
+
* llm-pi-ai section and its relay-key credential is dropped. Logout,
|
|
2591
|
+
* definitive auth loss, and instance switches all funnel here.
|
|
2592
|
+
*/
|
|
2593
|
+
const purgeSyncState = async () => {
|
|
2594
|
+
reports.clear();
|
|
2595
|
+
const section = ctx.settings.describe().find((descriptor) => descriptor.ns === PI_AI_NS)?.value;
|
|
2596
|
+
const routes = describeOwnedRoutes(section).map((route) => route.route);
|
|
2597
|
+
if (routes.length > 0) await mutatePiAi(routes.map((route) => ({
|
|
2598
|
+
op: "unset",
|
|
2599
|
+
path: ["providers", route]
|
|
2600
|
+
})));
|
|
2601
|
+
for (const route of routes) await credentials.unset(routeKeyRef(route)).catch(() => undefined);
|
|
2602
|
+
await mutateOwn([{
|
|
2603
|
+
op: "unset",
|
|
2604
|
+
path: ["routeKeyIds"]
|
|
2605
|
+
}]).catch(() => undefined);
|
|
2606
|
+
};
|
|
2607
|
+
/** Drop one instance's unreachable panel secrets (token/refresh/OAuth pair). */
|
|
2608
|
+
const dropInstanceSecrets = async (baseURL) => {
|
|
2609
|
+
for (const ref of [
|
|
2610
|
+
panelTokenRef(baseURL),
|
|
2611
|
+
panelRefreshRef(baseURL),
|
|
2612
|
+
oauthTokenRef(baseURL),
|
|
2613
|
+
oauthRefreshRef(baseURL)
|
|
2614
|
+
]) await credentials.unset(ref).catch(() => undefined);
|
|
2615
|
+
};
|
|
2616
|
+
const gateway = new PanelGateway({
|
|
2617
|
+
baseURL: () => source().baseURL?.trim() ?? "",
|
|
2618
|
+
credentials,
|
|
2619
|
+
onAuthLost: () => void purgeSyncState().catch(() => undefined)
|
|
2620
|
+
});
|
|
2621
|
+
const engine = new Sub2apiSyncEngine({
|
|
2622
|
+
settings: ctx.settings,
|
|
2623
|
+
credentials,
|
|
2624
|
+
gateway,
|
|
2625
|
+
siteName: () => instanceProbe.siteName(instance())
|
|
2626
|
+
});
|
|
2627
|
+
let pendingTypedPassword;
|
|
2628
|
+
/** Conflict-retried write into this plugin's own section (best effort). */
|
|
2629
|
+
const mutateOwn = (ops) => mutateWithRetry(ctx.settings, OWN_NS, () => ops, { missingError: "sub2api-sync: 自身 settings 命名空间未注册" });
|
|
2630
|
+
const instance = () => source().baseURL?.trim() ?? "";
|
|
2631
|
+
const instanceProbe = createInstanceProbe();
|
|
2632
|
+
const oauthAttempts = new OAuthAttemptStore();
|
|
2633
|
+
const deps = {
|
|
2634
|
+
instance,
|
|
2635
|
+
authState: () => gateway.authState(source().email),
|
|
2636
|
+
login: async (email, password) => {
|
|
2637
|
+
const typed = password !== undefined && password.length > 0 ? password : undefined;
|
|
2638
|
+
let pwd = typed ?? (await credentials.resolve(panelPasswordRef(instance())))?.value;
|
|
2639
|
+
if (pwd === undefined || pwd.length === 0) throw new Error("请输入面板密码(首次登录后密码会保存到凭据存储,之后可免输入)");
|
|
2640
|
+
await mutateOwn([{
|
|
2641
|
+
op: "set",
|
|
2642
|
+
path: ["email"],
|
|
2643
|
+
value: email
|
|
2644
|
+
}]).catch(() => undefined);
|
|
2645
|
+
const outcome = await gateway.login(email, pwd);
|
|
2646
|
+
if (outcome.kind === "2fa") {
|
|
2647
|
+
pendingTypedPassword = typed;
|
|
2648
|
+
return {
|
|
2649
|
+
ok: true,
|
|
2650
|
+
kind: "2fa",
|
|
2651
|
+
requires2FA: true
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
if (outcome.kind === "ok" && typed !== undefined) await credentials.set(panelPasswordRef(instance()), typed).catch(() => undefined);
|
|
2655
|
+
pendingTypedPassword = undefined;
|
|
2656
|
+
return {
|
|
2657
|
+
ok: true,
|
|
2658
|
+
kind: "ok"
|
|
2659
|
+
};
|
|
2660
|
+
},
|
|
2661
|
+
login2FA: async (code) => {
|
|
2662
|
+
await gateway.login2FA(code);
|
|
2663
|
+
if (pendingTypedPassword !== undefined) {
|
|
2664
|
+
await credentials.set(panelPasswordRef(instance()), pendingTypedPassword).catch(() => undefined);
|
|
2665
|
+
pendingTypedPassword = undefined;
|
|
2666
|
+
}
|
|
2667
|
+
},
|
|
2668
|
+
logout: async () => {
|
|
2669
|
+
await gateway.logout();
|
|
2670
|
+
await purgeSyncState().catch(() => undefined);
|
|
2671
|
+
},
|
|
2672
|
+
listGroups: async () => {
|
|
2673
|
+
const groups = await gateway.use((panel, token) => panel.listAvailableGroups(token));
|
|
2674
|
+
return groups.map((group) => ({
|
|
2675
|
+
id: group.id,
|
|
2676
|
+
name: group.name,
|
|
2677
|
+
platform: group.platform,
|
|
2678
|
+
status: group.status,
|
|
2679
|
+
rateMultiplier: group.rateMultiplier,
|
|
2680
|
+
isExclusive: group.isExclusive,
|
|
2681
|
+
claudeCodeOnly: group.claudeCodeOnly
|
|
2682
|
+
}));
|
|
2683
|
+
},
|
|
2684
|
+
sync: async (keyIds) => {
|
|
2685
|
+
const report = await engine.sync(source(), keyIds);
|
|
2686
|
+
reports.record(report);
|
|
2687
|
+
return report;
|
|
2688
|
+
},
|
|
2689
|
+
lastReport: () => reports.report(),
|
|
2690
|
+
lastReportAt: () => reports.at(),
|
|
2691
|
+
listKeys: async (page, pageSize) => {
|
|
2692
|
+
const result = await gateway.use((panel, token) => panel.listKeys(token, {
|
|
2693
|
+
page,
|
|
2694
|
+
pageSize
|
|
2695
|
+
}));
|
|
2696
|
+
return {
|
|
2697
|
+
keys: result.items.map((key) => ({
|
|
2698
|
+
id: key.id,
|
|
2699
|
+
name: key.name,
|
|
2700
|
+
groupId: key.groupId,
|
|
2701
|
+
...key.status !== undefined ? { status: key.status } : {}
|
|
2702
|
+
})),
|
|
2703
|
+
total: result.total,
|
|
2704
|
+
pages: result.pages
|
|
2705
|
+
};
|
|
2706
|
+
},
|
|
2707
|
+
createKey: async (input) => {
|
|
2708
|
+
const config$1 = source();
|
|
2709
|
+
const key = await gateway.use((panel, token) => panel.createKey(token, {
|
|
2710
|
+
name: input.name,
|
|
2711
|
+
groupId: input.groupId,
|
|
2712
|
+
...config$1.keyQuota !== undefined ? { quota: config$1.keyQuota } : {},
|
|
2713
|
+
...config$1.keyExpiresInDays !== undefined ? { expiresinDays: config$1.keyExpiresInDays } : {}
|
|
2714
|
+
}));
|
|
2715
|
+
const baseURL = config$1.baseURL?.trim() ?? "";
|
|
2716
|
+
if (key.key !== undefined && key.key.length > 0 && !key.key.includes("****") && /^https?:\/\//i.test(baseURL)) await credentials.set(routeKeyRef(routeForKey(baseURL, key.id)), key.key).catch(() => undefined);
|
|
2717
|
+
return {
|
|
2718
|
+
id: key.id,
|
|
2719
|
+
name: key.name,
|
|
2720
|
+
groupId: key.groupId,
|
|
2721
|
+
...key.status !== undefined ? { status: key.status } : {}
|
|
2722
|
+
};
|
|
2723
|
+
},
|
|
2724
|
+
isSyncRunning: () => engine.isRunning(),
|
|
2725
|
+
icon: () => instanceProbe.icon(instance()),
|
|
2726
|
+
siteName: () => instanceProbe.siteName(instance()),
|
|
2727
|
+
account: () => gateway.use((panel, token) => panel.getProfile(token)),
|
|
2728
|
+
resetSync: async (previousBaseURL) => {
|
|
2729
|
+
await purgeSyncState();
|
|
2730
|
+
if (previousBaseURL !== undefined && previousBaseURL.trim().length > 0) await dropInstanceSecrets(previousBaseURL);
|
|
2731
|
+
},
|
|
2732
|
+
removeSyncedKey: async (keyId) => {
|
|
2733
|
+
const baseURL = instance();
|
|
2734
|
+
if (!/^https?:\/\//i.test(baseURL)) throw new Error("sub2api-sync: baseURL 未配置或不是 http(s) 地址");
|
|
2735
|
+
const entry = reports.report()?.entries.find((candidate) => candidate.keyId === keyId && candidate.invalid !== true);
|
|
2736
|
+
const route = entry?.route ?? routeForKey(baseURL, keyId);
|
|
2737
|
+
if (!isOwnedRoute(route)) throw new Error(`sub2api-sync: 路由 ${route} 不在本插件的所有权边界内`);
|
|
2738
|
+
await mutatePiAi([{
|
|
2739
|
+
op: "unset",
|
|
2740
|
+
path: ["providers", route]
|
|
2741
|
+
}]);
|
|
2742
|
+
await credentials.unset(routeKeyRef(route)).catch(() => undefined);
|
|
2743
|
+
await mutateOwn([{
|
|
2744
|
+
op: "unset",
|
|
2745
|
+
path: ["routeKeyIds", route]
|
|
2746
|
+
}]).catch(() => undefined);
|
|
2747
|
+
const current = reports.report();
|
|
2748
|
+
if (current !== undefined && current.entries.some((candidate) => candidate.route === route)) reports.record({
|
|
2749
|
+
...current,
|
|
2750
|
+
entries: current.entries.filter((candidate) => candidate.route !== route),
|
|
2751
|
+
removed: [...current.removed, route],
|
|
2752
|
+
writes: current.writes + 1
|
|
2753
|
+
});
|
|
2754
|
+
},
|
|
2755
|
+
ownedRoutes: () => {
|
|
2756
|
+
const descriptors = ctx.settings.describe();
|
|
2757
|
+
const routes = describeOwnedRoutes(descriptors.find((descriptor) => descriptor.ns === PI_AI_NS)?.value);
|
|
2758
|
+
const map = (descriptors.find((descriptor) => descriptor.ns === OWN_NS)?.value)?.routeKeyIds ?? {};
|
|
2759
|
+
return routes.map((route) => map[route.route] !== undefined ? {
|
|
2760
|
+
...route,
|
|
2761
|
+
keyId: map[route.route]
|
|
2762
|
+
} : route);
|
|
2763
|
+
},
|
|
2764
|
+
allowRemote: () => source().allowRemoteActions === true,
|
|
2765
|
+
oauthProbe: async (address) => {
|
|
2766
|
+
try {
|
|
2767
|
+
await discoverOAuthEndpoints(address);
|
|
2768
|
+
return true;
|
|
2769
|
+
} catch {
|
|
2770
|
+
resetDiscoveryCache();
|
|
2771
|
+
return false;
|
|
2772
|
+
}
|
|
2773
|
+
},
|
|
2774
|
+
oauthStart: async (callbackOrigin) => {
|
|
2775
|
+
const instanceValue = instance();
|
|
2776
|
+
if (!/^https?:\/\//i.test(instanceValue)) throw new Error("请先填写 sub2api 实例地址,再发起网页授权");
|
|
2777
|
+
const redirectUri = loopbackCallbackOrigin(callbackOrigin);
|
|
2778
|
+
const endpoints = await discoverOAuthEndpoints(instanceValue);
|
|
2779
|
+
const { verifier, challenge } = await createPkcePair();
|
|
2780
|
+
const attempt = oauthAttempts.create({
|
|
2781
|
+
verifier,
|
|
2782
|
+
redirectUri,
|
|
2783
|
+
scope: oauthScopesFor(source().deleteOrphanKeys)
|
|
2784
|
+
});
|
|
2785
|
+
const authorizeUrl = buildAuthorizeUrl(endpoints, {
|
|
2786
|
+
redirectUri,
|
|
2787
|
+
scope: attempt.scope,
|
|
2788
|
+
state: attempt.state,
|
|
2789
|
+
challenge
|
|
2790
|
+
});
|
|
2791
|
+
return {
|
|
2792
|
+
attemptId: attempt.id,
|
|
2793
|
+
authorizeUrl
|
|
2794
|
+
};
|
|
2795
|
+
},
|
|
2796
|
+
oauthCallback: async (query, lang = "zh") => {
|
|
2797
|
+
const state = query.get("state") ?? "";
|
|
2798
|
+
const attempt = state.length > 0 ? oauthAttempts.takeByState(state) : undefined;
|
|
2799
|
+
if (attempt === undefined) return { html: callbackPageHtml("failure", "授权请求不存在、已过期或已被使用——请回到应用重新发起网页授权", lang) };
|
|
2800
|
+
const providerError = query.get("error");
|
|
2801
|
+
if (providerError !== null && providerError.length > 0) {
|
|
2802
|
+
const description = query.get("error_description") ?? "";
|
|
2803
|
+
oauthAttempts.settle(attempt.id, "failed", providerError);
|
|
2804
|
+
return { html: callbackPageHtml("failure", `授权被拒绝(${providerError})${description.length > 0 ? `:${description}` : ""}`, lang) };
|
|
2805
|
+
}
|
|
2806
|
+
const code = query.get("code") ?? "";
|
|
2807
|
+
if (code.length === 0) {
|
|
2808
|
+
oauthAttempts.settle(attempt.id, "failed", "回调缺少授权码");
|
|
2809
|
+
return { html: callbackPageHtml("failure", "授权回调缺少授权码(code)——请回到应用重新发起", lang) };
|
|
2810
|
+
}
|
|
2811
|
+
try {
|
|
2812
|
+
const endpoints = await discoverOAuthEndpoints(instance());
|
|
2813
|
+
const pair = await exchangeAuthorizationCode(endpoints, {
|
|
2814
|
+
code,
|
|
2815
|
+
redirectUri: attempt.redirectUri,
|
|
2816
|
+
verifier: attempt.verifier
|
|
2817
|
+
});
|
|
2818
|
+
await gateway.storeOAuthTokens(pair);
|
|
2819
|
+
oauthAttempts.settle(attempt.id, "completed");
|
|
2820
|
+
const successMessage = lang === "en" ? "You can close this page and return to the desktop app to continue." : "你可以关闭此页面,返回桌面端继续工作。";
|
|
2821
|
+
return { html: callbackPageHtml("success", successMessage, lang) };
|
|
2822
|
+
} catch (cause) {
|
|
2823
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
2824
|
+
oauthAttempts.settle(attempt.id, "failed", message);
|
|
2825
|
+
return { html: callbackPageHtml("failure", message, lang) };
|
|
2826
|
+
}
|
|
2827
|
+
},
|
|
2828
|
+
oauthStatus: async (attemptId) => {
|
|
2829
|
+
const attempt = attemptId !== undefined ? oauthAttempts.peek(attemptId) : undefined;
|
|
2830
|
+
const email = source().email;
|
|
2831
|
+
return {
|
|
2832
|
+
ok: true,
|
|
2833
|
+
attempt,
|
|
2834
|
+
auth: await gateway.authState(email)
|
|
2835
|
+
};
|
|
2836
|
+
},
|
|
2837
|
+
oauthCancel: async (attemptId) => {
|
|
2838
|
+
oauthAttempts.cancel(attemptId);
|
|
2839
|
+
}
|
|
2840
|
+
};
|
|
2841
|
+
registerRoutes(ctx, deps);
|
|
2842
|
+
let timer;
|
|
2843
|
+
let armedFor;
|
|
2844
|
+
const disarm = () => {
|
|
2845
|
+
if (timer !== undefined) clearInterval(timer);
|
|
2846
|
+
timer = undefined;
|
|
2847
|
+
armedFor = undefined;
|
|
2848
|
+
};
|
|
2849
|
+
const arm = () => {
|
|
2850
|
+
const interval = source().syncIntervalMs;
|
|
2851
|
+
if (interval === undefined || interval < 6e4) {
|
|
2852
|
+
disarm();
|
|
2853
|
+
return;
|
|
2854
|
+
}
|
|
2855
|
+
if (armedFor === interval) return;
|
|
2856
|
+
disarm();
|
|
2857
|
+
timer = setInterval(() => {
|
|
2858
|
+
if (engine.isRunning()) return;
|
|
2859
|
+
void engine.sync(source()).catch((error) => ctx.logger.error(error));
|
|
2860
|
+
}, interval);
|
|
2861
|
+
armedFor = interval;
|
|
2862
|
+
};
|
|
2863
|
+
arm();
|
|
2864
|
+
ctx.on("loader/volatile-update", () => {
|
|
2865
|
+
judge();
|
|
2866
|
+
arm();
|
|
2867
|
+
});
|
|
2868
|
+
ctx.effect(() => () => disarm());
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
//#endregion
|
|
2872
|
+
export { API_PROTOCOLS, Config, DEFAULT_PROVIDER_NAME_TEMPLATE, OAUTH_CALLBACK_PATH, OAUTH_CLIENT_ID, OAuthAttemptStore, OAuthError, OWN_NS, PI_AI_NS, PLATFORMS, PanelClient, PanelError, PanelGateway, ROUTES, SUB2API_RETRY_POLICY, Sub2apiSyncEngine, Sub2apiSyncError, apply, baseURLForProtocol, buildAuthorizeUrl, buildProviderProfile, callbackLangOf, callbackPageHtml, createPkcePair, discoverOAuthEndpoints, exchangeAuthorizationCode, guessPlatformFromModels, inject, instanceKey, isOwnedRoute, legacyKeyIdOfRoute, legacyKeyRef, loopbackCallbackOrigin, name, oauthRefreshRef, oauthScopesFor, oauthTokenRef, panelPasswordRef, panelRefreshRef, panelTokenRef, parseGroupId, protocolForPlatform, refreshAccessToken, renderProviderName, revokeToken, routeForKey, routeKeyRef, trustedRequest, validateConfig };
|