@lievclouds/dsh-cortex 0.4.4 → 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.

Potentially problematic release.


This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.

Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/client.js +428 -406
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -106
  7. package/src/cortex-ui/dsh/api.ts +5 -2
  8. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  9. package/src/cortex-ui/renderer/App.tsx +39 -0
  10. package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
  11. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  12. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  13. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  14. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  16. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  17. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
  18. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  19. package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
  20. package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
  21. package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
  22. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  23. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  24. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  25. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  26. package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
  27. package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
  28. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  29. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  30. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  31. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  32. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  33. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
  34. package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
  35. package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
  36. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  37. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  38. package/src/cortex-ui/shared/ipc.ts +3 -4
  39. package/src/cortex-ui/shared/types.ts +9 -0
  40. package/src/host/cortex/fs.js +72 -0
  41. package/src/host/cortex/index.js +49 -1
  42. package/src/host/cortex/repeater-store.js +411 -0
  43. package/src/host/cortex/repeater.js +15 -2
  44. package/src/host/cortex/settings.js +54 -0
  45. package/src/host/cortex/tools.js +2 -0
  46. package/src/host/cortex/vulns.js +36 -13
  47. package/src/host/miniapp/analyze.js +1078 -0
  48. package/src/host/miniapp/assets-import.js +294 -0
  49. package/src/host/miniapp/detect.js +426 -0
  50. package/src/host/miniapp/format.js +263 -0
  51. package/src/host/miniapp/roots.js +360 -0
  52. package/src/host/miniapp/routes.js +304 -0
  53. package/src/host/miniapp/service.js +559 -0
  54. package/src/host/miniapp/store.js +756 -0
  55. package/src/host/miniapp/tools.js +335 -0
  56. package/src/host/miniapp/unpack.js +1010 -0
  57. package/src/host/miniapp/wxapkg.js +416 -0
  58. package/src/host/reports-office.js +341 -0
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Mini-program (wxapkg) root discovery and identity inference.
3
+ *
4
+ * All functions are pure path math over the environment (plus `existsSync`
5
+ * checks in `expandRoots`) so they are trivially testable with fake envs and
6
+ * temp trees. Nothing here writes to disk or touches WeChat's cache.
7
+ *
8
+ * Layouts handled:
9
+ * WeChat 4.x (xwechat) <appdata>/Tencent/xwechat/radium/users/<hash>/applet/packages/<appid>/<version>/*.wxapkg
10
+ * public library <users>/<hash>/applet/publicLib/<version>/publicLib.wxapkg
11
+ * WeChat 3.x <home>/Documents/WeChat Files/.../packages/<appid>/<version>/*.wxapkg
12
+ * devtools <localappdata>/微信web开发者工具/User Data/...
13
+ * custom/env roots arbitrary depth (`CORTEX_MINIAPP_ROOTS`)
14
+ */
15
+
16
+ import { existsSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { basename, delimiter, dirname, join, relative, resolve, sep } from 'node:path'
19
+
20
+ /** @typedef {{kind:'xwechat'|'wechat3'|'devtools'|'custom', path:string, label:string, mode:'packages'|'recursive', maxDepth:number}} Root */
21
+
22
+ /** Default recursive scan depth used by discovery and detection. */
23
+ export const DEFAULT_MAX_DEPTH = 10
24
+
25
+ /** Appid directory segment, e.g. `wxdabf06925a55f410`. */
26
+ const APPID_RE = /^wx[0-9a-f]{16}$/i
27
+
28
+ /** Directory names that are never descended into. */
29
+ const SKIP_DIR_NAMES = new Set(['node_modules'])
30
+
31
+ /** @param {NodeJS.ProcessEnv} env */
32
+ function homeDir(env) {
33
+ if (env && typeof env.HOME === 'string' && env.HOME.trim()) return env.HOME.trim()
34
+ if (env && typeof env.USERPROFILE === 'string' && env.USERPROFILE.trim()) return env.USERPROFILE.trim()
35
+ return homedir()
36
+ }
37
+
38
+ /** @param {NodeJS.ProcessEnv} env */
39
+ function appDataDir(env) {
40
+ if (env && typeof env.APPDATA === 'string' && env.APPDATA.trim()) return env.APPDATA.trim()
41
+ return join(homeDir(env), 'AppData', 'Roaming')
42
+ }
43
+
44
+ /** @param {NodeJS.ProcessEnv} env */
45
+ function localAppDataDir(env) {
46
+ if (env && typeof env.LOCALAPPDATA === 'string' && env.LOCALAPPDATA.trim()) return env.LOCALAPPDATA.trim()
47
+ return join(homeDir(env), 'AppData', 'Local')
48
+ }
49
+
50
+ /**
51
+ * Candidate roots (existing or not; `expandRoots` applies the existence filter).
52
+ * @param {NodeJS.ProcessEnv} [env]
53
+ * @returns {Root[]}
54
+ */
55
+ export function defaultRoots(env = process.env) {
56
+ const roots = [
57
+ {
58
+ kind: 'xwechat',
59
+ path: resolve(join(appDataDir(env), 'Tencent', 'xwechat', 'radium', 'users')),
60
+ label: 'WeChat 4.x (xwechat)',
61
+ mode: 'recursive',
62
+ maxDepth: DEFAULT_MAX_DEPTH,
63
+ },
64
+ {
65
+ kind: 'wechat3',
66
+ path: resolve(join(homeDir(env), 'Documents', 'WeChat Files')),
67
+ label: 'WeChat Files (3.x)',
68
+ mode: 'recursive',
69
+ maxDepth: DEFAULT_MAX_DEPTH,
70
+ },
71
+ {
72
+ kind: 'devtools',
73
+ path: resolve(join(localAppDataDir(env), '微信web开发者工具', 'User Data')),
74
+ label: 'WeChat DevTools',
75
+ mode: 'recursive',
76
+ maxDepth: DEFAULT_MAX_DEPTH,
77
+ },
78
+ ]
79
+
80
+ // WeChat 4.x can keep its "files" directory on another drive (or the user's
81
+ // Documents folder). Its account data may still contain applet caches; scan
82
+ // every discovered `Documents` directory without touching the whole drive.
83
+ const documents = new Set([resolve(join(homeDir(env), 'Documents'))])
84
+ // Extra-drive probing is opt-in (host sets the flag at runtime) so tests and
85
+ // embedders with a synthetic env never touch unrelated drives.
86
+ if (env && env.CORTEX_MINIAPP_EXTRA_DRIVES === '1') {
87
+ for (const letter of 'CDEFGHIJKLMNOPQRSTUVWXYZ') {
88
+ const driveDocuments = `${letter}:\\Documents`
89
+ try {
90
+ if (existsSync(driveDocuments)) documents.add(driveDocuments)
91
+ } catch {
92
+ /* inaccessible drive letter */
93
+ }
94
+ }
95
+ }
96
+ for (const directory of documents) {
97
+ const xwechatFiles = join(directory, 'xwechat_files')
98
+ try {
99
+ if (existsSync(xwechatFiles)) {
100
+ roots.push({
101
+ kind: 'xwechat',
102
+ path: resolve(xwechatFiles),
103
+ label: `WeChat 4.x files (${directory.slice(0, 2)})`,
104
+ mode: 'recursive',
105
+ maxDepth: DEFAULT_MAX_DEPTH,
106
+ })
107
+ }
108
+ } catch {
109
+ /* ignore */
110
+ }
111
+ const classic = join(directory, 'WeChat Files')
112
+ try {
113
+ if (existsSync(classic)) {
114
+ roots.push({
115
+ kind: 'wechat3',
116
+ path: resolve(classic),
117
+ label: `WeChat Files (${directory.slice(0, 2)})`,
118
+ mode: 'recursive',
119
+ maxDepth: DEFAULT_MAX_DEPTH,
120
+ })
121
+ }
122
+ } catch {
123
+ /* ignore */
124
+ }
125
+ }
126
+ return roots
127
+ }
128
+
129
+ /**
130
+ * Extra roots from `CORTEX_MINIAPP_ROOTS` (split on `path.delimiter`).
131
+ * @param {NodeJS.ProcessEnv} [env]
132
+ * @returns {string[]} absolute paths
133
+ */
134
+ export function envRoots(env = process.env) {
135
+ const raw = env && typeof env.CORTEX_MINIAPP_ROOTS === 'string' ? env.CORTEX_MINIAPP_ROOTS : ''
136
+ if (!raw.trim()) return []
137
+ const out = []
138
+ for (const part of raw.split(delimiter)) {
139
+ let value = part.trim()
140
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1).trim()
141
+ if (!value) continue
142
+ out.push(resolve(value))
143
+ }
144
+ return out
145
+ }
146
+
147
+ /** @param {string} value */
148
+ function rootKey(value) {
149
+ const absolute = resolve(value)
150
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute
151
+ }
152
+
153
+ /**
154
+ * Resolve the effective root list: existing defaults first, then custom and
155
+ * env roots. Non-existent roots are dropped unless `includeMissing`.
156
+ * @param {{customRoots?:string[], env?:NodeJS.ProcessEnv, includeMissing?:boolean}} [options]
157
+ * @returns {Root[]}
158
+ */
159
+ export function expandRoots({ customRoots = [], env = process.env, includeMissing = false } = {}) {
160
+ /** @type {Root[]} */
161
+ const roots = []
162
+ const seen = new Set()
163
+ const push = (root) => {
164
+ const key = rootKey(root.path)
165
+ if (seen.has(key)) return
166
+ seen.add(key)
167
+ roots.push(root)
168
+ }
169
+
170
+ for (const root of defaultRoots(env)) {
171
+ const exists = existsSync(root.path)
172
+ if (exists || includeMissing) push({ ...root, path: resolve(root.path) })
173
+ }
174
+
175
+ const extra = []
176
+ for (const entry of Array.isArray(customRoots) ? customRoots : []) {
177
+ if (typeof entry === 'string' && entry.trim()) extra.push(entry.trim())
178
+ }
179
+ extra.push(...envRoots(env))
180
+
181
+ for (const entry of extra) {
182
+ const absolute = resolve(entry)
183
+ const exists = existsSync(absolute)
184
+ if (!exists && !includeMissing) continue
185
+ push({
186
+ kind: 'custom',
187
+ path: absolute,
188
+ label: basename(absolute) || absolute,
189
+ mode: 'recursive',
190
+ maxDepth: DEFAULT_MAX_DEPTH,
191
+ })
192
+ }
193
+
194
+ return roots
195
+ }
196
+
197
+ /**
198
+ * Sanitize one path segment for use below the miniapp workspace.
199
+ * @param {string} value
200
+ * @param {string} [fallback]
201
+ * @returns {string}
202
+ */
203
+ export function sanitizeSegment(value, fallback = '_') {
204
+ const raw = typeof value === 'string' ? value : value === undefined || value === null ? '' : String(value)
205
+ let out = raw
206
+ .replace(/[\u0000-\u001f<>:"/\\|?*]/g, '_')
207
+ .replace(/^[.\s]+/, '')
208
+ .replace(/[.\s]+$/, '')
209
+ if (!out) return fallback
210
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(out)) out = `_${out}`
211
+ return out.slice(0, 120)
212
+ }
213
+
214
+ /** @param {string} value */
215
+ function isInsideRoot(rootPath, target) {
216
+ const root = resolve(rootPath)
217
+ const path = resolve(target)
218
+ if (process.platform === 'win32') {
219
+ const lowerRoot = root.toLowerCase()
220
+ const lowerPath = path.toLowerCase()
221
+ return lowerPath === lowerRoot || lowerPath.startsWith(lowerRoot + sep)
222
+ }
223
+ return path === root || path.startsWith(root + sep)
224
+ }
225
+
226
+ /** @param {string} segment */
227
+ function isVersionSegment(segment) {
228
+ if (!segment || segment.length > 32) return false
229
+ if (/^\d+$/.test(segment)) return true
230
+ if (/^\d+(?:\.\d+){1,3}$/.test(segment)) return true
231
+ if (/^\d{4}[-_.]\d{2}[-_.]\d{2}(?:[T _.-].*)?$/.test(segment)) return true
232
+ return false
233
+ }
234
+
235
+ /**
236
+ * Strip `_packages_` prefixes and app bookkeeping names, then accept only a
237
+ * canonical wx appid segment.
238
+ * @param {string} segment
239
+ * @returns {string}
240
+ */
241
+ function appidFromSegment(segment) {
242
+ let value = String(segment ?? '').trim()
243
+ if (!value || SKIP_DIR_NAMES.has(value.toLowerCase())) return ''
244
+ const lower = value.toLowerCase()
245
+ const marker = lower.indexOf('_packages_')
246
+ if (marker >= 0) value = value.slice(marker + '_packages_'.length)
247
+ if (/^_?(?:__app__|publiclib)$/i.test(value)) return ''
248
+ if (APPID_RE.test(value)) return value
249
+ return ''
250
+ }
251
+
252
+ /**
253
+ * Derive `{appid, version, packageDir, packageName, rootPath, rootKind}` from
254
+ * one package file and the scan root that produced it.
255
+ *
256
+ * @param {string} filePath
257
+ * @param {Root|{kind?:string, path:string}|string} root
258
+ */
259
+ export function inferIdentity(filePath, root) {
260
+ const rootPath = resolve(typeof root === 'string' ? root : root && root.path ? root.path : process.cwd())
261
+ const rootKind = typeof root === 'object' && root && typeof root.kind === 'string' && root.kind ? root.kind : 'custom'
262
+ const fileAbs = resolve(filePath)
263
+ const inside = isInsideRoot(rootPath, fileAbs)
264
+ const packageDir = inside ? dirname(fileAbs) : rootPath
265
+
266
+ const base = basename(fileAbs)
267
+ const packageName = base.toLowerCase().endsWith('.wxapkg') ? base.slice(0, -'.wxapkg'.length) : base
268
+
269
+ /** @type {string} */
270
+ let appid = ''
271
+ /** @type {string} */
272
+ let version = ''
273
+
274
+ if (inside) {
275
+ const segments = relative(rootPath, packageDir)
276
+ .split(/[\\/]+/)
277
+ .filter(Boolean)
278
+
279
+ // 1. publicLib layout: .../applet/publicLib/<version>/publicLib.wxapkg
280
+ let publicLibIndex = -1
281
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
282
+ if (segments[index].toLowerCase() === 'publiclib') {
283
+ publicLibIndex = index
284
+ break
285
+ }
286
+ }
287
+ if (publicLibIndex >= 0) {
288
+ const candidate = segments[publicLibIndex + 1] || ''
289
+ return {
290
+ appid: 'publicLib',
291
+ version: isVersionSegment(candidate) ? candidate : '',
292
+ packageDir,
293
+ packageName,
294
+ rootPath,
295
+ rootKind,
296
+ }
297
+ }
298
+
299
+ // 2. canonical wx appid directory, nearest the file first.
300
+ let appidIndex = -1
301
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
302
+ const candidate = appidFromSegment(segments[index])
303
+ if (candidate) {
304
+ appid = candidate
305
+ appidIndex = index
306
+ break
307
+ }
308
+ }
309
+
310
+ if (appidIndex >= 0) {
311
+ for (let index = appidIndex + 1; index < segments.length; index += 1) {
312
+ if (isVersionSegment(segments[index])) {
313
+ version = segments[index]
314
+ break
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ return { appid, version, packageDir, packageName, rootPath, rootKind }
321
+ }
322
+
323
+ /**
324
+ * Absolute miniapp workspace root (`CORTEX_MINIAPP_WORKSPACE` or
325
+ * `~/.cortex/miniapps`).
326
+ * @param {NodeJS.ProcessEnv} [env]
327
+ * @returns {string}
328
+ */
329
+ export function miniAppWorkspaceRoot(env = process.env) {
330
+ const override =
331
+ env && typeof env.CORTEX_MINIAPP_WORKSPACE === 'string' ? env.CORTEX_MINIAPP_WORKSPACE.trim() : ''
332
+ return resolve(override || join(homeDir(env), '.cortex', 'miniapps'))
333
+ }
334
+
335
+ /**
336
+ * Per-package workspace layout:
337
+ * <root>/<appid>/<version>/
338
+ * unpacked/ extracted app files + manifest.json + overview.json
339
+ *
340
+ * `manifestPath` / `extractedPath` live inside `unpacked` so the unpack and
341
+ * analyze helpers can address them from `unpacked_path` alone.
342
+ *
343
+ * @param {string} appid
344
+ * @param {string} version
345
+ * @param {NodeJS.ProcessEnv} [env]
346
+ */
347
+ export function packageWorkspace(appid, version, env = process.env) {
348
+ const root = join(
349
+ miniAppWorkspaceRoot(env),
350
+ sanitizeSegment(appid, 'unknown'),
351
+ sanitizeSegment(version, 'unknown'),
352
+ )
353
+ const unpacked = join(root, 'unpacked')
354
+ return {
355
+ root,
356
+ unpacked,
357
+ manifestPath: join(unpacked, 'manifest.json'),
358
+ extractedPath: join(unpacked, 'extracted.json'),
359
+ }
360
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * HTTP surface for the mini-program (wxapkg) reverse-engineering feature.
3
+ *
4
+ * Mounted under `/cortex-api/cortex/miniapps/**`. The parent dispatcher
5
+ * (`src/host/cortex/index.js`) already applied the loopback peer/Host fences and
6
+ * the same-origin write fence, so this module only routes, validates input and
7
+ * maps the shared service layer onto JSON responses.
8
+ *
9
+ * The parent imports this file lazily: a failure in the miniapp modules must
10
+ * never take down the rest of the Cortex UI/API.
11
+ */
12
+
13
+ import { existsSync } from 'node:fs'
14
+ import { isAbsolute, resolve } from 'node:path'
15
+ import {
16
+ badRequest,
17
+ methodNotAllowed,
18
+ queryBool01,
19
+ queryInt,
20
+ queryString,
21
+ readJsonBody,
22
+ safeDecode,
23
+ sendJson,
24
+ } from '../http.js'
25
+ import { defaultRoots, expandRoots } from './roots.js'
26
+ import {
27
+ analyzeSourceRecord,
28
+ detailPayload,
29
+ fileForSource,
30
+ importAssetsForSource,
31
+ listMiniapps,
32
+ miniappStore,
33
+ openSourceDirectory,
34
+ requireSourceById,
35
+ scan,
36
+ searchSource,
37
+ treeForSource,
38
+ unpackSourceRecord,
39
+ } from './service.js'
40
+
41
+ const MAX_ID = 200
42
+ const MAX_PATH = 4000
43
+ const MAX_CUSTOM_ROOTS = 20
44
+
45
+ function sourceId(ctx) {
46
+ const raw = Array.isArray(ctx.params) ? ctx.params[0] : ''
47
+ const id = safeDecode(raw, 'miniapp id')
48
+ if (id.length === 0) throw badRequest('miniapp id is required')
49
+ if (id.length > MAX_ID) throw badRequest('miniapp id is too long', { max: MAX_ID })
50
+ return id
51
+ }
52
+
53
+ /** Read a JSON body when present; empty POSTs are treated as `{}`. */
54
+ async function bodyOrEmpty(req) {
55
+ const headers = (req && req.headers) || {}
56
+ const length = Number(headers['content-length'] || 0)
57
+ if (length === 0 && headers['transfer-encoding'] === undefined) return {}
58
+ return readJsonBody(req)
59
+ }
60
+
61
+ export async function handleList({ res, searchParams }) {
62
+ const search = queryString(searchParams, 'search', { max: 400 }) ?? ''
63
+ const status = queryString(searchParams, 'status', { max: 40 }) ?? ''
64
+ sendJson(res, 200, listMiniapps(miniappStore(), { search, status }))
65
+ }
66
+
67
+ export async function handleScan({ req, res }) {
68
+ const body = await bodyOrEmpty(req)
69
+ let customRoots
70
+ // An empty array from the UI means "scan with the saved roots"; only a
71
+ // non-empty array replaces them. Clearing is done through POST /miniapps/roots.
72
+ if (Array.isArray(body.customRoots) && body.customRoots.length > 0) {
73
+ customRoots = normalizeRoots(body.customRoots)
74
+ }
75
+ const result = await scan(miniappStore(), { customRoots })
76
+ sendJson(res, 200, result)
77
+ }
78
+
79
+ function rootsPayload(customRoots) {
80
+ const all = expandRoots({ customRoots, includeMissing: true }).map((root) => ({
81
+ kind: root.kind,
82
+ path: root.path,
83
+ label: root.label,
84
+ mode: root.mode,
85
+ exists: existsSync(root.path),
86
+ }))
87
+ return { roots: all, customRoots }
88
+ }
89
+
90
+ export function handleRootsGet({ res }) {
91
+ const store = miniappStore()
92
+ sendJson(res, 200, rootsPayload(store.customRoots()))
93
+ }
94
+
95
+ export async function handleRootsSet({ req, res }) {
96
+ const body = await readJsonBody(req)
97
+ const roots = normalizeRoots(body.roots)
98
+ const mode = body.mode === 'append' ? 'append' : 'replace'
99
+ const store = miniappStore()
100
+ const next = mode === 'append' ? [...new Set([...store.customRoots(), ...roots])] : roots
101
+ store.setCustomRoots(next)
102
+ sendJson(res, 200, rootsPayload(next))
103
+ }
104
+
105
+ export async function handleDetailRoute(ctx) {
106
+ const store = miniappStore()
107
+ const source = requireSourceById(store, sourceId(ctx))
108
+ sendJson(ctx.res, 200, detailPayload(store, source))
109
+ }
110
+
111
+ export async function handleUnpack(ctx) {
112
+ const store = miniappStore()
113
+ const source = requireSourceById(store, sourceId(ctx))
114
+ let force = false
115
+ try {
116
+ const body = await bodyOrEmpty(ctx.req)
117
+ force = body.force === true
118
+ } catch {
119
+ force = false
120
+ }
121
+ const result = await unpackSourceRecord({ store, source, force })
122
+ const row = store.getSource(source.id) || source
123
+ sendJson(ctx.res, 200, {
124
+ ok: result.ok === true,
125
+ status: result.status,
126
+ cached: result.cached === true,
127
+ error: result.error || '',
128
+ totals: result.totals || null,
129
+ unpackedPath: result.unpackedPath || '',
130
+ manifest: result.manifest || null,
131
+ miniapp: detailPayload(store, row).miniapp,
132
+ })
133
+ }
134
+
135
+ export async function handleAnalyze(ctx) {
136
+ const store = miniappStore()
137
+ const source = requireSourceById(store, sourceId(ctx))
138
+ let force = false
139
+ try {
140
+ const body = await bodyOrEmpty(ctx.req)
141
+ force = body.force === true
142
+ } catch {
143
+ force = false
144
+ }
145
+ const result = analyzeSourceRecord({ store, source, force })
146
+ const row = store.getSource(source.id) || source
147
+ sendJson(ctx.res, 200, {
148
+ ok: result.ok,
149
+ cached: result.cached === true,
150
+ analysis: result.analysis,
151
+ overviewText: result.overviewText,
152
+ counts: result.counts,
153
+ miniapp: detailPayload(store, row).miniapp,
154
+ })
155
+ }
156
+
157
+ export function handleTreeRoute(ctx) {
158
+ const source = requireSourceById(miniappStore(), sourceId(ctx))
159
+ const path = queryString(ctx.searchParams, 'path', { max: MAX_PATH }) ?? ''
160
+ sendJson(ctx.res, 200, treeForSource(source, path))
161
+ }
162
+
163
+ export function handleFileRoute(ctx) {
164
+ const source = requireSourceById(miniappStore(), sourceId(ctx))
165
+ const path = queryString(ctx.searchParams, 'path', { max: MAX_PATH })
166
+ if (!path) throw badRequest('path is required', { field: 'path' })
167
+ const offset = queryInt(ctx.searchParams, 'offset', { min: 0, max: 10_000_000 }) ?? 0
168
+ const limit = queryInt(ctx.searchParams, 'limit', { min: 1, max: 5000 }) ?? 200
169
+ const maxBytes = queryInt(ctx.searchParams, 'maxBytes', { min: 1024, max: 5 * 1024 * 1024 }) ?? 1024 * 1024
170
+ const rawFormat = queryBool01(ctx.searchParams, 'format')
171
+ const find = queryString(ctx.searchParams, 'find', { max: 400 }) ?? ''
172
+ const findRegex = queryBool01(ctx.searchParams, 'findRegex') === true
173
+ const findMax = queryInt(ctx.searchParams, 'findMax', { min: 1, max: 1000 }) ?? 200
174
+ const result = fileForSource(source, path, {
175
+ offset,
176
+ limit,
177
+ format: rawFormat === false ? false : 'auto',
178
+ maxBytes,
179
+ find,
180
+ findRegex,
181
+ findMax,
182
+ })
183
+ if (result.binary) {
184
+ throw badRequest('binary files cannot be previewed as text', {
185
+ code: 'BINARY_FILE',
186
+ path: result.path,
187
+ size: result.size,
188
+ })
189
+ }
190
+ sendJson(ctx.res, 200, result)
191
+ }
192
+
193
+ export function handleSearchRoute(ctx) {
194
+ const source = requireSourceById(miniappStore(), sourceId(ctx))
195
+ const query = queryString(ctx.searchParams, 'query', { max: 400 })
196
+ if (!query) throw badRequest('query is required', { field: 'query' })
197
+ const regex = queryBool01(ctx.searchParams, 'regex') === true
198
+ const glob = queryString(ctx.searchParams, 'glob', { max: 300 }) ?? ''
199
+ const maxResults = queryInt(ctx.searchParams, 'maxResults', { min: 1, max: 200 }) ?? 50
200
+ sendJson(ctx.res, 200, searchSource(source, { query, regex, glob, maxResults }))
201
+ }
202
+
203
+ export async function handleImportAssets(ctx) {
204
+ const store = miniappStore()
205
+ const source = requireSourceById(store, sourceId(ctx))
206
+ const body = await bodyOrEmpty(ctx.req)
207
+ const projectName =
208
+ typeof body.project_name === 'string' && body.project_name.trim().length > 0
209
+ ? body.project_name.trim().slice(0, 120)
210
+ : undefined
211
+ const hostPort =
212
+ typeof body.host_port === 'string' && body.host_port.trim().length > 0
213
+ ? body.host_port.trim().slice(0, 4000)
214
+ : undefined
215
+ const result = await importAssetsForSource({
216
+ holder: ctx.holder,
217
+ store,
218
+ source,
219
+ projectName,
220
+ hostPort,
221
+ })
222
+ sendJson(ctx.res, 200, result)
223
+ }
224
+
225
+ export async function handleOpen(ctx) {
226
+ const store = miniappStore()
227
+ const source = requireSourceById(store, sourceId(ctx))
228
+ sendJson(ctx.res, 200, openSourceDirectory(source))
229
+ }
230
+
231
+ export async function handleRename(ctx) {
232
+ const store = miniappStore()
233
+ const source = requireSourceById(store, sourceId(ctx))
234
+ const body = await bodyOrEmpty(ctx.req)
235
+ const raw = typeof body.app_name === 'string' ? body.app_name : ''
236
+ if (raw.length > 120) throw badRequest('app_name is too long', { field: 'app_name', max: 120 })
237
+ const appName = raw.replace(/\s+/g, ' ').trim().slice(0, 60)
238
+ const row = store.updateSource(source.id, { app_name: appName })
239
+ sendJson(ctx.res, 200, { ok: true, miniapp: detailPayload(store, row || source).miniapp })
240
+ }
241
+
242
+ function normalizeRoots(raw) {
243
+ if (!Array.isArray(raw)) throw badRequest('roots must be an array', { field: 'roots' })
244
+ if (raw.length > MAX_CUSTOM_ROOTS) throw badRequest(`at most ${MAX_CUSTOM_ROOTS} custom roots`, { max: MAX_CUSTOM_ROOTS })
245
+ const result = []
246
+ for (const value of raw) {
247
+ if (typeof value !== 'string') throw badRequest('each root must be a string', { field: 'roots' })
248
+ const trimmed = value.trim()
249
+ if (trimmed.length === 0) continue
250
+ if (trimmed.length > 2000) throw badRequest('root path is too long', { field: 'roots', max: 2000 })
251
+ if (!isAbsolute(trimmed)) throw badRequest('root path must be absolute', { field: 'roots' })
252
+ const resolved = resolve(trimmed)
253
+ if (!result.includes(resolved)) result.push(resolved)
254
+ }
255
+ return result
256
+ }
257
+
258
+ /* -------------------------------- routing -------------------------------- */
259
+
260
+ const ROUTES = [
261
+ { method: 'GET', pattern: /^\/miniapps$/, handler: handleList },
262
+ { method: 'POST', pattern: /^\/miniapps\/scan$/, handler: handleScan },
263
+ { method: 'GET', pattern: /^\/miniapps\/roots$/, handler: handleRootsGet },
264
+ { method: 'POST', pattern: /^\/miniapps\/roots$/, handler: handleRootsSet },
265
+ { method: 'POST', pattern: /^\/miniapps\/([^/]+)\/unpack$/, handler: handleUnpack },
266
+ { method: 'POST', pattern: /^\/miniapps\/([^/]+)\/analyze$/, handler: handleAnalyze },
267
+ { method: 'POST', pattern: /^\/miniapps\/([^/]+)\/import-assets$/, handler: handleImportAssets },
268
+ { method: 'POST', pattern: /^\/miniapps\/([^/]+)\/open$/, handler: handleOpen },
269
+ { method: 'POST', pattern: /^\/miniapps\/([^/]+)\/name$/, handler: handleRename },
270
+ { method: 'GET', pattern: /^\/miniapps\/([^/]+)\/tree$/, handler: handleTreeRoute },
271
+ { method: 'GET', pattern: /^\/miniapps\/([^/]+)\/file$/, handler: handleFileRoute },
272
+ { method: 'GET', pattern: /^\/miniapps\/([^/]+)\/search$/, handler: handleSearchRoute },
273
+ { method: 'GET', pattern: /^\/miniapps\/([^/]+)$/, handler: handleDetailRoute },
274
+ ]
275
+
276
+ /**
277
+ * Handle one `/miniapps/**` request. Returns false when the path is not ours
278
+ * (the parent dispatcher then falls through to its own route table).
279
+ * @param {Record<string, any>} ctx
280
+ */
281
+ export async function handleMiniapps(ctx) {
282
+ const path = String(ctx.path || '')
283
+ const method = String(ctx.method || 'GET').toUpperCase()
284
+ if (path !== '/miniapps' && !path.startsWith('/miniapps/')) return false
285
+ const matched = ROUTES.filter((route) => route.pattern.test(path))
286
+ if (matched.length === 0) return false
287
+ const route = matched.find((candidate) => candidate.method === method)
288
+ if (!route) throw methodNotAllowed()
289
+ const match = route.pattern.exec(path)
290
+ ctx.params = match.slice(1)
291
+ try {
292
+ await route.handler(ctx)
293
+ } catch (error) {
294
+ // Workspace/container errors are client-visible 4xx, never opaque 500s.
295
+ if (error && error.name === 'WxapkgError') {
296
+ throw badRequest(error.message || 'mini-program operation failed', { code: error.code })
297
+ }
298
+ throw error
299
+ }
300
+ return true
301
+ }
302
+
303
+ /** Exposed for diagnostics/tests (not part of the public HTTP contract). */
304
+ export { ROUTES as MINIAPP_ROUTES, defaultRoots }