dsh-arknights-theme 1.0.3 → 1.1.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/README.md +31 -3
- package/assets/skin.css +292 -5
- package/lib/client.js +1072 -15
- package/lib/index.js +264 -9
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -11,17 +11,24 @@
|
|
|
11
11
|
* GET /amiya-wallpaper/ -> assets/wallpapers.json
|
|
12
12
|
* GET /amiya-wallpaper/<file>.webp -> assets/<file>.webp
|
|
13
13
|
* GET /amiya-wallpaper/icons/<n>.svg -> icons/<n>.svg
|
|
14
|
+
* GET /amiya-wallpaper/user/<f> -> an imported picture, if one is stored
|
|
15
|
+
* GET /amiya-wallpaper/upload -> the manifest of imported pictures
|
|
16
|
+
* POST /amiya-wallpaper/upload -> store one imported picture (raw bytes + ?type=&name=)
|
|
17
|
+
* GET|POST /amiya-wallpaper/diag -> the layout-capture channel (see below)
|
|
14
18
|
*
|
|
15
19
|
* Responses are content-addressed by generation time, so they are served
|
|
16
20
|
* `immutable` with a long max-age: the asset set only changes when the import
|
|
17
|
-
* module is re-run and the bundle is reinstalled.
|
|
21
|
+
* module is re-run and the bundle is reinstalled. Imported pictures are named by
|
|
22
|
+
* the hash of their own bytes, so they get the same treatment.
|
|
18
23
|
*
|
|
19
|
-
* Security: the route serves exactly
|
|
24
|
+
* Security: the route serves exactly three directories and rejects any request
|
|
20
25
|
* whose decoded path escapes them, so a traversal segment cannot read outside
|
|
21
|
-
* the package.
|
|
26
|
+
* the package. The only writable endpoint stores under a generated name; nothing
|
|
27
|
+
* accepts a caller-supplied path. GET, HEAD and POST are answered.
|
|
22
28
|
*/
|
|
23
29
|
import os from 'node:os'
|
|
24
|
-
import {
|
|
30
|
+
import { createHash } from 'node:crypto'
|
|
31
|
+
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
25
32
|
import { tmpdir } from 'node:os'
|
|
26
33
|
import path from 'node:path'
|
|
27
34
|
import { fileURLToPath } from 'node:url'
|
|
@@ -65,6 +72,49 @@ export const inject = ['webServer']
|
|
|
65
72
|
*/
|
|
66
73
|
const STATUS_FILE = path.join(tmpdir(), 'dsh-amiya-wallpaper-host.json')
|
|
67
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Where a wallpaper the reader imported lands.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately **outside** the package, next to the other two runtime files: an npm install lives
|
|
79
|
+
* under `node_modules`, and writing into it would (a) be lost on the next upgrade or reinstall,
|
|
80
|
+
* (b) dirty an installation the same way the diagnostics file must not, and (c) put user pictures
|
|
81
|
+
* where a packaging tool could sweep them into a published tarball. A profile directory would be
|
|
82
|
+
* just as wrong — it is Cordis's own state, not this plugin's scratch space.
|
|
83
|
+
*/
|
|
84
|
+
const USER_DIR = path.join(tmpdir(), 'dsh-amiya-wallpaper-user')
|
|
85
|
+
/** The manifest for the imported set, in the same directory as its images. */
|
|
86
|
+
const USER_MANIFEST = path.join(USER_DIR, 'manifest.json')
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Largest image accepted, in bytes. 24 MB covers a phone photo or a downloaded 4K artwork with room
|
|
90
|
+
* to spare; above that the request is more likely a mistake than a wallpaper, and the body is held
|
|
91
|
+
* in memory while it is decoded, so the cap is the guard.
|
|
92
|
+
*/
|
|
93
|
+
const MAX_UPLOAD_BYTES = 24 * 1024 * 1024
|
|
94
|
+
|
|
95
|
+
/** How many imported pictures one install keeps. Older ones are evicted oldest-first. */
|
|
96
|
+
const MAX_USER_WALLPAPERS = 24
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What `DELETE /upload?id=` may remove: a name this module generated, and nothing else.
|
|
100
|
+
* `user-` + 32 hex digits + one of the accepted extensions. Anything that is not exactly that shape
|
|
101
|
+
* is refused before it reaches the filesystem, so the route cannot be pointed at a file it did not
|
|
102
|
+
* write — including by a caller who guesses a shipped asset's name.
|
|
103
|
+
*/
|
|
104
|
+
const USER_FILE_PATTERN = /^user-[0-9a-f]{32}\.[a-z]{3,4}$/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Image types accepted, keyed by MIME → the extension written to disk. The browser half decodes the
|
|
108
|
+
* bytes itself, so what matters here is that only real image formats are stored and served back.
|
|
109
|
+
*/
|
|
110
|
+
const UPLOAD_TYPES = new Map([
|
|
111
|
+
['image/png', '.png'],
|
|
112
|
+
['image/jpeg', '.jpg'],
|
|
113
|
+
['image/webp', '.webp'],
|
|
114
|
+
['image/gif', '.gif'],
|
|
115
|
+
['image/avif', '.avif'],
|
|
116
|
+
])
|
|
117
|
+
|
|
68
118
|
/**
|
|
69
119
|
* Host plugin body: register the asset route for exactly this fiber's lifetime,
|
|
70
120
|
* then record whether that worked.
|
|
@@ -174,6 +224,192 @@ async function writeStatus(detail) {
|
|
|
174
224
|
/** Where the browser half's layout report lands. */
|
|
175
225
|
const DIAG_FILE = path.join(os.tmpdir(), 'dsh-amiya-wallpaper-diag.json')
|
|
176
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Answer `POST /amiya-wallpaper/upload` (store one imported picture) and
|
|
229
|
+
* `GET /amiya-wallpaper/upload` (the manifest of what is stored).
|
|
230
|
+
*
|
|
231
|
+
* The bytes are written under a name this function generates — an id plus the extension taken from
|
|
232
|
+
* the declared MIME type — because a file name arriving from the page would be a path we then serve
|
|
233
|
+
* back out. Nothing here trusts the client's `name`; it is kept only as a label for the picker.
|
|
234
|
+
*
|
|
235
|
+
* @param {import('node:http').IncomingMessage} req
|
|
236
|
+
* @param {import('node:http').ServerResponse} res
|
|
237
|
+
* @param {string} method
|
|
238
|
+
*/
|
|
239
|
+
async function handleUpload(req, res, method) {
|
|
240
|
+
if (method === 'GET') {
|
|
241
|
+
const manifest = await readUserManifest()
|
|
242
|
+
send(res, 200, 'application/json; charset=utf-8', JSON.stringify(manifest))
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
if (method === 'DELETE') {
|
|
246
|
+
/* Forget one import. The id comes in the query and is resolved only through the manifest, so the
|
|
247
|
+
caller names an entry rather than a path — and the generated-name pattern below means even a
|
|
248
|
+
forged manifest row could not point outside the directory. */
|
|
249
|
+
const query = new URL(req.url ?? '', 'http://localhost').searchParams
|
|
250
|
+
const wanted = String(query.get('id') ?? '')
|
|
251
|
+
const manifest = await readUserManifest()
|
|
252
|
+
const kept = []
|
|
253
|
+
const removed = []
|
|
254
|
+
for (const entry of manifest.wallpapers) {
|
|
255
|
+
if (entry.id === wanted && USER_FILE_PATTERN.test(String(entry.file))) removed.push(entry.file)
|
|
256
|
+
else kept.push(entry)
|
|
257
|
+
}
|
|
258
|
+
if (removed.length === 0) {
|
|
259
|
+
send(res, 404, 'text/plain; charset=utf-8', 'no such import')
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
manifest.wallpapers = kept
|
|
263
|
+
await writeUserManifest(manifest)
|
|
264
|
+
for (const file of removed) {
|
|
265
|
+
await rm(path.join(USER_DIR, file), { force: true }).catch(() => {})
|
|
266
|
+
}
|
|
267
|
+
send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ ok: true, removed: removed.length }))
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
if (method !== 'POST') {
|
|
271
|
+
res.writeHead(405, { allow: 'GET, POST, DELETE' })
|
|
272
|
+
res.end()
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const query = new URL(req.url ?? '', 'http://localhost').searchParams
|
|
277
|
+
const declaredType = String(query.get('type') ?? '')
|
|
278
|
+
const extension = UPLOAD_TYPES.get(declaredType)
|
|
279
|
+
if (extension === undefined) {
|
|
280
|
+
send(res, 415, 'text/plain; charset=utf-8', 'unsupported image type: ' + (declaredType || '(none)'))
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* Raw bytes, not base64: a wallpaper is megabytes, and encoding it in the page would inflate the
|
|
285
|
+
request by a third for nothing. `readBody` returns text, so the body is read through its own
|
|
286
|
+
binary collector here. */
|
|
287
|
+
const bytes = await readRawBody(req, MAX_UPLOAD_BYTES)
|
|
288
|
+
if (bytes.length === 0) {
|
|
289
|
+
send(res, 400, 'text/plain; charset=utf-8', 'empty body')
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* A magic-number check, because the declared type arrives from the page and costs nothing to lie
|
|
294
|
+
about. This is not a decoder — the browser half decodes the bytes anyway and will fail loudly if
|
|
295
|
+
they are not an image — it is the guard that keeps an arbitrary blob out of a directory the same
|
|
296
|
+
route serves files from. */
|
|
297
|
+
if (!looksLikeImage(bytes, declaredType)) {
|
|
298
|
+
send(res, 415, 'text/plain; charset=utf-8', 'body is not a ' + declaredType)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const id = hashBytes(bytes).slice(0, 32)
|
|
303
|
+
const fileName = 'user-' + id + extension
|
|
304
|
+
await mkdir(USER_DIR, { recursive: true })
|
|
305
|
+
|
|
306
|
+
const manifest = await readUserManifest()
|
|
307
|
+
const already = manifest.wallpapers.some((entry) => entry.file === fileName)
|
|
308
|
+
if (!already) {
|
|
309
|
+
await writeFile(path.join(USER_DIR, fileName), bytes)
|
|
310
|
+
/* A label is stored only when the caller supplies one. Otherwise the row stays nameless and the
|
|
311
|
+
browser half numbers it ("导入 01"), because a file name taken off someone's disk —
|
|
312
|
+
`IMG_2047.jpg`, or worse — is not something to print in their interface forever. */
|
|
313
|
+
const supplied = sanitizeLabel(String(query.get('name') ?? ''))
|
|
314
|
+
manifest.wallpapers.push({
|
|
315
|
+
id,
|
|
316
|
+
file: fileName,
|
|
317
|
+
title: supplied.length > 0 ? supplied : '',
|
|
318
|
+
bytes: bytes.length,
|
|
319
|
+
addedAt: new Date().toISOString(),
|
|
320
|
+
})
|
|
321
|
+
/* Cap the set oldest-first, deleting the file with the entry so the directory cannot accumulate
|
|
322
|
+
pictures no manifest mentions. */
|
|
323
|
+
while (manifest.wallpapers.length > MAX_USER_WALLPAPERS) {
|
|
324
|
+
const [oldest] = manifest.wallpapers.splice(0, 1)
|
|
325
|
+
/* The entry is dropped from the manifest either way, so a file that will not delete is
|
|
326
|
+
unreachable rather than leaked — but it is still attempted, or the directory grows. */
|
|
327
|
+
if (oldest !== undefined) await rm(path.join(USER_DIR, oldest.file), { force: true }).catch(() => {})
|
|
328
|
+
}
|
|
329
|
+
await writeUserManifest(manifest)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/* The entry the page needs to paint: where the file is served from, and whether it was new. */
|
|
333
|
+
const entry = manifest.wallpapers.find((candidate) => candidate.file === fileName)
|
|
334
|
+
send(
|
|
335
|
+
res,
|
|
336
|
+
200,
|
|
337
|
+
'application/json; charset=utf-8',
|
|
338
|
+
JSON.stringify({ ok: true, reused: already, id, file: fileName, route: ROUTE + '/user/', entry }),
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Read a request body as bytes up to `limit`.
|
|
344
|
+
* @param {import('node:http').IncomingMessage} req
|
|
345
|
+
* @param {number} limit
|
|
346
|
+
*/
|
|
347
|
+
async function readRawBody(req, limit) {
|
|
348
|
+
const chunks = []
|
|
349
|
+
let size = 0
|
|
350
|
+
for await (const chunk of req) {
|
|
351
|
+
size += chunk.length
|
|
352
|
+
if (size > limit) throw new Error('image too large (' + size + ' bytes; limit ' + limit + ')')
|
|
353
|
+
chunks.push(chunk)
|
|
354
|
+
}
|
|
355
|
+
return Buffer.concat(chunks)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Check the first bytes against the declared format. PNG/JPEG/GIF/WebP/AVIF each have a fixed
|
|
360
|
+
* signature; AVIF is a container, so its brand field is checked rather than a magic number.
|
|
361
|
+
* @param {Buffer} bytes
|
|
362
|
+
* @param {string} type
|
|
363
|
+
* @returns {boolean}
|
|
364
|
+
*/
|
|
365
|
+
function looksLikeImage(bytes, type) {
|
|
366
|
+
const head = bytes.subarray(0, 16)
|
|
367
|
+
switch (type) {
|
|
368
|
+
case 'image/png':
|
|
369
|
+
return head.length > 8 && head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
370
|
+
case 'image/jpeg':
|
|
371
|
+
return head.length > 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff
|
|
372
|
+
case 'image/gif':
|
|
373
|
+
return head.length > 5 && (head.subarray(0, 3).toString('latin1') === 'GIF' || head.subarray(0, 6).toString('latin1') === 'GIF89a')
|
|
374
|
+
case 'image/webp':
|
|
375
|
+
return head.length > 12 && head.subarray(0, 4).toString('latin1') === 'RIFF' && head.subarray(8, 12).toString('latin1') === 'WEBP'
|
|
376
|
+
case 'image/avif':
|
|
377
|
+
return head.length > 12 && head.subarray(4, 8).toString('latin1') === 'ftyp' && /avif|avis/.test(head.subarray(8, 12).toString('latin1'))
|
|
378
|
+
default:
|
|
379
|
+
return false
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** SHA-256 of the bytes, hex. Content-addressed, so the same picture twice is one file. */
|
|
384
|
+
function hashBytes(bytes) {
|
|
385
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Keep a label short and free of control characters; it is rendered as text in the picker. */
|
|
389
|
+
function sanitizeLabel(value) {
|
|
390
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 48)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The imported set, always with its current shape. A missing or damaged file reads as empty rather
|
|
395
|
+
* than throwing: losing the reader's imported list should never take the whole skin down.
|
|
396
|
+
*/
|
|
397
|
+
async function readUserManifest() {
|
|
398
|
+
try {
|
|
399
|
+
const parsed = JSON.parse(await readFile(USER_MANIFEST, 'utf8'))
|
|
400
|
+
if (Array.isArray(parsed?.wallpapers)) return { wallpapers: parsed.wallpapers }
|
|
401
|
+
} catch {
|
|
402
|
+
/* first import */
|
|
403
|
+
}
|
|
404
|
+
return { wallpapers: [] }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Write the manifest next to the images it describes. */
|
|
408
|
+
async function writeUserManifest(manifest) {
|
|
409
|
+
await mkdir(USER_DIR, { recursive: true })
|
|
410
|
+
await writeFile(USER_MANIFEST, JSON.stringify({ ...manifest, updatedAt: new Date().toISOString() }, null, 1), 'utf8')
|
|
411
|
+
}
|
|
412
|
+
|
|
177
413
|
/**
|
|
178
414
|
* Read a request body up to `limit` bytes.
|
|
179
415
|
* @param {import('node:http').IncomingMessage} req
|
|
@@ -198,6 +434,21 @@ async function readBody(req, limit) {
|
|
|
198
434
|
async function respond(req, res) {
|
|
199
435
|
const method = req.method ?? 'GET'
|
|
200
436
|
|
|
437
|
+
/* Imported wallpapers. The browser half POSTs the raw bytes it just read from disk — with a local
|
|
438
|
+
file there is no server to upload to and no form to submit, so the body *is* the image; the type
|
|
439
|
+
travels in the query rather than in `Content-Type`, which a `fetch` of a Blob sets for us but
|
|
440
|
+
which we do not trust here. Everything else about this route is the same shape as `diag`: its own
|
|
441
|
+
path, its own limits, and the read-only asset route untouched underneath it. */
|
|
442
|
+
try {
|
|
443
|
+
if (decodePath(req.url ?? '').split('?')[0] === 'upload') {
|
|
444
|
+
await handleUpload(req, res, method)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
} catch (error) {
|
|
448
|
+
send(res, 400, 'text/plain; charset=utf-8', 'upload failed: ' + String(error?.message ?? error))
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
|
|
201
452
|
/* Diagnostics channel, written by the browser half: it POSTs a measurement of the
|
|
202
453
|
settings dialog and GET returns the last one. Kept on its own path so the read-only
|
|
203
454
|
asset route stays exactly as strict as it was for everything else. */
|
|
@@ -300,12 +551,13 @@ function decodePath(url) {
|
|
|
300
551
|
}
|
|
301
552
|
|
|
302
553
|
/**
|
|
303
|
-
* Map a relative path onto a file inside the
|
|
554
|
+
* Map a relative path onto a file inside the served directories.
|
|
304
555
|
*
|
|
305
|
-
*
|
|
306
|
-
* file name,
|
|
307
|
-
*
|
|
308
|
-
*
|
|
556
|
+
* Four shapes are accepted, and nothing else: the manifest, a bare wallpaper
|
|
557
|
+
* file name, a name under `assets/` or `icons/`, or one under `user/` — the
|
|
558
|
+
* directory holding pictures this plugin's own upload route wrote. Anything
|
|
559
|
+
* containing a path separator beyond that known prefix, a parent link, or a NUL
|
|
560
|
+
* is refused — which is what blocks traversal and absolute paths.
|
|
309
561
|
*
|
|
310
562
|
* @param {string} relative
|
|
311
563
|
* @returns {string | undefined}
|
|
@@ -316,6 +568,9 @@ function resolveTarget(relative) {
|
|
|
316
568
|
const directories = [
|
|
317
569
|
{ prefix: 'assets/', root: ASSET_ROOT },
|
|
318
570
|
{ prefix: 'icons/', root: ICON_ROOT },
|
|
571
|
+
/* The reader's own imports. Served through the same flat-name rule as everything else, so a
|
|
572
|
+
request can only reach a file in that directory by its exact generated name. */
|
|
573
|
+
{ prefix: 'user/', root: USER_DIR },
|
|
319
574
|
]
|
|
320
575
|
for (const { prefix, root } of directories) {
|
|
321
576
|
if (!relative.startsWith(prefix)) continue
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-arknights-theme",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "为《明日方舟》玩家在 DSH Web 上构建沉浸感:12 张整屏壁纸(按画面自适应的边缘虚化)、按每张图解算的玻璃与文字色、罗德岛图标体系,以及签字式的授权与选择交互。 / Building immersion for Arknights players on the DSH Web GUI: twelve full-screen wallpapers with per-image edge blur, glass and ink solved from each picture, Rhodes Island iconography, and sign-off style approval and choice interactions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|