ehbrowser 1.0.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 +202 -0
- package/README.md +531 -0
- package/dist/api/contract.js +7 -0
- package/dist/api/dto/auth.js +5 -0
- package/dist/api/dto/common.js +6 -0
- package/dist/api/dto/download.js +5 -0
- package/dist/api/dto/favorite.js +5 -0
- package/dist/api/dto/gallery.js +43 -0
- package/dist/api/dto/index.js +6 -0
- package/dist/api/dto/library.js +5 -0
- package/dist/api/dto/log.js +5 -0
- package/dist/api/dto/playlist.js +8 -0
- package/dist/api/dto/settings.js +5 -0
- package/dist/api/dto/storage.js +5 -0
- package/dist/api/dto/translate.js +6 -0
- package/dist/api/envelope.js +41 -0
- package/dist/api/events.js +20 -0
- package/dist/api/index.js +12 -0
- package/dist/api/routes.js +516 -0
- package/dist/config/atomic.js +85 -0
- package/dist/config/auth-setting.js +97 -0
- package/dist/config/cli.js +19 -0
- package/dist/config/db.js +168 -0
- package/dist/config/index.js +48 -0
- package/dist/config/json-store.js +219 -0
- package/dist/config/migrations.js +100 -0
- package/dist/config/schema.js +108 -0
- package/dist/config/user-setting.js +21 -0
- package/dist/config/validate.js +173 -0
- package/dist/eh/api.js +106 -0
- package/dist/eh/archives.js +100 -0
- package/dist/eh/auth.js +89 -0
- package/dist/eh/cookies.js +65 -0
- package/dist/eh/favorites.js +94 -0
- package/dist/eh/html.js +158 -0
- package/dist/eh/http.js +214 -0
- package/dist/eh/index.js +13 -0
- package/dist/eh/types.js +11 -0
- package/dist/eh/urls.js +81 -0
- package/dist/main.js +170 -0
- package/dist/platform/errors.js +76 -0
- package/dist/platform/open-external.js +90 -0
- package/dist/platform/os.js +28 -0
- package/dist/platform/paths.js +140 -0
- package/dist/server.js +1074 -0
- package/dist/services/auth-service.js +276 -0
- package/dist/services/config-service.js +227 -0
- package/dist/services/detail-cache.js +193 -0
- package/dist/services/detail-store.js +208 -0
- package/dist/services/download-file.js +127 -0
- package/dist/services/download-service.js +586 -0
- package/dist/services/favorite-service.js +237 -0
- package/dist/services/gallery-service.js +403 -0
- package/dist/services/local-library.js +471 -0
- package/dist/services/log-service.js +139 -0
- package/dist/services/playlist-service.js +85 -0
- package/dist/services/search-cache.js +78 -0
- package/dist/services/storage-service.js +27 -0
- package/dist/services/translate-service.js +208 -0
- package/dist/services/update-service.js +268 -0
- package/dist/services/upstream-service.js +64 -0
- package/dist/services/zip.js +210 -0
- package/dist/web/assets/index-C0tAHpmx.css +1 -0
- package/dist/web/assets/index-myYEnJ1q.js +4 -0
- package/dist/web/index.html +13 -0
- package/package.json +63 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 本地 HTTP 服务。监听回环地址,按 api/routes.ts 的路由表分发。
|
|
3
|
+
* 安全上有三道:仅绑 127.0.0.1、校验 Origin 与 Host、校验本地访问令牌。
|
|
4
|
+
* 令牌每次启动重新生成并注入界面;SSE 因 EventSource 无法设置请求头,令牌走查询参数。
|
|
5
|
+
*/
|
|
6
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { createServer } from "node:http";
|
|
9
|
+
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { API_EVENT_HEARTBEAT_MS, API_TOKEN_HEADER, API_TOKEN_QUERY_PARAM, ROUTES, fail, ok, statusForError, } from "./api/index.js";
|
|
12
|
+
import { ConfigInvalidError } from "./config/index.js";
|
|
13
|
+
import { LoginRequiredError } from "./eh/index.js";
|
|
14
|
+
import { describeError, describeTransportError, errorCode } from "./platform/errors.js";
|
|
15
|
+
const HOST = "127.0.0.1";
|
|
16
|
+
/** 默认端口。取一个不常用的四位数:8787 常被其他调试服务占用,冲突时需要改配置或传 --port */
|
|
17
|
+
const DEFAULT_PORT = 7727;
|
|
18
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
19
|
+
const MATCHERS = buildMatchers();
|
|
20
|
+
export async function startServer(options) {
|
|
21
|
+
const host = options.host ?? HOST;
|
|
22
|
+
const requestedPort = options.port ?? DEFAULT_PORT;
|
|
23
|
+
const staticDir = options.staticDir ?? (await resolveStaticDir());
|
|
24
|
+
const clients = new Set();
|
|
25
|
+
let startedAt = Math.floor(Date.now() / 1000);
|
|
26
|
+
const server = createServer((request, response) => {
|
|
27
|
+
handle(request, response).catch((error) => {
|
|
28
|
+
if (!response.headersSent) {
|
|
29
|
+
sendError(response, "internal", describeError(error));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
response.end();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
async function handle(request, response) {
|
|
36
|
+
const url = new URL(request.url ?? "/", `http://${host}:${requestedPort}`);
|
|
37
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
38
|
+
if (!isHostAllowed(request.headers.host, host, requestedPort)) {
|
|
39
|
+
sendError(response, "forbidden", "Host 不在允许范围");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!isOriginAllowed(request.headers.origin, host, requestedPort)) {
|
|
43
|
+
sendError(response, "forbidden", "Origin 不在允许范围");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const matched = matchRoute(method, url.pathname);
|
|
47
|
+
if (matched !== null && ROUTES[matched.name].requiresToken) {
|
|
48
|
+
if (!tokenMatches(readToken(request, url), options.token)) {
|
|
49
|
+
sendError(response, "unauthorized", "本地令牌缺失或错误");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (url.pathname.startsWith("/api/")) {
|
|
54
|
+
await handleApi(request, response, url, matched);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
await serveStatic(response, url.pathname, staticDir, options.token, currentPort(), {
|
|
58
|
+
method,
|
|
59
|
+
accept: request.headers.accept ?? "",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function currentPort() {
|
|
63
|
+
const address = server.address();
|
|
64
|
+
return typeof address === "object" && address !== null ? address.port : requestedPort;
|
|
65
|
+
}
|
|
66
|
+
async function handleApi(request, response, url, matched) {
|
|
67
|
+
if (matched === null) {
|
|
68
|
+
sendError(response, "not_found", `无此路由:${request.method} ${url.pathname}`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (matched.name.startsWith("galleries.")) {
|
|
72
|
+
await handleGallery(response, url, matched);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (matched.name.startsWith("auth.")) {
|
|
76
|
+
await handleAuth(request, response, matched);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (matched.name.startsWith("favorites.")) {
|
|
80
|
+
await handleFavorites(request, response, url, matched, options.favorites);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (matched.name.startsWith("playlist.")) {
|
|
84
|
+
await handlePlaylist(request, response, matched, options.playlist);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (matched.name.startsWith("translate.")) {
|
|
88
|
+
await handleTranslate(response, matched, options.translate);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (matched.name === "log.state") {
|
|
92
|
+
const limit = Number(url.searchParams.get("limit") ?? 100);
|
|
93
|
+
sendJson(response, 200, ok(options.logs?.state(limit) ?? {
|
|
94
|
+
enabled: false,
|
|
95
|
+
directory: "",
|
|
96
|
+
file: "",
|
|
97
|
+
defaultDirectory: "",
|
|
98
|
+
entries: [],
|
|
99
|
+
}));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (matched.name.startsWith("storage.")) {
|
|
103
|
+
await handleStorage(request, response, matched, options.storage);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (matched.name.startsWith("library.")) {
|
|
107
|
+
await handleLibrary(request, response, matched, options.library, options.updates);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (matched.name.startsWith("downloads.")) {
|
|
111
|
+
await handleDownloads(request, response, matched);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
switch (matched.name) {
|
|
115
|
+
case "system.health": {
|
|
116
|
+
sendJson(response, 200, ok(health()));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
case "config.get": {
|
|
120
|
+
sendJson(response, 200, ok(options.service.snapshot()));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
case "config.paths": {
|
|
124
|
+
sendJson(response, 200, ok(options.service.pathsInfo()));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
case "config.patch": {
|
|
128
|
+
let body;
|
|
129
|
+
try {
|
|
130
|
+
body = await readJsonBody(request);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
sendError(response, "bad_request", describeError(error));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const snapshot = await options.service.patchUserSetting(body);
|
|
138
|
+
sendJson(response, 200, ok(snapshot));
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (error instanceof ConfigInvalidError) {
|
|
142
|
+
sendError(response, "config_invalid", "配置校验失败", error.issues);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
sendError(response, "internal", describeError(error));
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
case "system.shutdown": {
|
|
150
|
+
// 先写完响应再执行关闭流程:否则连接会随服务一起断开,界面只看到网络错误
|
|
151
|
+
sendJson(response, 200, ok({ closing: true }));
|
|
152
|
+
setImmediate(() => {
|
|
153
|
+
options.onShutdown?.();
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
case "system.events": {
|
|
158
|
+
openEventStream(request, response);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
default: {
|
|
162
|
+
sendError(response, "not_implemented", `路由尚未实现:${matched.name}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function handleAuth(request, response, matched) {
|
|
167
|
+
const auth = options.auth;
|
|
168
|
+
if (auth === undefined) {
|
|
169
|
+
sendError(response, "not_implemented", "账号服务未接入");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const accountId = matched.params["accountId"] ?? "";
|
|
173
|
+
try {
|
|
174
|
+
switch (matched.name) {
|
|
175
|
+
case "auth.status": {
|
|
176
|
+
sendJson(response, 200, ok(auth.status()));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
case "auth.login": {
|
|
180
|
+
const body = (await readJsonBody(request));
|
|
181
|
+
sendJson(response, 200, ok(await auth.login(body)));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
case "auth.logout": {
|
|
185
|
+
sendJson(response, 200, ok(await auth.logout()));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
case "auth.accounts.list": {
|
|
189
|
+
sendJson(response, 200, ok(auth.listAccounts()));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
case "auth.accounts.create": {
|
|
193
|
+
const body = (await readJsonBody(request));
|
|
194
|
+
sendJson(response, 200, ok(await auth.createAccount(body)));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
case "auth.accounts.update": {
|
|
198
|
+
const body = (await readJsonBody(request));
|
|
199
|
+
sendJson(response, 200, ok(await auth.updateAccount(accountId, body)));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
case "auth.accounts.remove": {
|
|
203
|
+
sendJson(response, 200, ok({ removed: await auth.removeAccount(accountId) }));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
case "auth.accounts.activate": {
|
|
207
|
+
sendJson(response, 200, ok(await auth.activateAccount(accountId)));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
default: {
|
|
211
|
+
sendError(response, "not_implemented", `路由尚未实现:${matched.name}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
sendError(response, classifyError(error), describeError(error));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function handleDownloads(request, response, matched) {
|
|
220
|
+
const downloads = options.downloads;
|
|
221
|
+
if (downloads === undefined) {
|
|
222
|
+
sendError(response, "not_implemented", "下载服务未接入");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const taskId = matched.params["taskId"] ?? "";
|
|
226
|
+
try {
|
|
227
|
+
switch (matched.name) {
|
|
228
|
+
case "downloads.retry": {
|
|
229
|
+
const id = matched.params["taskId"] ?? "";
|
|
230
|
+
sendJson(response, 200, ok(await downloads.retry(id)));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
case "downloads.list": {
|
|
234
|
+
sendJson(response, 200, ok(downloads.list()));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
case "downloads.create": {
|
|
238
|
+
const body = (await readJsonBody(request));
|
|
239
|
+
sendJson(response, 200, ok(await downloads.create(body)));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
case "downloads.cancel": {
|
|
243
|
+
sendJson(response, 200, ok(await downloads.cancel(taskId)));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
default: {
|
|
247
|
+
sendError(response, "not_implemented", `路由尚未实现:${matched.name}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
sendError(response, classifyError(error), describeError(error));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async function handleGallery(response, url, matched) {
|
|
256
|
+
const gallery = options.gallery;
|
|
257
|
+
if (gallery === undefined) {
|
|
258
|
+
sendError(response, "not_implemented", "画廊服务未接入");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// 本地库可能未接入(少见,但确实存在这种组合):未接入时只是用不上详情快照
|
|
262
|
+
const library = options.library;
|
|
263
|
+
const gid = Number(matched.params["gid"]);
|
|
264
|
+
const token = matched.params["token"] ?? "";
|
|
265
|
+
try {
|
|
266
|
+
switch (matched.name) {
|
|
267
|
+
case "galleries.search": {
|
|
268
|
+
sendJson(response, 200, ok(await gallery.search(readSearchQuery(url))));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
case "galleries.searchCache": {
|
|
272
|
+
// 没有缓存时 data 为 null,界面据此决定是否自行发一次检索
|
|
273
|
+
sendJson(response, 200, ok(await gallery.cachedSearch()));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
case "galleries.newer": {
|
|
277
|
+
sendJson(response, 200, ok(await gallery.newer(gid, token)));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
case "galleries.detail": {
|
|
281
|
+
const fresh = isFresh(url);
|
|
282
|
+
/*
|
|
283
|
+
* 本地已有副本且下载时存了详情快照:直接用落盘那一份,不发送任何上游请求。
|
|
284
|
+
* 这是「下载过的画廊本地浏览不再拉详情」的落点:播放器与详情页都走这个接口。
|
|
285
|
+
* fresh 表示要的一定是新数据(更新检查这类),此时不使用快照,仍向上游请求
|
|
286
|
+
*/
|
|
287
|
+
if (!fresh && library !== undefined) {
|
|
288
|
+
const stored = await library.storedDetail(gid, token);
|
|
289
|
+
if (stored !== null) {
|
|
290
|
+
sendJson(response, 200, ok(stored));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const info = await gallery.detail(gid, token, { fresh });
|
|
295
|
+
// 有本地副本却没有快照(老版本下载的数据):此时补存一份,下次打开就不必再请求上游
|
|
296
|
+
if (library !== undefined) {
|
|
297
|
+
try {
|
|
298
|
+
await library.saveDetail(gid, info);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// 快照只是省一次上游请求,写入失败不影响本次返回
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
sendJson(response, 200, ok(info));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
case "galleries.page": {
|
|
308
|
+
const page = Number(matched.params["page"]);
|
|
309
|
+
const pageToken = url.searchParams.get("pageToken") ?? undefined;
|
|
310
|
+
sendJson(response, 200, ok(await gallery.imagePage(gid, token, page, pageToken, {
|
|
311
|
+
fresh: isFresh(url),
|
|
312
|
+
})));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
case "galleries.previews": {
|
|
316
|
+
const index = Number(url.searchParams.get("index") ?? 0);
|
|
317
|
+
sendJson(response, 200, ok(await gallery.previews(gid, token, index, undefined, {
|
|
318
|
+
fresh: isFresh(url),
|
|
319
|
+
})));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
case "galleries.detailCache": {
|
|
323
|
+
sendJson(response, 200, ok(gallery.detailCacheStats()));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
case "galleries.detailCache.clear": {
|
|
327
|
+
sendJson(response, 200, ok(gallery.clearDetailCache()));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
case "galleries.torrents": {
|
|
331
|
+
sendJson(response, 200, ok(await gallery.torrents(gid, token)));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
case "galleries.archives": {
|
|
335
|
+
sendJson(response, 200, ok(await gallery.archives(gid, token)));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
default: {
|
|
339
|
+
sendError(response, "not_implemented", `路由尚未实现:${matched.name}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
if (error instanceof LoginRequiredError) {
|
|
345
|
+
sendError(response, "not_logged_in", error.message);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// 上游不可达、页面结构变更、解析失败都归到这里
|
|
349
|
+
sendError(response, "upstream_unavailable", describeTransportError(error));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function health() {
|
|
353
|
+
const now = Math.floor(Date.now() / 1000);
|
|
354
|
+
return {
|
|
355
|
+
status: "ok",
|
|
356
|
+
version: options.version,
|
|
357
|
+
nodeVersion: process.version,
|
|
358
|
+
platform: process.platform === "win32"
|
|
359
|
+
? "windows"
|
|
360
|
+
: process.platform === "darwin"
|
|
361
|
+
? "macos"
|
|
362
|
+
: "linux",
|
|
363
|
+
startedAt,
|
|
364
|
+
uptimeSeconds: now - startedAt,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function openEventStream(request, response) {
|
|
368
|
+
response.writeHead(200, {
|
|
369
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
370
|
+
"cache-control": "no-cache, no-transform",
|
|
371
|
+
connection: "keep-alive",
|
|
372
|
+
"x-accel-buffering": "no",
|
|
373
|
+
});
|
|
374
|
+
response.write("retry: 3000\n\n");
|
|
375
|
+
clients.add(response);
|
|
376
|
+
const heartbeat = setInterval(() => {
|
|
377
|
+
response.write(": ping\n\n");
|
|
378
|
+
}, API_EVENT_HEARTBEAT_MS);
|
|
379
|
+
request.on("close", () => {
|
|
380
|
+
clearInterval(heartbeat);
|
|
381
|
+
clients.delete(response);
|
|
382
|
+
});
|
|
383
|
+
writeEvent(response, "server.ready", { version: options.version, startedAt });
|
|
384
|
+
}
|
|
385
|
+
options.service.onChange((snapshot) => {
|
|
386
|
+
broadcast("config.changed", { snapshot });
|
|
387
|
+
});
|
|
388
|
+
options.auth?.onChange((status) => {
|
|
389
|
+
broadcast("auth.changed", { status });
|
|
390
|
+
});
|
|
391
|
+
options.downloads?.onChange((task) => {
|
|
392
|
+
broadcast("download.changed", { task });
|
|
393
|
+
});
|
|
394
|
+
options.updates?.onChange((entry) => {
|
|
395
|
+
broadcast("library.update", entry);
|
|
396
|
+
});
|
|
397
|
+
// 进度单独推送:查完最后一条时队列已经为空,但那一轮尚未结束,界面要等这条事件才知道处理完毕
|
|
398
|
+
options.updates?.onState(({ checking, pending }) => {
|
|
399
|
+
broadcast("library.progress", { checking, pending });
|
|
400
|
+
});
|
|
401
|
+
options.logs?.onChange(({ level, message, at, tag }) => {
|
|
402
|
+
broadcast("log.appended", { level, tag, message, at });
|
|
403
|
+
});
|
|
404
|
+
function broadcast(event, data) {
|
|
405
|
+
for (const client of clients) {
|
|
406
|
+
writeEvent(client, event, data);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
410
|
+
server.once("error", rejectPromise);
|
|
411
|
+
server.listen(requestedPort, host, () => {
|
|
412
|
+
startedAt = Math.floor(Date.now() / 1000);
|
|
413
|
+
resolvePromise();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
const boundPort = currentPort();
|
|
417
|
+
return {
|
|
418
|
+
url: `http://localhost:${boundPort}/`,
|
|
419
|
+
host,
|
|
420
|
+
port: boundPort,
|
|
421
|
+
token: options.token,
|
|
422
|
+
broadcast,
|
|
423
|
+
async close() {
|
|
424
|
+
for (const client of clients) {
|
|
425
|
+
client.end();
|
|
426
|
+
}
|
|
427
|
+
clients.clear();
|
|
428
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
429
|
+
server.close((error) => {
|
|
430
|
+
if (error) {
|
|
431
|
+
rejectPromise(error);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
resolvePromise();
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/** 启动时生成一次性令牌 */
|
|
441
|
+
export function generateToken() {
|
|
442
|
+
return randomBytes(16).toString("hex");
|
|
443
|
+
}
|
|
444
|
+
function buildMatchers() {
|
|
445
|
+
return Object.keys(ROUTES).map((name) => {
|
|
446
|
+
const route = ROUTES[name];
|
|
447
|
+
const keys = [];
|
|
448
|
+
const pattern = route.path.replace(/:([A-Za-z0-9_]+)/g, (_match, key) => {
|
|
449
|
+
keys.push(key);
|
|
450
|
+
return "([^/]+)";
|
|
451
|
+
});
|
|
452
|
+
return { name, method: route.method, regex: new RegExp(`^${pattern}$`), keys };
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
function matchRoute(method, pathname) {
|
|
456
|
+
for (const matcher of MATCHERS) {
|
|
457
|
+
if (matcher.method !== method) {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const found = matcher.regex.exec(pathname);
|
|
461
|
+
if (found === null) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const params = {};
|
|
465
|
+
matcher.keys.forEach((key, index) => {
|
|
466
|
+
params[key] = decodeURIComponent(found[index + 1] ?? "");
|
|
467
|
+
});
|
|
468
|
+
return { name: matcher.name, method, params };
|
|
469
|
+
}
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
function isHostAllowed(hostHeader, host, port) {
|
|
473
|
+
if (hostHeader === undefined) {
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
const allowed = [`localhost:${port}`, `${host}:${port}`, `[::1]:${port}`];
|
|
477
|
+
return allowed.includes(hostHeader.toLowerCase());
|
|
478
|
+
}
|
|
479
|
+
function isOriginAllowed(origin, host, port) {
|
|
480
|
+
if (origin === undefined) {
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
const allowed = [`http://localhost:${port}`, `http://${host}:${port}`, `http://[::1]:${port}`];
|
|
484
|
+
return allowed.includes(origin.toLowerCase());
|
|
485
|
+
}
|
|
486
|
+
function readToken(request, url) {
|
|
487
|
+
const header = request.headers[API_TOKEN_HEADER];
|
|
488
|
+
if (typeof header === "string" && header !== "") {
|
|
489
|
+
return header;
|
|
490
|
+
}
|
|
491
|
+
return url.searchParams.get(API_TOKEN_QUERY_PARAM);
|
|
492
|
+
}
|
|
493
|
+
/** 定长比较,避免按字符提前返回 */
|
|
494
|
+
function tokenMatches(provided, expected) {
|
|
495
|
+
if (provided === null) {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
const left = Buffer.from(provided);
|
|
499
|
+
const right = Buffer.from(expected);
|
|
500
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
501
|
+
}
|
|
502
|
+
async function readJsonBody(request) {
|
|
503
|
+
const chunks = [];
|
|
504
|
+
let size = 0;
|
|
505
|
+
for await (const chunk of request) {
|
|
506
|
+
const buffer = chunk;
|
|
507
|
+
size += buffer.length;
|
|
508
|
+
if (size > MAX_BODY_BYTES) {
|
|
509
|
+
throw new Error(`请求体超过 ${MAX_BODY_BYTES} 字节`);
|
|
510
|
+
}
|
|
511
|
+
chunks.push(buffer);
|
|
512
|
+
}
|
|
513
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
514
|
+
if (text.trim() === "") {
|
|
515
|
+
throw new Error("请求体为空");
|
|
516
|
+
}
|
|
517
|
+
return JSON.parse(text);
|
|
518
|
+
}
|
|
519
|
+
function sendJson(response, status, payload) {
|
|
520
|
+
const body = `${JSON.stringify(payload)}\n`;
|
|
521
|
+
response.writeHead(status, {
|
|
522
|
+
"content-type": "application/json; charset=utf-8",
|
|
523
|
+
"content-length": Buffer.byteLength(body),
|
|
524
|
+
});
|
|
525
|
+
response.end(body);
|
|
526
|
+
}
|
|
527
|
+
function sendError(response, code, message, issues) {
|
|
528
|
+
sendJson(response, statusForError(code), fail(code, message, { issues }));
|
|
529
|
+
}
|
|
530
|
+
function writeEvent(response, event, data) {
|
|
531
|
+
response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
532
|
+
}
|
|
533
|
+
async function resolveStaticDir() {
|
|
534
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
535
|
+
for (const candidate of [join(root, "dist", "web"), join(root, "web")]) {
|
|
536
|
+
try {
|
|
537
|
+
const info = await stat(candidate);
|
|
538
|
+
if (info.isDirectory()) {
|
|
539
|
+
return candidate;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
// 继续尝试下一个候选目录
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
/** 收藏路由。本地部分是同步的,云端部分失败时只影响那一条 */
|
|
549
|
+
async function handleFavorites(request, response, url, matched, favorites) {
|
|
550
|
+
if (favorites === undefined) {
|
|
551
|
+
sendError(response, "not_implemented", "收藏服务未接入");
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const folderId = matched.params["folderId"] ?? "";
|
|
555
|
+
const gid = Number(matched.params["gid"]);
|
|
556
|
+
async function body() {
|
|
557
|
+
try {
|
|
558
|
+
return await readJsonBody(request);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
sendError(response, "bad_request", describeError(error));
|
|
562
|
+
return undefined;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
switch (matched.name) {
|
|
567
|
+
case "favorites.state": {
|
|
568
|
+
sendJson(response, 200, ok(await favorites.state()));
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
case "favorites.folder.create": {
|
|
572
|
+
const payload = (await body());
|
|
573
|
+
if (payload === undefined) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
sendJson(response, 200, ok(favorites.createFolder(payload.name ?? "")));
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
case "favorites.folder.remove": {
|
|
580
|
+
sendJson(response, 200, ok(favorites.removeFolder(folderId)));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
case "favorites.items": {
|
|
584
|
+
sendJson(response, 200, ok(favorites.items(folderId)));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
case "favorites.items.add": {
|
|
588
|
+
const payload = await body();
|
|
589
|
+
if (payload === undefined) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
sendJson(response, 200, ok(await favorites.add(payload)));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
case "favorites.items.remove": {
|
|
596
|
+
sendJson(response, 200, ok(favorites.removeItem(folderId, gid)));
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
case "favorites.items.slot": {
|
|
600
|
+
const payload = (await body());
|
|
601
|
+
if (payload === undefined) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const slot = Number(payload.slot);
|
|
605
|
+
if (!Number.isFinite(slot)) {
|
|
606
|
+
sendError(response, "bad_request", "slot 必须是数字");
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
sendJson(response, 200, ok(await favorites.setSlot(folderId, gid, slot)));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
case "favorites.cloud": {
|
|
613
|
+
const slot = Number(url.searchParams.get("slot") ?? 0);
|
|
614
|
+
sendJson(response, 200, ok(await favorites.cloudItems(slot)));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
case "favorites.items.refresh": {
|
|
618
|
+
const payload = (await body());
|
|
619
|
+
if (payload === undefined) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const gids = (Array.isArray(payload.gids) ? payload.gids : [])
|
|
623
|
+
.map((value) => Number(value))
|
|
624
|
+
.filter((value) => Number.isFinite(value));
|
|
625
|
+
sendJson(response, 200, ok(await favorites.refresh(folderId, gids)));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
default: {
|
|
629
|
+
sendError(response, "not_implemented", `路由尚未实现:${matched.name}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
if (error instanceof LoginRequiredError) {
|
|
635
|
+
sendError(response, "not_logged_in", error.message);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
sendError(response, "upstream_unavailable", describeTransportError(error));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/** 播放列表路由。每条都返回整份快照,界面收到后可直接替换本地状态 */
|
|
642
|
+
async function handlePlaylist(request, response, matched, playlist) {
|
|
643
|
+
if (playlist === undefined) {
|
|
644
|
+
sendError(response, "not_implemented", "播放列表未接入");
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const key = decodeURIComponent(matched.params["key"] ?? "");
|
|
648
|
+
try {
|
|
649
|
+
switch (matched.name) {
|
|
650
|
+
case "playlist.list": {
|
|
651
|
+
sendJson(response, 200, ok(playlist.snapshot()));
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
case "playlist.add": {
|
|
655
|
+
let body;
|
|
656
|
+
try {
|
|
657
|
+
body = await readJsonBody(request);
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
sendError(response, "bad_request", describeError(error));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
sendJson(response, 200, ok(playlist.add(body)));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
case "playlist.play": {
|
|
667
|
+
sendJson(response, 200, ok(playlist.play(key)));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
case "playlist.progress": {
|
|
671
|
+
let body;
|
|
672
|
+
try {
|
|
673
|
+
body = await readJsonBody(request);
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
sendError(response, "bad_request", describeError(error));
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const page = Number(body.page);
|
|
680
|
+
if (!Number.isFinite(page)) {
|
|
681
|
+
sendError(response, "bad_request", "page 必须是数字");
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
sendJson(response, 200, ok(playlist.progress(key, page)));
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
case "playlist.remove": {
|
|
688
|
+
sendJson(response, 200, ok(playlist.remove(key)));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
case "playlist.clear": {
|
|
692
|
+
sendJson(response, 200, ok(playlist.clear()));
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
default: {
|
|
696
|
+
sendError(response, "not_implemented", `路由尚未实现:${matched.name}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
sendError(response, "internal", describeError(error));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* 标签翻译词库路由
|
|
706
|
+
* 词库不随程序分发,未接入或未安装时回一份空状态,界面照常使用内置的那份
|
|
707
|
+
*/
|
|
708
|
+
async function handleTranslate(response, matched, translate) {
|
|
709
|
+
const empty = {
|
|
710
|
+
installed: false,
|
|
711
|
+
version: null,
|
|
712
|
+
updatedAt: null,
|
|
713
|
+
source: "",
|
|
714
|
+
tagCount: 0,
|
|
715
|
+
namespaceCount: 0,
|
|
716
|
+
updating: false,
|
|
717
|
+
error: null,
|
|
718
|
+
};
|
|
719
|
+
if (translate === undefined) {
|
|
720
|
+
sendJson(response, 200, ok(matched.name === "translate.tags" ? null : empty));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
try {
|
|
724
|
+
switch (matched.name) {
|
|
725
|
+
case "translate.tags": {
|
|
726
|
+
sendJson(response, 200, ok(await translate.database()));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
case "translate.update": {
|
|
730
|
+
sendJson(response, 200, ok(await translate.update()));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
case "translate.remove": {
|
|
734
|
+
sendJson(response, 200, ok(await translate.remove()));
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
default: {
|
|
738
|
+
sendJson(response, 200, ok(await translate.status()));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
sendError(response, "internal", describeError(error));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/*
|
|
747
|
+
* 储存空间路由。storage 在 ServerOptions 中声明为可选参数,未接入时统一返回 not_implemented。
|
|
748
|
+
*/
|
|
749
|
+
async function handleStorage(request, response, matched, storage) {
|
|
750
|
+
if (storage === undefined) {
|
|
751
|
+
sendError(response, "not_implemented", "储存空间未接入");
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
switch (matched.name) {
|
|
756
|
+
case "storage.stats": {
|
|
757
|
+
sendJson(response, 200, ok(await storage.stats()));
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
case "storage.cleanup": {
|
|
761
|
+
let body;
|
|
762
|
+
try {
|
|
763
|
+
body = await readJsonBody(request);
|
|
764
|
+
}
|
|
765
|
+
catch (error) {
|
|
766
|
+
sendError(response, "bad_request", describeError(error));
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const days = Number(body.days);
|
|
770
|
+
if (!Number.isFinite(days) || days < 0) {
|
|
771
|
+
sendError(response, "bad_request", "days 必须是不小于 0 的数字");
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
sendJson(response, 200, ok(await storage.cleanup(days)));
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
default: {
|
|
778
|
+
sendError(response, "not_implemented", `路由尚未实现:${matched.name}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
catch (error) {
|
|
783
|
+
sendError(response, "internal", describeError(error));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* 本地库路由
|
|
788
|
+
* 图片接口直接返回图片本身,因为它要能放进 <img src>,令牌经 query 传递,与 SSE 的处理方式相同
|
|
789
|
+
*/
|
|
790
|
+
async function handleLibrary(request, response, matched, library, updates) {
|
|
791
|
+
// 更新检查只读内存缓存,不依赖本地库是否接入
|
|
792
|
+
if (matched.name === "library.updates") {
|
|
793
|
+
sendJson(response, 200, ok(updates?.state() ?? { checking: false, entries: [], pending: 0 }));
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
if (matched.name === "library.updates.forget") {
|
|
797
|
+
if (updates === undefined) {
|
|
798
|
+
sendJson(response, 200, ok({ checking: false, entries: [], pending: 0 }));
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
let payload;
|
|
802
|
+
try {
|
|
803
|
+
payload = await readJsonBody(request);
|
|
804
|
+
}
|
|
805
|
+
catch (error) {
|
|
806
|
+
sendError(response, "bad_request", describeError(error));
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const keys = payload.keys;
|
|
810
|
+
sendJson(response, 200, ok(updates.forget(Array.isArray(keys) ? keys.map((key) => String(key)) : [])));
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (matched.name === "library.check") {
|
|
814
|
+
if (updates === undefined) {
|
|
815
|
+
sendJson(response, 200, ok({ checking: false, entries: [], pending: 0 }));
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
let body;
|
|
819
|
+
try {
|
|
820
|
+
body = await readJsonBody(request);
|
|
821
|
+
}
|
|
822
|
+
catch (error) {
|
|
823
|
+
sendError(response, "bad_request", describeError(error));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const input = body;
|
|
827
|
+
sendJson(response, 200, ok(await updates.check(input)));
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (library === undefined) {
|
|
831
|
+
sendError(response, "not_implemented", "本地库未接入");
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const gid = Number(matched.params["gid"]);
|
|
835
|
+
const resolution = matched.params["resolution"] ?? "";
|
|
836
|
+
try {
|
|
837
|
+
switch (matched.name) {
|
|
838
|
+
case "library.list": {
|
|
839
|
+
sendJson(response, 200, ok(await library.list()));
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
case "library.progress": {
|
|
843
|
+
let body;
|
|
844
|
+
try {
|
|
845
|
+
body = await readJsonBody(request);
|
|
846
|
+
}
|
|
847
|
+
catch (error) {
|
|
848
|
+
sendError(response, "bad_request", describeError(error));
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const page = Number(body.page);
|
|
852
|
+
if (!Number.isFinite(page)) {
|
|
853
|
+
sendError(response, "bad_request", "page 必须是数字");
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const saved = await library.saveProgress(gid, resolution, page);
|
|
857
|
+
if (saved === null) {
|
|
858
|
+
sendError(response, "not_found", `本地没有这个画廊:${gid} ${resolution}`);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
sendJson(response, 200, ok(saved));
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
case "library.image": {
|
|
865
|
+
const page = Number(matched.params["page"]);
|
|
866
|
+
const file = await library.imagePath(gid, resolution, page);
|
|
867
|
+
if (file === null) {
|
|
868
|
+
sendError(response, "not_found", `本地没有这一页:${gid} ${resolution} 第 ${page} 页`);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const content = await readFile(file);
|
|
872
|
+
response.writeHead(200, {
|
|
873
|
+
"content-type": contentTypeOf(file),
|
|
874
|
+
"content-length": String(content.byteLength),
|
|
875
|
+
// 本地文件不常变,给一小段缓存:播放器来回翻页不必反复读盘
|
|
876
|
+
"cache-control": "private, max-age=300",
|
|
877
|
+
});
|
|
878
|
+
response.end(content);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
case "library.remove": {
|
|
882
|
+
const removed = await library.remove(gid, resolution);
|
|
883
|
+
sendJson(response, 200, ok({ removed }));
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
default: {
|
|
887
|
+
sendError(response, "not_implemented", `路由尚未实现:${matched.name}`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
catch (error) {
|
|
892
|
+
sendError(response, "internal", describeError(error));
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
async function serveStatic(response, pathname, staticDir, token, port, client) {
|
|
896
|
+
if (staticDir === null) {
|
|
897
|
+
sendHtml(response, 200, fallbackPage(token, port));
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
|
|
901
|
+
const target = resolve(staticDir, normalize(relative));
|
|
902
|
+
if (!target.startsWith(staticDir + sep) && target !== staticDir) {
|
|
903
|
+
sendError(response, "forbidden", "路径越界");
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
try {
|
|
907
|
+
const content = await readFile(target);
|
|
908
|
+
if (target.endsWith(".html")) {
|
|
909
|
+
sendHtml(response, 200, injectToken(content.toString("utf8"), token, port));
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
response.writeHead(200, { "content-type": contentTypeOf(target) });
|
|
913
|
+
response.end(content);
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
const index = await readIndexForRoute(staticDir, pathname, client);
|
|
917
|
+
if (index !== null) {
|
|
918
|
+
sendHtml(response, 200, injectToken(index, token, port));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
sendError(response, "not_found", `资源不存在:${pathname}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* 前端用 HTML5 路径,深链与刷新会请求不存在的无扩展名路径,此时回退到界面入口
|
|
926
|
+
* 带扩展名的请求仍按资源处理,否则构建产物过期时会拿到一段 HTML 当脚本执行
|
|
927
|
+
*/
|
|
928
|
+
async function readIndexForRoute(staticDir, pathname, client) {
|
|
929
|
+
if (client.method !== "GET" && client.method !== "HEAD") {
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
if (!client.accept.includes("text/html")) {
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
if (pathname === "/" || pathname.startsWith("/api/") || extname(pathname) !== "") {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
try {
|
|
939
|
+
return (await readFile(resolve(staticDir, "index.html"))).toString("utf8");
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
function sendHtml(response, status, html) {
|
|
946
|
+
response.writeHead(status, {
|
|
947
|
+
"content-type": "text/html; charset=utf-8",
|
|
948
|
+
"cache-control": "no-store",
|
|
949
|
+
});
|
|
950
|
+
response.end(html);
|
|
951
|
+
}
|
|
952
|
+
/** 把令牌与端口注入页面,供界面脚本读取 */
|
|
953
|
+
function injectToken(html, token, port) {
|
|
954
|
+
const boot = `<script>window.__EHBROWSER__=${JSON.stringify({ token, port })}</script>`;
|
|
955
|
+
const head = html.indexOf("</head>");
|
|
956
|
+
return head === -1 ? boot + html : `${html.slice(0, head)}${boot}${html.slice(head)}`;
|
|
957
|
+
}
|
|
958
|
+
function contentTypeOf(file) {
|
|
959
|
+
switch (extname(file).toLowerCase()) {
|
|
960
|
+
case ".js":
|
|
961
|
+
return "text/javascript; charset=utf-8";
|
|
962
|
+
case ".css":
|
|
963
|
+
return "text/css; charset=utf-8";
|
|
964
|
+
case ".json":
|
|
965
|
+
return "application/json; charset=utf-8";
|
|
966
|
+
case ".svg":
|
|
967
|
+
return "image/svg+xml";
|
|
968
|
+
case ".png":
|
|
969
|
+
return "image/png";
|
|
970
|
+
case ".jpg":
|
|
971
|
+
case ".jpeg":
|
|
972
|
+
return "image/jpeg";
|
|
973
|
+
case ".gif":
|
|
974
|
+
return "image/gif";
|
|
975
|
+
case ".webp":
|
|
976
|
+
return "image/webp";
|
|
977
|
+
case ".bmp":
|
|
978
|
+
return "image/bmp";
|
|
979
|
+
case ".avif":
|
|
980
|
+
return "image/avif";
|
|
981
|
+
case ".ico":
|
|
982
|
+
return "image/x-icon";
|
|
983
|
+
// 图标字体(web/src/assets/iconfont.ttf)
|
|
984
|
+
case ".ttf":
|
|
985
|
+
return "font/ttf";
|
|
986
|
+
case ".woff":
|
|
987
|
+
return "font/woff";
|
|
988
|
+
case ".woff2":
|
|
989
|
+
return "font/woff2";
|
|
990
|
+
default:
|
|
991
|
+
return "application/octet-stream";
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/** 未找到静态目录时的兜底页面 */
|
|
995
|
+
function fallbackPage(token, port) {
|
|
996
|
+
return injectToken(`<!doctype html>
|
|
997
|
+
<html lang="zh-CN">
|
|
998
|
+
<head><meta charset="utf-8"><title>EhBrowser</title></head>
|
|
999
|
+
<body>
|
|
1000
|
+
<h1>EhBrowser</h1>
|
|
1001
|
+
<p>未找到静态资源目录(dist/web 或 web),当前为兜底页面。</p>
|
|
1002
|
+
<p>服务端口:${port}</p>
|
|
1003
|
+
</body>
|
|
1004
|
+
</html>
|
|
1005
|
+
`, token, port);
|
|
1006
|
+
}
|
|
1007
|
+
/** 凭据不足、输入不合法归 400;上游不可达归 502 */
|
|
1008
|
+
function classifyError(error) {
|
|
1009
|
+
if (error instanceof LoginRequiredError) {
|
|
1010
|
+
return "not_logged_in";
|
|
1011
|
+
}
|
|
1012
|
+
if (error instanceof ConfigInvalidError) {
|
|
1013
|
+
return "config_invalid";
|
|
1014
|
+
}
|
|
1015
|
+
const name = error instanceof Error ? error.name : "";
|
|
1016
|
+
if (name === "TimeoutError" || errorCode(error).startsWith("UND_ERR") || name === "TypeError") {
|
|
1017
|
+
return "upstream_unavailable";
|
|
1018
|
+
}
|
|
1019
|
+
return "bad_request";
|
|
1020
|
+
}
|
|
1021
|
+
/** fresh 参数:buildQueryString 把布尔值写成 1/0 */
|
|
1022
|
+
function isFresh(url) {
|
|
1023
|
+
const raw = url.searchParams.get("fresh");
|
|
1024
|
+
return raw === "1" || raw === "true";
|
|
1025
|
+
}
|
|
1026
|
+
/** 查询串 -> 搜索条件。逗号分隔的多值参数 */
|
|
1027
|
+
function readSearchQuery(url) {
|
|
1028
|
+
const query = {};
|
|
1029
|
+
const page = readInt(url, "page");
|
|
1030
|
+
query.page = page === null ? 1 : page;
|
|
1031
|
+
const limit = readInt(url, "limit");
|
|
1032
|
+
if (limit !== null) {
|
|
1033
|
+
query.limit = limit;
|
|
1034
|
+
}
|
|
1035
|
+
const text = url.searchParams.get("query");
|
|
1036
|
+
if (text !== null && text !== "") {
|
|
1037
|
+
query.query = text;
|
|
1038
|
+
}
|
|
1039
|
+
const categories = readList(url, "categories");
|
|
1040
|
+
if (categories !== undefined) {
|
|
1041
|
+
query.categories = categories;
|
|
1042
|
+
}
|
|
1043
|
+
const excluded = readList(url, "excludedCategories");
|
|
1044
|
+
if (excluded !== undefined) {
|
|
1045
|
+
query.excludedCategories = excluded;
|
|
1046
|
+
}
|
|
1047
|
+
const language = url.searchParams.get("language");
|
|
1048
|
+
if (language !== null && language !== "") {
|
|
1049
|
+
query.language = language;
|
|
1050
|
+
}
|
|
1051
|
+
const minRating = readInt(url, "minRating");
|
|
1052
|
+
if (minRating !== null) {
|
|
1053
|
+
query.minRating = minRating;
|
|
1054
|
+
}
|
|
1055
|
+
return query;
|
|
1056
|
+
}
|
|
1057
|
+
function readInt(url, key) {
|
|
1058
|
+
const raw = url.searchParams.get(key);
|
|
1059
|
+
if (raw === null || raw.trim() === "") {
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
const value = Number(raw);
|
|
1063
|
+
return Number.isFinite(value) ? Math.trunc(value) : null;
|
|
1064
|
+
}
|
|
1065
|
+
function readList(url, key) {
|
|
1066
|
+
const raw = url.searchParams.get(key);
|
|
1067
|
+
if (raw === null || raw.trim() === "") {
|
|
1068
|
+
return undefined;
|
|
1069
|
+
}
|
|
1070
|
+
return raw
|
|
1071
|
+
.split(",")
|
|
1072
|
+
.map((item) => item.trim())
|
|
1073
|
+
.filter((item) => item !== "");
|
|
1074
|
+
}
|