@linxin666/dsh-pet 0.2.9 → 0.3.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.i18n.yaml +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/assets/whale-refined/previews/idle.gif +0 -0
- package/lib/client.js +68 -29
- package/lib/client.js.map +1 -1
- package/lib/index.js +97 -51
- package/lib/types/client/PetSettingsCard.d.ts +5 -2
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +29 -5
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +27 -19
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +7 -2
- package/lib/types/http.d.ts +43 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/http.js +92 -0
- package/lib/types/registry.d.ts +8 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +37 -9
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +16 -44
- package/package.json +1 -1
- package/src/client/PetSettingsCard.tsx +23 -5
- package/src/client/PetSprite.test.tsx +16 -0
- package/src/client/PetSprite.tsx +51 -27
- package/src/client/index.ts +7 -2
- package/src/client/pet.module.css +34 -2
- package/src/client/settings-section.module.css +3 -3
- package/src/http.ts +105 -0
- package/src/registry.test.ts +83 -1
- package/src/registry.ts +40 -8
- package/src/routes.ts +16 -45
package/src/registry.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { closeSync, existsSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
DEFAULT_TRACK_PATTERNS,
|
|
9
9
|
PET_ROW_ORDER,
|
|
10
10
|
PET_SCAN_JSON_CAP,
|
|
11
|
+
PET_SCAN_LIVE2D_MODEL_CAP,
|
|
11
12
|
codexPetsDir,
|
|
12
13
|
loadPetRegistry,
|
|
13
14
|
petAtlasFile,
|
|
@@ -261,6 +262,41 @@ describe('loadPetRegistry', () => {
|
|
|
261
262
|
rmSync(root, { recursive: true, force: true })
|
|
262
263
|
}
|
|
263
264
|
})
|
|
265
|
+
|
|
266
|
+
it('skips an oversized pet.json with a warning instead of reading it', () => {
|
|
267
|
+
const root = tempDir()
|
|
268
|
+
try {
|
|
269
|
+
const petsDir = join(root, 'pets')
|
|
270
|
+
mkdirSync(join(petsDir, 'loud'), { recursive: true })
|
|
271
|
+
writeFileSync(join(petsDir, 'loud', 'pet.json'), '{ ' + 'x'.repeat(PET_SCAN_JSON_CAP) + ' }', 'utf8')
|
|
272
|
+
// A healthy neighbor keeps listing while the pathological one is skipped.
|
|
273
|
+
mkdirSync(join(petsDir, 'plain'), { recursive: true })
|
|
274
|
+
writeFileSync(join(petsDir, 'plain', 'pet.json'), JSON.stringify({
|
|
275
|
+
id: 'plain', displayName: 'Plain', spritesheetPath: 'spritesheet.webp',
|
|
276
|
+
}), 'utf8')
|
|
277
|
+
writeFileSync(join(petsDir, 'plain', 'spritesheet.webp'), 'webp', 'utf8')
|
|
278
|
+
const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
|
|
279
|
+
expect(registry.byId('loud')).toBeUndefined()
|
|
280
|
+
expect(registry.byId('plain')).toBeDefined()
|
|
281
|
+
expect(registry.warnings.some(w => w.includes('scan ceiling'))).toBe(true)
|
|
282
|
+
} finally {
|
|
283
|
+
rmSync(root, { recursive: true, force: true })
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('skips a non-regular pet.json with a warning', () => {
|
|
288
|
+
const root = tempDir()
|
|
289
|
+
try {
|
|
290
|
+
const petsDir = join(root, 'pets')
|
|
291
|
+
mkdirSync(join(petsDir, 'odd', 'pet.json'), { recursive: true })
|
|
292
|
+
const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
|
|
293
|
+
expect(registry.entries.map(entry => entry.id)).toEqual([])
|
|
294
|
+
expect(registry.warnings.some(w => w.includes('not a regular file'))).toBe(true)
|
|
295
|
+
expect(registry.diagnostics.some(d => d.level === 'warning' && d.message.includes('pet manifest is not a regular file'))).toBe(true)
|
|
296
|
+
} finally {
|
|
297
|
+
rmSync(root, { recursive: true, force: true })
|
|
298
|
+
}
|
|
299
|
+
})
|
|
264
300
|
})
|
|
265
301
|
|
|
266
302
|
describe('codexPetsDir', () => {
|
|
@@ -479,6 +515,52 @@ describe('loadPetRegistry pet-center v2 (issue #623)', () => {
|
|
|
479
515
|
rmSync(root, { recursive: true, force: true })
|
|
480
516
|
}
|
|
481
517
|
})
|
|
518
|
+
|
|
519
|
+
it('skips a live2d pet whose model3.json exceeds the model scan ceiling with a warning', () => {
|
|
520
|
+
const root = tempDir()
|
|
521
|
+
try {
|
|
522
|
+
const petsDir = join(root, 'pets')
|
|
523
|
+
mkdirSync(join(petsDir, 'haru'), { recursive: true })
|
|
524
|
+
writeFileSync(join(petsDir, 'haru', 'pet.json'), JSON.stringify({
|
|
525
|
+
petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
|
|
526
|
+
renderer: 'live2d', live2d: { model: 'haru.model3.json', motions: { idle: 'Idle' } },
|
|
527
|
+
}), 'utf8')
|
|
528
|
+
// A sparse file one byte past the ceiling: stat reports the size
|
|
529
|
+
// without materializing 32 MB of bytes on disk.
|
|
530
|
+
const fd = openSync(join(petsDir, 'haru', 'haru.model3.json'), 'w')
|
|
531
|
+
try {
|
|
532
|
+
ftruncateSync(fd, PET_SCAN_LIVE2D_MODEL_CAP + 1)
|
|
533
|
+
} finally {
|
|
534
|
+
closeSync(fd)
|
|
535
|
+
}
|
|
536
|
+
const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
|
|
537
|
+
expect(registry.byId('haru')).toBeUndefined()
|
|
538
|
+
expect(registry.warnings.some(w => w.includes('scan ceiling'))).toBe(true)
|
|
539
|
+
expect(registry.diagnostics.some(d => d.level === 'warning'
|
|
540
|
+
&& d.message.includes('exceeds the ' + PET_SCAN_LIVE2D_MODEL_CAP + '-byte scan ceiling'))).toBe(true)
|
|
541
|
+
} finally {
|
|
542
|
+
rmSync(root, { recursive: true, force: true })
|
|
543
|
+
}
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
it('skips a live2d pet whose model3.json is not a regular file with a warning', () => {
|
|
547
|
+
const root = tempDir()
|
|
548
|
+
try {
|
|
549
|
+
const petsDir = join(root, 'pets')
|
|
550
|
+
mkdirSync(join(petsDir, 'haru', 'haru.model3.json'), { recursive: true })
|
|
551
|
+
writeFileSync(join(petsDir, 'haru', 'pet.json'), JSON.stringify({
|
|
552
|
+
petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
|
|
553
|
+
renderer: 'live2d', live2d: { model: 'haru.model3.json', motions: { idle: 'Idle' } },
|
|
554
|
+
}), 'utf8')
|
|
555
|
+
const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
|
|
556
|
+
expect(registry.byId('haru')).toBeUndefined()
|
|
557
|
+
expect(registry.warnings.some(w => w.includes('not a regular file'))).toBe(true)
|
|
558
|
+
expect(registry.diagnostics.some(d => d.level === 'warning'
|
|
559
|
+
&& d.message.includes('live2d model haru.model3.json is not a regular file'))).toBe(true)
|
|
560
|
+
} finally {
|
|
561
|
+
rmSync(root, { recursive: true, force: true })
|
|
562
|
+
}
|
|
563
|
+
})
|
|
482
564
|
})
|
|
483
565
|
|
|
484
566
|
describe('voice packs (pet-center M4, issue #677)', () => {
|
package/src/registry.ts
CHANGED
|
@@ -516,9 +516,20 @@ function resolveLive2dEntry(
|
|
|
516
516
|
record('error', 'pet ' + manifest.id + ': renderer live2d requires a live2d block')
|
|
517
517
|
return undefined
|
|
518
518
|
}
|
|
519
|
+
const modelFile = join(dir, block.model)
|
|
519
520
|
let model3: unknown
|
|
520
521
|
try {
|
|
521
|
-
|
|
522
|
+
// Stat guard before the read: a pathological model file — huge, or a
|
|
523
|
+
// FIFO/device — is skipped with a warning instead of stalling the host
|
|
524
|
+
// at scan time, mirroring the voice/decoration descriptor discipline.
|
|
525
|
+
// The guard stays silent on stat errors, so a missing or unreadable
|
|
526
|
+
// path is re-stat'ed here to fall through to the original fail-closed
|
|
527
|
+
// 'not readable' diagnostic below.
|
|
528
|
+
if (guardedScannedJsonStat(modelFile, options, 'live2d model ' + block.model, PET_SCAN_LIVE2D_MODEL_CAP) === undefined) {
|
|
529
|
+
statSync(modelFile)
|
|
530
|
+
return undefined
|
|
531
|
+
}
|
|
532
|
+
model3 = JSON.parse(readFileSync(modelFile, 'utf8'))
|
|
522
533
|
} catch (error) {
|
|
523
534
|
record('error', 'pet ' + manifest.id + ': live2d model ' + block.model + ' is not readable: '
|
|
524
535
|
+ (error instanceof Error ? error.message : String(error)))
|
|
@@ -583,7 +594,7 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
|
|
|
583
594
|
for (const name of names) {
|
|
584
595
|
const manifestFile = join(dir, name, 'pet.json')
|
|
585
596
|
if (!existsSync(manifestFile)) continue
|
|
586
|
-
const parsed = readPetJson(manifestFile, options
|
|
597
|
+
const parsed = readPetJson(manifestFile, options)
|
|
587
598
|
if (parsed === undefined) continue
|
|
588
599
|
const entryDir = join(dir, name)
|
|
589
600
|
const verdict = parsePetManifest(parsed, entryDir)
|
|
@@ -613,12 +624,21 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
|
|
|
613
624
|
return entries
|
|
614
625
|
}
|
|
615
626
|
|
|
616
|
-
/**
|
|
617
|
-
|
|
627
|
+
/**
|
|
628
|
+
* Read and parse one pet.json manifest; undefined (warning recorded) on
|
|
629
|
+
* failure. The descriptor stat guard applies first: a pathological file —
|
|
630
|
+
* huge, or a FIFO/device — is skipped with a warning instead of stalling
|
|
631
|
+
* or OOM-ing the host at scan time (same discipline as voice/decoration).
|
|
632
|
+
*/
|
|
633
|
+
function readPetJson(
|
|
634
|
+
file: string,
|
|
635
|
+
options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
|
|
636
|
+
): unknown {
|
|
637
|
+
if (guardedScannedJsonStat(file, options, 'pet manifest') === undefined) return undefined
|
|
618
638
|
try {
|
|
619
639
|
return JSON.parse(readFileSync(file, 'utf8'))
|
|
620
640
|
} catch (error) {
|
|
621
|
-
warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)))
|
|
641
|
+
options.warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)))
|
|
622
642
|
return undefined
|
|
623
643
|
}
|
|
624
644
|
}
|
|
@@ -632,16 +652,28 @@ function readPetJson(file: string, warnings: string[] | undefined): unknown {
|
|
|
632
652
|
*/
|
|
633
653
|
export const PET_SCAN_JSON_CAP = 64 * 1024
|
|
634
654
|
|
|
655
|
+
/**
|
|
656
|
+
* Scan-time read ceiling for a live2d model3.json, matching the asset
|
|
657
|
+
* route's model cap (PET_ASSET_CAPS.model). Model descriptors are far
|
|
658
|
+
* larger than the other scanned JSON, but a pathological file — huge, or a
|
|
659
|
+
* FIFO/device — must still be skipped with a warning instead of stalling
|
|
660
|
+
* or OOM-ing the host at plugin startup (same review-spd follow-up).
|
|
661
|
+
*/
|
|
662
|
+
export const PET_SCAN_LIVE2D_MODEL_CAP = 32 * 1024 * 1024
|
|
663
|
+
|
|
635
664
|
/**
|
|
636
665
|
* Stat one scanned JSON descriptor with a regular-file + size guard, so a
|
|
637
666
|
* pathological user file is skipped with a warning instead of stalling or
|
|
638
667
|
* OOM-ing the host at startup. Returns the Stats, or undefined when the
|
|
639
|
-
* caller must skip the file (a warning was recorded).
|
|
668
|
+
* caller must skip the file (a warning was recorded). 'cap' defaults to
|
|
669
|
+
* the descriptor ceiling (PET_SCAN_JSON_CAP); model descriptors pass the
|
|
670
|
+
* larger live2d ceiling.
|
|
640
671
|
*/
|
|
641
672
|
function guardedScannedJsonStat(
|
|
642
673
|
file: string,
|
|
643
674
|
options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
|
|
644
675
|
what: string,
|
|
676
|
+
cap: number = PET_SCAN_JSON_CAP,
|
|
645
677
|
): ReturnType<typeof statSync> | undefined {
|
|
646
678
|
let st: ReturnType<typeof statSync>
|
|
647
679
|
try {
|
|
@@ -657,8 +689,8 @@ function guardedScannedJsonStat(
|
|
|
657
689
|
warn(what + ' is not a regular file; ignored')
|
|
658
690
|
return undefined
|
|
659
691
|
}
|
|
660
|
-
if (st.size >
|
|
661
|
-
warn(what + ' exceeds the ' +
|
|
692
|
+
if (st.size > cap) {
|
|
693
|
+
warn(what + ' exceeds the ' + cap + '-byte scan ceiling; ignored')
|
|
662
694
|
return undefined
|
|
663
695
|
}
|
|
664
696
|
return st
|
package/src/routes.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { PetInteraction } from './affinity.ts'
|
|
|
23
23
|
import { DECORATION_ASSET_PREFIX, petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
|
|
24
24
|
import { isPetAllowed } from './access.ts'
|
|
25
25
|
import { dshHome } from './dsh-home.ts'
|
|
26
|
+
import { readJsonBody, writeJson } from './http.ts'
|
|
26
27
|
|
|
27
28
|
/** Browser-facing base path of the pet API. */
|
|
28
29
|
export const PET_API_PREFIX = '/api/pet'
|
|
@@ -97,52 +98,17 @@ function mimeFor(file: string): string {
|
|
|
97
98
|
return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? 'application/octet-stream'
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
/** Write one JSON response. */
|
|
101
|
-
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
102
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
103
|
-
res.end(JSON.stringify(body))
|
|
104
|
-
}
|
|
105
|
-
|
|
106
101
|
/** Require the method or answer 405. */
|
|
107
102
|
function requireMethod(req: IncomingMessage, res: ServerResponse, method: string): boolean {
|
|
108
103
|
if (req.method === method) return true
|
|
109
|
-
|
|
104
|
+
writeJson(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
110
105
|
return false
|
|
111
106
|
}
|
|
112
107
|
|
|
113
|
-
/** Read a JSON request body (bounded). */
|
|
114
|
-
function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
115
|
-
return new Promise((resolve, reject) => {
|
|
116
|
-
let size = 0
|
|
117
|
-
const chunks: Buffer[] = []
|
|
118
|
-
req.on('data', (chunk: Buffer) => {
|
|
119
|
-
size += chunk.length
|
|
120
|
-
if (size > 64 * 1024) {
|
|
121
|
-
reject(new Error('body-too-large'))
|
|
122
|
-
queueMicrotask(() => req.destroy())
|
|
123
|
-
return
|
|
124
|
-
}
|
|
125
|
-
chunks.push(chunk)
|
|
126
|
-
})
|
|
127
|
-
req.on('end', () => {
|
|
128
|
-
if (chunks.length === 0) {
|
|
129
|
-
resolve({})
|
|
130
|
-
return
|
|
131
|
-
}
|
|
132
|
-
try {
|
|
133
|
-
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
134
|
-
} catch {
|
|
135
|
-
reject(new Error('invalid-json'))
|
|
136
|
-
}
|
|
137
|
-
})
|
|
138
|
-
req.on('error', reject)
|
|
139
|
-
})
|
|
140
|
-
}
|
|
141
|
-
|
|
142
108
|
/** Shared route fence: loopback always passes; a live paired-device cookie is an extra allow path. */
|
|
143
109
|
function guard(ctx: Context, req: IncomingMessage, res: ServerResponse): boolean {
|
|
144
110
|
if (isPetAllowed(ctx, req)) return true
|
|
145
|
-
|
|
111
|
+
writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
146
112
|
return false
|
|
147
113
|
}
|
|
148
114
|
|
|
@@ -154,8 +120,8 @@ function getRoute(ctx: Context, path: string, run: () => Promise<unknown>): WebR
|
|
|
154
120
|
handler: (req: IncomingMessage, res: ServerResponse): void => {
|
|
155
121
|
if (!guard(ctx, req, res)) return
|
|
156
122
|
if (!requireMethod(req, res, 'GET')) return
|
|
157
|
-
run().then((value) =>
|
|
158
|
-
|
|
123
|
+
run().then((value) => writeJson(res, 200, value), (error) => {
|
|
124
|
+
writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
159
125
|
})
|
|
160
126
|
},
|
|
161
127
|
}
|
|
@@ -169,16 +135,21 @@ function postRoute(ctx: Context, path: string, run: (body: Record<string, unknow
|
|
|
169
135
|
handler: (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
170
136
|
if (!guard(ctx, req, res)) return Promise.resolve()
|
|
171
137
|
if (!requireMethod(req, res, 'POST')) return Promise.resolve()
|
|
172
|
-
|
|
173
|
-
|
|
138
|
+
// Shared lenient reader (64 KiB cap): an empty body yields null and is
|
|
139
|
+
// restored to {} at the call site (legacy empty-body semantics); invalid
|
|
140
|
+
// JSON and over-limit bodies also yield null, so the endpoint validators
|
|
141
|
+
// below keep answering 400 with the same { ok: false, error } envelope.
|
|
142
|
+
return readJsonBody(req, { maxBytes: 64 * 1024 }).then((parsed) => {
|
|
143
|
+
const payload = parsed ?? {}
|
|
144
|
+
const record = (typeof payload === 'object' && payload !== null) ? payload as Record<string, unknown> : {}
|
|
174
145
|
return run(record).then(
|
|
175
|
-
(value) =>
|
|
146
|
+
(value) => writeJson(res, 200, value),
|
|
176
147
|
(error) => {
|
|
177
|
-
|
|
148
|
+
writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
178
149
|
},
|
|
179
150
|
)
|
|
180
151
|
}, (error) => {
|
|
181
|
-
|
|
152
|
+
writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
182
153
|
})
|
|
183
154
|
},
|
|
184
155
|
}
|
|
@@ -393,7 +364,7 @@ function runtimeHandler(ctx: Context, roots: { runtimeDir: string; vendorDir: st
|
|
|
393
364
|
const base = spec.root === 'runtimeDir' ? roots.runtimeDir : roots.vendorDir
|
|
394
365
|
const file = join(base, name)
|
|
395
366
|
if (!existsSync(file)) {
|
|
396
|
-
|
|
367
|
+
writeJson(res, 404, { ok: false, error: 'runtime-file-missing', file: name })
|
|
397
368
|
return
|
|
398
369
|
}
|
|
399
370
|
const resolved = containedRealpath(base, file)
|