dsh-arknights-theme 1.0.4 → 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/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 two directories and rejects any request
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. Only GET and HEAD are answered.
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 { readFile, stat, writeFile } from 'node:fs/promises'
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 two served directories.
554
+ * Map a relative path onto a file inside the served directories.
304
555
  *
305
- * Three shapes are accepted, and nothing else: the manifest, a bare wallpaper
306
- * file name, or a name under `assets/` or `icons/`. Anything containing a path
307
- * separator beyond that known prefix, a parent link, or a NUL is refused — which
308
- * is what blocks traversal and absolute paths.
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.4",
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",