@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.
- package/README.md +1 -1
- package/client.js +428 -406
- package/index.js +7 -0
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +115 -106
- package/src/cortex-ui/dsh/api.ts +5 -2
- package/src/cortex-ui/dsh/css-overrides.css +8 -0
- package/src/cortex-ui/renderer/App.tsx +39 -0
- package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
- package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
- package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
- package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
- package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
- package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
- package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
- package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
- package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
- package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
- package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
- package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
- package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
- package/src/cortex-ui/renderer/lib/curl.ts +635 -0
- package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
- package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
- package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
- package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
- package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
- package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
- package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
- package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
- package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
- package/src/cortex-ui/shared/ipc.ts +3 -4
- package/src/cortex-ui/shared/types.ts +9 -0
- package/src/host/cortex/fs.js +72 -0
- package/src/host/cortex/index.js +49 -1
- package/src/host/cortex/repeater-store.js +411 -0
- package/src/host/cortex/repeater.js +15 -2
- package/src/host/cortex/settings.js +54 -0
- package/src/host/cortex/tools.js +2 -0
- package/src/host/cortex/vulns.js +36 -13
- package/src/host/miniapp/analyze.js +1078 -0
- package/src/host/miniapp/assets-import.js +294 -0
- package/src/host/miniapp/detect.js +426 -0
- package/src/host/miniapp/format.js +263 -0
- package/src/host/miniapp/roots.js +360 -0
- package/src/host/miniapp/routes.js +304 -0
- package/src/host/miniapp/service.js +559 -0
- package/src/host/miniapp/store.js +756 -0
- package/src/host/miniapp/tools.js +335 -0
- package/src/host/miniapp/unpack.js +1010 -0
- package/src/host/miniapp/wxapkg.js +416 -0
- package/src/host/reports-office.js +341 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin-local SQLite store for the mini-program (wxapkg) feature.
|
|
3
|
+
*
|
|
4
|
+
* The Cortex desktop app owns `~/.cortex/data.db`; this module deliberately
|
|
5
|
+
* keeps its own database (`~/.cortex/plugin.db`) so the schema v5 contract is
|
|
6
|
+
* never touched or migrated. `CORTEX_PLUGIN_DB` overrides the path for tests.
|
|
7
|
+
*
|
|
8
|
+
* Tables (created on `ensure`, all `IF NOT EXISTS`):
|
|
9
|
+
* miniapp_sources one row per package directory (root_path + package_dir)
|
|
10
|
+
* miniapp_jobs unpack/analyze job queue metadata
|
|
11
|
+
* miniapp_analyses cached Overview JSON per source
|
|
12
|
+
* miniapp_settings key/value settings (custom roots, last scan, ...)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
16
|
+
import { mkdirSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
19
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
20
|
+
|
|
21
|
+
/** Statuses a miniapp source may carry (contract §2). */
|
|
22
|
+
export const MINIAPP_STATUSES = ['detected', 'decrypting', 'unpacked', 'unsupported', 'failed']
|
|
23
|
+
|
|
24
|
+
/** Formats `detectFormat` / unpack may record. */
|
|
25
|
+
export const MINIAPP_FORMATS = ['plain', 'v1mmwx', 'unknown']
|
|
26
|
+
|
|
27
|
+
/** Statuses and unpacked output preserved across an idempotent re-scan. */
|
|
28
|
+
const PRESERVED_STATUSES = new Set(['unpacked', 'unsupported', 'failed'])
|
|
29
|
+
|
|
30
|
+
/** Setting key holding the user's custom scan roots (JSON array). */
|
|
31
|
+
const CUSTOM_ROOTS_KEY = 'customRoots'
|
|
32
|
+
|
|
33
|
+
const SCHEMA_SQL = `
|
|
34
|
+
CREATE TABLE IF NOT EXISTS miniapp_sources (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
root_path TEXT NOT NULL,
|
|
37
|
+
root_kind TEXT NOT NULL,
|
|
38
|
+
appid TEXT NOT NULL,
|
|
39
|
+
app_name TEXT NOT NULL DEFAULT '',
|
|
40
|
+
version TEXT NOT NULL DEFAULT '',
|
|
41
|
+
package_dir TEXT NOT NULL,
|
|
42
|
+
files_json TEXT NOT NULL DEFAULT '[]',
|
|
43
|
+
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
file_count INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
package_hash TEXT NOT NULL DEFAULT '',
|
|
46
|
+
format TEXT NOT NULL DEFAULT 'unknown',
|
|
47
|
+
status TEXT NOT NULL DEFAULT 'detected',
|
|
48
|
+
unpacked_path TEXT NOT NULL DEFAULT '',
|
|
49
|
+
error TEXT NOT NULL DEFAULT '',
|
|
50
|
+
detected_at TEXT NOT NULL,
|
|
51
|
+
updated_at TEXT NOT NULL,
|
|
52
|
+
UNIQUE (root_path, package_dir)
|
|
53
|
+
);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS miniapp_jobs (
|
|
55
|
+
id TEXT PRIMARY KEY,
|
|
56
|
+
source_id TEXT NOT NULL,
|
|
57
|
+
kind TEXT NOT NULL,
|
|
58
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
59
|
+
progress INTEGER NOT NULL DEFAULT 0,
|
|
60
|
+
message TEXT NOT NULL DEFAULT '',
|
|
61
|
+
created_at TEXT NOT NULL,
|
|
62
|
+
updated_at TEXT NOT NULL
|
|
63
|
+
);
|
|
64
|
+
CREATE TABLE IF NOT EXISTS miniapp_analyses (
|
|
65
|
+
source_id TEXT PRIMARY KEY,
|
|
66
|
+
overview_json TEXT NOT NULL DEFAULT '{}',
|
|
67
|
+
updated_at TEXT NOT NULL
|
|
68
|
+
);
|
|
69
|
+
CREATE TABLE IF NOT EXISTS miniapp_settings (
|
|
70
|
+
key TEXT PRIMARY KEY,
|
|
71
|
+
value TEXT NOT NULL,
|
|
72
|
+
updated_at TEXT NOT NULL
|
|
73
|
+
);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_miniapp_sources_appid ON miniapp_sources(appid, version);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_miniapp_jobs_source ON miniapp_jobs(source_id, created_at);
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
/** @param {NodeJS.ProcessEnv} env */
|
|
79
|
+
function homeDir(env) {
|
|
80
|
+
if (env && typeof env.HOME === 'string' && env.HOME.trim()) return env.HOME.trim()
|
|
81
|
+
if (env && typeof env.USERPROFILE === 'string' && env.USERPROFILE.trim()) return env.USERPROFILE.trim()
|
|
82
|
+
return homedir()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Default `plugin.db` path: `~/.cortex/plugin.db`, overridable via
|
|
87
|
+
* `CORTEX_PLUGIN_DB`. Always absolute (except the `:memory:` test sentinel).
|
|
88
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
89
|
+
*/
|
|
90
|
+
export function defaultPluginDbPath(env = process.env) {
|
|
91
|
+
const override =
|
|
92
|
+
env && typeof env.CORTEX_PLUGIN_DB === 'string' ? env.CORTEX_PLUGIN_DB.trim() : ''
|
|
93
|
+
if (override) return override === ':memory:' ? override : resolve(override)
|
|
94
|
+
return resolve(join(homeDir(env), '.cortex', 'plugin.db'))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Stable source id: `sha1(resolve(rootPath) + '\0' + resolve(packageDir))`.
|
|
99
|
+
* @param {string} rootPath
|
|
100
|
+
* @param {string} packageDir
|
|
101
|
+
*/
|
|
102
|
+
export function sourceIdFor(rootPath, packageDir) {
|
|
103
|
+
const root = resolve(String(rootPath ?? ''))
|
|
104
|
+
const pkg = resolve(String(packageDir ?? ''))
|
|
105
|
+
return createHash('sha1').update(root, 'utf8').update('\0', 'utf8').update(pkg, 'utf8').digest('hex')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const nowIso = () => new Date().toISOString()
|
|
109
|
+
|
|
110
|
+
/** @param {unknown} value */
|
|
111
|
+
function toText(value) {
|
|
112
|
+
return typeof value === 'string' ? value : value === undefined || value === null ? '' : String(value)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {unknown} value @param {number} fallback */
|
|
116
|
+
function toInt(value, fallback) {
|
|
117
|
+
const n = Number(value)
|
|
118
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @param {unknown} value @param {string} fallback */
|
|
122
|
+
function normalizeStatus(value, fallback) {
|
|
123
|
+
return MINIAPP_STATUSES.includes(value) ? String(value) : fallback
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {unknown} value */
|
|
127
|
+
function normalizeFormat(value) {
|
|
128
|
+
return MINIAPP_FORMATS.includes(value) ? String(value) : 'unknown'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {unknown} value @param {string} field */
|
|
132
|
+
function requirePath(value, field) {
|
|
133
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
134
|
+
throw new TypeError(`MiniappStore: ${field} is required`)
|
|
135
|
+
}
|
|
136
|
+
return resolve(value)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @param {string} value */
|
|
140
|
+
function escapeLike(value) {
|
|
141
|
+
return value.replace(/[\\%_]/g, (match) => `\\${match}`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Canonical file entries sorted by name: `{name, path, size}`.
|
|
146
|
+
* Accepts the contract shape, bare path strings, or a parsed files array.
|
|
147
|
+
* @param {unknown} files
|
|
148
|
+
*/
|
|
149
|
+
function normalizeFiles(files) {
|
|
150
|
+
const input = Array.isArray(files) ? files : []
|
|
151
|
+
/** @type {{name:string,path:string,size:number}[]} */
|
|
152
|
+
const out = []
|
|
153
|
+
for (const item of input) {
|
|
154
|
+
let filePath = ''
|
|
155
|
+
let name = ''
|
|
156
|
+
let size = 0
|
|
157
|
+
if (typeof item === 'string') {
|
|
158
|
+
filePath = resolve(item)
|
|
159
|
+
name = basename(filePath)
|
|
160
|
+
} else if (item && typeof item === 'object') {
|
|
161
|
+
filePath = typeof item.path === 'string' && item.path ? resolve(item.path) : ''
|
|
162
|
+
name = typeof item.name === 'string' && item.name ? item.name : filePath ? basename(filePath) : ''
|
|
163
|
+
size = toInt(item.size, 0)
|
|
164
|
+
}
|
|
165
|
+
if (!filePath && !name) continue
|
|
166
|
+
out.push({ name, path: filePath, size })
|
|
167
|
+
}
|
|
168
|
+
out.sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path))
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @param {string} filesJson */
|
|
173
|
+
function parseFilesJson(filesJson) {
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(filesJson)
|
|
176
|
+
return Array.isArray(parsed) ? parsed : []
|
|
177
|
+
} catch {
|
|
178
|
+
return []
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @param {any} row */
|
|
183
|
+
function rowToSource(row) {
|
|
184
|
+
if (!row) return null
|
|
185
|
+
const source = { ...row }
|
|
186
|
+
source.files = parseFilesJson(source.files_json)
|
|
187
|
+
return source
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @param {any[]} a @param {any[]} b */
|
|
191
|
+
function sameFiles(a, b) {
|
|
192
|
+
const left = Array.isArray(a) ? a : []
|
|
193
|
+
const right = Array.isArray(b) ? b : []
|
|
194
|
+
if (left.length !== right.length) return false
|
|
195
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
196
|
+
const one = left[index] || {}
|
|
197
|
+
const two = right[index] || {}
|
|
198
|
+
if (one.name !== two.name || one.path !== two.path || Number(one.size) !== Number(two.size)) {
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Columns compared when deciding whether an upsert changed anything. */
|
|
206
|
+
const CHANGE_COLUMNS = [
|
|
207
|
+
'root_path',
|
|
208
|
+
'root_kind',
|
|
209
|
+
'appid',
|
|
210
|
+
'app_name',
|
|
211
|
+
'version',
|
|
212
|
+
'package_dir',
|
|
213
|
+
'files_json',
|
|
214
|
+
'size_bytes',
|
|
215
|
+
'file_count',
|
|
216
|
+
'package_hash',
|
|
217
|
+
'format',
|
|
218
|
+
'status',
|
|
219
|
+
'unpacked_path',
|
|
220
|
+
'error',
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
/** @param {any} before @param {any} after */
|
|
224
|
+
function contentChanged(before, after) {
|
|
225
|
+
for (const column of CHANGE_COLUMNS) {
|
|
226
|
+
if (before[column] !== after[column]) return true
|
|
227
|
+
}
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Newest-first version compare used by `listSources` / `findSources`. */
|
|
232
|
+
function compareVersionDesc(a, b) {
|
|
233
|
+
const left = String(a ?? '')
|
|
234
|
+
const right = String(b ?? '')
|
|
235
|
+
const leftNumeric = /^\d+$/.test(left)
|
|
236
|
+
const rightNumeric = /^\d+$/.test(right)
|
|
237
|
+
if (leftNumeric && rightNumeric) {
|
|
238
|
+
const diff = Number(right) - Number(left)
|
|
239
|
+
if (diff !== 0) return diff
|
|
240
|
+
}
|
|
241
|
+
return right.localeCompare(left, undefined, { numeric: true })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Deterministic source ordering: appid asc, version desc (numeric), dir asc. */
|
|
245
|
+
function compareSources(a, b) {
|
|
246
|
+
const byAppid = String(a.appid ?? '').localeCompare(String(b.appid ?? ''))
|
|
247
|
+
if (byAppid !== 0) return byAppid
|
|
248
|
+
const byVersion = compareVersionDesc(a.version, b.version)
|
|
249
|
+
if (byVersion !== 0) return byVersion
|
|
250
|
+
return String(a.package_dir ?? '').localeCompare(String(b.package_dir ?? ''))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const SOURCE_INSERT = `INSERT INTO miniapp_sources
|
|
254
|
+
(id, root_path, root_kind, appid, app_name, version, package_dir, files_json,
|
|
255
|
+
size_bytes, file_count, package_hash, format, status, unpacked_path, error,
|
|
256
|
+
detected_at, updated_at)
|
|
257
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
258
|
+
|
|
259
|
+
const SOURCE_UPDATE = `UPDATE miniapp_sources SET
|
|
260
|
+
root_path = ?, root_kind = ?, appid = ?, app_name = ?, version = ?, package_dir = ?,
|
|
261
|
+
files_json = ?, size_bytes = ?, file_count = ?, package_hash = ?, format = ?,
|
|
262
|
+
status = ?, unpacked_path = ?, error = ?, updated_at = ?
|
|
263
|
+
WHERE id = ?`
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Single-connection wrapper around `plugin.db`. Methods auto-`ensure()` so the
|
|
267
|
+
* host can construct the store lazily; call `close()` on dispose.
|
|
268
|
+
*/
|
|
269
|
+
export class MiniappStore {
|
|
270
|
+
/**
|
|
271
|
+
* @param {{dbPath?:string, env?:NodeJS.ProcessEnv}} [options]
|
|
272
|
+
*/
|
|
273
|
+
constructor({ dbPath = defaultPluginDbPath(), env = process.env } = {}) {
|
|
274
|
+
/** @type {string} */
|
|
275
|
+
this.dbPath = String(dbPath)
|
|
276
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
277
|
+
this.env = env || process.env
|
|
278
|
+
/** @type {DatabaseSync|null} */
|
|
279
|
+
this.db = null
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Create the directory/file and tables. Idempotent. */
|
|
283
|
+
ensure() {
|
|
284
|
+
if (this.db) return
|
|
285
|
+
if (this.dbPath !== ':memory:') mkdirSync(dirname(this.dbPath), { recursive: true })
|
|
286
|
+
const db = new DatabaseSync(this.dbPath)
|
|
287
|
+
try {
|
|
288
|
+
db.exec('PRAGMA busy_timeout = 5000')
|
|
289
|
+
db.exec('PRAGMA journal_mode = WAL')
|
|
290
|
+
db.exec(SCHEMA_SQL)
|
|
291
|
+
} catch (err) {
|
|
292
|
+
try {
|
|
293
|
+
db.close()
|
|
294
|
+
} catch {
|
|
295
|
+
/* ignore close failure on a half-open db */
|
|
296
|
+
}
|
|
297
|
+
throw err
|
|
298
|
+
}
|
|
299
|
+
this.db = db
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** @returns {DatabaseSync} */
|
|
303
|
+
_db() {
|
|
304
|
+
this.ensure()
|
|
305
|
+
if (!this.db) throw new Error('MiniappStore: database is not open')
|
|
306
|
+
return this.db
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Close the connection; later calls reopen through `ensure()`. */
|
|
310
|
+
close() {
|
|
311
|
+
const db = this.db
|
|
312
|
+
this.db = null
|
|
313
|
+
if (db) {
|
|
314
|
+
try {
|
|
315
|
+
db.close()
|
|
316
|
+
} catch {
|
|
317
|
+
/* already closed */
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @param {{search?:string, status?:string, appid?:string}} [filters]
|
|
324
|
+
* @returns {any[]}
|
|
325
|
+
*/
|
|
326
|
+
listSources({ search = '', status = '', appid = '' } = {}) {
|
|
327
|
+
const db = this._db()
|
|
328
|
+
const where = []
|
|
329
|
+
const params = []
|
|
330
|
+
const term = String(search ?? '').trim()
|
|
331
|
+
if (term) {
|
|
332
|
+
const like = `%${escapeLike(term)}%`
|
|
333
|
+
where.push(
|
|
334
|
+
"(appid LIKE ? ESCAPE '\\' OR app_name LIKE ? ESCAPE '\\' OR version LIKE ? ESCAPE '\\' " +
|
|
335
|
+
"OR package_dir LIKE ? ESCAPE '\\' OR root_path LIKE ? ESCAPE '\\')",
|
|
336
|
+
)
|
|
337
|
+
params.push(like, like, like, like, like)
|
|
338
|
+
}
|
|
339
|
+
if (status) {
|
|
340
|
+
where.push('status = ?')
|
|
341
|
+
params.push(String(status))
|
|
342
|
+
}
|
|
343
|
+
if (appid) {
|
|
344
|
+
where.push('appid = ?')
|
|
345
|
+
params.push(String(appid))
|
|
346
|
+
}
|
|
347
|
+
let sql = 'SELECT * FROM miniapp_sources'
|
|
348
|
+
if (where.length > 0) sql += ` WHERE ${where.join(' AND ')}`
|
|
349
|
+
const rows = db.prepare(sql).all(...params).map(rowToSource)
|
|
350
|
+
rows.sort(compareSources)
|
|
351
|
+
return rows
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** @param {string} id @returns {any|null} */
|
|
355
|
+
getSource(id) {
|
|
356
|
+
const row = this._db().prepare('SELECT * FROM miniapp_sources WHERE id = ?').get(String(id))
|
|
357
|
+
return row ? rowToSource(row) : null
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {{appid:string, version?:string}} query
|
|
362
|
+
* @returns {any[]} newest version first
|
|
363
|
+
*/
|
|
364
|
+
findSources({ appid, version = '' } = {}) {
|
|
365
|
+
const db = this._db()
|
|
366
|
+
const rows = version
|
|
367
|
+
? db
|
|
368
|
+
.prepare('SELECT * FROM miniapp_sources WHERE appid = ? AND version = ?')
|
|
369
|
+
.all(String(appid), String(version))
|
|
370
|
+
: db.prepare('SELECT * FROM miniapp_sources WHERE appid = ?').all(String(appid))
|
|
371
|
+
const sources = rows.map(rowToSource)
|
|
372
|
+
sources.sort((a, b) => compareVersionDesc(a.version, b.version))
|
|
373
|
+
return sources
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** @param {string} appid @returns {any|null} */
|
|
377
|
+
latestForApp(appid) {
|
|
378
|
+
const rows = this.findSources({ appid })
|
|
379
|
+
return rows.length > 0 ? rows[0] : null
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Insert or reconcile one detected package directory.
|
|
384
|
+
*
|
|
385
|
+
* - identical file list/sizes -> no write, `changed:false`; an existing
|
|
386
|
+
* `unpacked`/`unsupported`/`failed` status and `unpacked_path` are kept;
|
|
387
|
+
* - changed file list/sizes -> reset to `detected` and drop stale output.
|
|
388
|
+
*
|
|
389
|
+
* @param {Record<string, any>} input
|
|
390
|
+
* @returns {{row:any, created:boolean, changed:boolean}}
|
|
391
|
+
*/
|
|
392
|
+
upsertSource(input = {}) {
|
|
393
|
+
const db = this._db()
|
|
394
|
+
const rootPath = requirePath(input.root_path, 'root_path')
|
|
395
|
+
const packageDir = requirePath(input.package_dir, 'package_dir')
|
|
396
|
+
const incomingFiles =
|
|
397
|
+
input.files !== undefined ? input.files : parseFilesJson(toText(input.files_json))
|
|
398
|
+
const files = normalizeFiles(incomingFiles)
|
|
399
|
+
const filesJson = JSON.stringify(files)
|
|
400
|
+
const computedBytes = files.reduce((sum, file) => sum + file.size, 0)
|
|
401
|
+
const sizeBytes = input.size_bytes !== undefined ? toInt(input.size_bytes, computedBytes) : computedBytes
|
|
402
|
+
const fileCount = input.file_count !== undefined ? toInt(input.file_count, files.length) : files.length
|
|
403
|
+
const id =
|
|
404
|
+
typeof input.id === 'string' && input.id
|
|
405
|
+
? input.id
|
|
406
|
+
: sourceIdFor(rootPath, packageDir)
|
|
407
|
+
const now = nowIso()
|
|
408
|
+
|
|
409
|
+
const existingRow = db.prepare('SELECT * FROM miniapp_sources WHERE id = ?').get(id)
|
|
410
|
+
const existing = existingRow ? rowToSource(existingRow) : null
|
|
411
|
+
const format = input.format !== undefined ? normalizeFormat(input.format) : existing ? existing.format : 'unknown'
|
|
412
|
+
|
|
413
|
+
if (!existing) {
|
|
414
|
+
const row = {
|
|
415
|
+
id,
|
|
416
|
+
root_path: rootPath,
|
|
417
|
+
root_kind: toText(input.root_kind) || 'custom',
|
|
418
|
+
appid: toText(input.appid),
|
|
419
|
+
app_name: toText(input.app_name),
|
|
420
|
+
version: toText(input.version),
|
|
421
|
+
package_dir: packageDir,
|
|
422
|
+
files_json: filesJson,
|
|
423
|
+
size_bytes: sizeBytes,
|
|
424
|
+
file_count: fileCount,
|
|
425
|
+
package_hash: toText(input.package_hash),
|
|
426
|
+
format,
|
|
427
|
+
status: normalizeStatus(input.status, 'detected'),
|
|
428
|
+
unpacked_path: toText(input.unpacked_path),
|
|
429
|
+
error: toText(input.error),
|
|
430
|
+
detected_at: toText(input.detected_at) || now,
|
|
431
|
+
updated_at: now,
|
|
432
|
+
}
|
|
433
|
+
db.prepare(SOURCE_INSERT).run(
|
|
434
|
+
row.id,
|
|
435
|
+
row.root_path,
|
|
436
|
+
row.root_kind,
|
|
437
|
+
row.appid,
|
|
438
|
+
row.app_name,
|
|
439
|
+
row.version,
|
|
440
|
+
row.package_dir,
|
|
441
|
+
row.files_json,
|
|
442
|
+
row.size_bytes,
|
|
443
|
+
row.file_count,
|
|
444
|
+
row.package_hash,
|
|
445
|
+
row.format,
|
|
446
|
+
row.status,
|
|
447
|
+
row.unpacked_path,
|
|
448
|
+
row.error,
|
|
449
|
+
row.detected_at,
|
|
450
|
+
row.updated_at,
|
|
451
|
+
)
|
|
452
|
+
return { row: rowToSource(row), created: true, changed: true }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const filesChanged =
|
|
456
|
+
!sameFiles(existing.files, files) ||
|
|
457
|
+
sizeBytes !== Number(existing.size_bytes) ||
|
|
458
|
+
fileCount !== Number(existing.file_count)
|
|
459
|
+
|
|
460
|
+
const next = {
|
|
461
|
+
...existing,
|
|
462
|
+
root_path: rootPath,
|
|
463
|
+
root_kind: input.root_kind !== undefined ? toText(input.root_kind) : existing.root_kind,
|
|
464
|
+
appid: input.appid !== undefined ? toText(input.appid) : existing.appid,
|
|
465
|
+
app_name: input.app_name !== undefined ? toText(input.app_name) : existing.app_name,
|
|
466
|
+
version: input.version !== undefined ? toText(input.version) : existing.version,
|
|
467
|
+
package_dir: packageDir,
|
|
468
|
+
files_json: filesJson,
|
|
469
|
+
files,
|
|
470
|
+
size_bytes: sizeBytes,
|
|
471
|
+
file_count: fileCount,
|
|
472
|
+
format,
|
|
473
|
+
updated_at: now,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (filesChanged) {
|
|
477
|
+
// The package payload changed: previous unpack/analysis output is stale.
|
|
478
|
+
next.status = 'detected'
|
|
479
|
+
next.unpacked_path = ''
|
|
480
|
+
next.package_hash = ''
|
|
481
|
+
next.error = ''
|
|
482
|
+
} else if (PRESERVED_STATUSES.has(existing.status)) {
|
|
483
|
+
next.status = existing.status
|
|
484
|
+
next.unpacked_path = existing.unpacked_path
|
|
485
|
+
next.package_hash = existing.package_hash
|
|
486
|
+
next.error = existing.error
|
|
487
|
+
} else {
|
|
488
|
+
next.status =
|
|
489
|
+
input.status !== undefined ? normalizeStatus(input.status, existing.status) : existing.status
|
|
490
|
+
next.unpacked_path =
|
|
491
|
+
input.unpacked_path !== undefined ? toText(input.unpacked_path) : existing.unpacked_path
|
|
492
|
+
next.package_hash =
|
|
493
|
+
input.package_hash !== undefined ? toText(input.package_hash) : existing.package_hash
|
|
494
|
+
next.error = input.error !== undefined ? toText(input.error) : existing.error
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const changed = contentChanged(existing, next)
|
|
498
|
+
if (!changed) return { row: existing, created: false, changed: false }
|
|
499
|
+
|
|
500
|
+
db.prepare(SOURCE_UPDATE).run(
|
|
501
|
+
next.root_path,
|
|
502
|
+
next.root_kind,
|
|
503
|
+
next.appid,
|
|
504
|
+
next.app_name,
|
|
505
|
+
next.version,
|
|
506
|
+
next.package_dir,
|
|
507
|
+
next.files_json,
|
|
508
|
+
next.size_bytes,
|
|
509
|
+
next.file_count,
|
|
510
|
+
next.package_hash,
|
|
511
|
+
next.format,
|
|
512
|
+
next.status,
|
|
513
|
+
next.unpacked_path,
|
|
514
|
+
next.error,
|
|
515
|
+
now,
|
|
516
|
+
next.id,
|
|
517
|
+
)
|
|
518
|
+
return { row: rowToSource({ ...next, updated_at: now }), created: false, changed: true }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Patch a source after unpack/analyze. Unknown keys and invalid statuses are
|
|
523
|
+
* ignored; `updated_at` is always refreshed when something is applied.
|
|
524
|
+
* @param {string} id
|
|
525
|
+
* @param {Record<string, any>} patch
|
|
526
|
+
* @returns {any|null}
|
|
527
|
+
*/
|
|
528
|
+
updateSource(id, patch = {}) {
|
|
529
|
+
const db = this._db()
|
|
530
|
+
const existingRow = db.prepare('SELECT * FROM miniapp_sources WHERE id = ?').get(String(id))
|
|
531
|
+
if (!existingRow) return null
|
|
532
|
+
const existing = rowToSource(existingRow)
|
|
533
|
+
const sets = []
|
|
534
|
+
const params = []
|
|
535
|
+
const textFields = ['root_kind', 'appid', 'app_name', 'version', 'package_hash', 'unpacked_path', 'error']
|
|
536
|
+
for (const field of textFields) {
|
|
537
|
+
if (patch[field] !== undefined) {
|
|
538
|
+
sets.push(`${field} = ?`)
|
|
539
|
+
params.push(toText(patch[field]))
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (patch.status !== undefined) {
|
|
543
|
+
const status = normalizeStatus(patch.status, '')
|
|
544
|
+
if (status) {
|
|
545
|
+
sets.push('status = ?')
|
|
546
|
+
params.push(status)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (patch.format !== undefined) {
|
|
550
|
+
sets.push('format = ?')
|
|
551
|
+
params.push(normalizeFormat(patch.format))
|
|
552
|
+
}
|
|
553
|
+
if (patch.files !== undefined) {
|
|
554
|
+
const files = normalizeFiles(patch.files)
|
|
555
|
+
sets.push('files_json = ?')
|
|
556
|
+
params.push(JSON.stringify(files))
|
|
557
|
+
sets.push('file_count = ?')
|
|
558
|
+
params.push(files.length)
|
|
559
|
+
sets.push('size_bytes = ?')
|
|
560
|
+
params.push(files.reduce((sum, file) => sum + file.size, 0))
|
|
561
|
+
} else if (patch.files_json !== undefined) {
|
|
562
|
+
sets.push('files_json = ?')
|
|
563
|
+
params.push(toText(patch.files_json))
|
|
564
|
+
}
|
|
565
|
+
if (patch.size_bytes !== undefined) {
|
|
566
|
+
sets.push('size_bytes = ?')
|
|
567
|
+
params.push(toInt(patch.size_bytes, existing.size_bytes))
|
|
568
|
+
}
|
|
569
|
+
if (patch.file_count !== undefined) {
|
|
570
|
+
sets.push('file_count = ?')
|
|
571
|
+
params.push(toInt(patch.file_count, existing.file_count))
|
|
572
|
+
}
|
|
573
|
+
if (sets.length === 0) return existing
|
|
574
|
+
sets.push('updated_at = ?')
|
|
575
|
+
params.push(nowIso())
|
|
576
|
+
params.push(String(id))
|
|
577
|
+
db.prepare(`UPDATE miniapp_sources SET ${sets.join(', ')} WHERE id = ?`).run(...params)
|
|
578
|
+
return this.getSource(id)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** @param {string} sourceId @returns {object|null} */
|
|
582
|
+
getAnalysis(sourceId) {
|
|
583
|
+
const row = this._db()
|
|
584
|
+
.prepare('SELECT overview_json FROM miniapp_analyses WHERE source_id = ?')
|
|
585
|
+
.get(String(sourceId))
|
|
586
|
+
if (!row) return null
|
|
587
|
+
try {
|
|
588
|
+
const parsed = JSON.parse(row.overview_json)
|
|
589
|
+
return parsed && typeof parsed === 'object' ? parsed : null
|
|
590
|
+
} catch {
|
|
591
|
+
return null
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** @param {string} sourceId @param {object} overview */
|
|
596
|
+
saveAnalysis(sourceId, overview) {
|
|
597
|
+
const db = this._db()
|
|
598
|
+
const json = JSON.stringify(overview ?? {})
|
|
599
|
+
db.prepare(
|
|
600
|
+
`INSERT INTO miniapp_analyses (source_id, overview_json, updated_at)
|
|
601
|
+
VALUES (?, ?, ?)
|
|
602
|
+
ON CONFLICT(source_id) DO UPDATE SET overview_json = excluded.overview_json,
|
|
603
|
+
updated_at = excluded.updated_at`,
|
|
604
|
+
).run(String(sourceId), json, nowIso())
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** @param {string} key @param {string|null} [fallback] @returns {string|null} */
|
|
608
|
+
getSetting(key, fallback = null) {
|
|
609
|
+
const row = this._db().prepare('SELECT value FROM miniapp_settings WHERE key = ?').get(String(key))
|
|
610
|
+
return row ? String(row.value) : fallback
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** @param {string} key @param {unknown} value @returns {string} */
|
|
614
|
+
setSetting(key, value) {
|
|
615
|
+
const text = value === undefined || value === null ? '' : String(value)
|
|
616
|
+
this._db()
|
|
617
|
+
.prepare(
|
|
618
|
+
`INSERT INTO miniapp_settings (key, value, updated_at) VALUES (?, ?, ?)
|
|
619
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
|
620
|
+
)
|
|
621
|
+
.run(String(key), text, nowIso())
|
|
622
|
+
return text
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** @returns {string[]} */
|
|
626
|
+
customRoots() {
|
|
627
|
+
const raw = this.getSetting(CUSTOM_ROOTS_KEY, '[]')
|
|
628
|
+
try {
|
|
629
|
+
const parsed = JSON.parse(raw ?? '[]')
|
|
630
|
+
if (!Array.isArray(parsed)) return []
|
|
631
|
+
return parsed.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim())
|
|
632
|
+
} catch {
|
|
633
|
+
return []
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** @param {string[]} roots @returns {string[]} normalized, de-duplicated */
|
|
638
|
+
setCustomRoots(roots) {
|
|
639
|
+
const list = []
|
|
640
|
+
const seen = new Set()
|
|
641
|
+
for (const entry of Array.isArray(roots) ? roots : []) {
|
|
642
|
+
if (typeof entry !== 'string' || entry.trim() === '') continue
|
|
643
|
+
const absolute = resolve(entry.trim())
|
|
644
|
+
const key = process.platform === 'win32' ? absolute.toLowerCase() : absolute
|
|
645
|
+
if (seen.has(key)) continue
|
|
646
|
+
seen.add(key)
|
|
647
|
+
list.push(absolute)
|
|
648
|
+
}
|
|
649
|
+
this.setSetting(CUSTOM_ROOTS_KEY, JSON.stringify(list))
|
|
650
|
+
return list
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* @param {{source_id:string, kind:string, status?:string, message?:string}} input
|
|
655
|
+
* @returns {any}
|
|
656
|
+
*/
|
|
657
|
+
createJob({ source_id, kind, status = 'queued', message = '' } = {}) {
|
|
658
|
+
const db = this._db()
|
|
659
|
+
const now = nowIso()
|
|
660
|
+
const job = {
|
|
661
|
+
id: randomUUID(),
|
|
662
|
+
source_id: toText(source_id),
|
|
663
|
+
kind: toText(kind),
|
|
664
|
+
status: toText(status) || 'queued',
|
|
665
|
+
progress: 0,
|
|
666
|
+
message: toText(message),
|
|
667
|
+
created_at: now,
|
|
668
|
+
updated_at: now,
|
|
669
|
+
}
|
|
670
|
+
db.prepare(
|
|
671
|
+
`INSERT INTO miniapp_jobs
|
|
672
|
+
(id, source_id, kind, status, progress, message, created_at, updated_at)
|
|
673
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
674
|
+
).run(job.id, job.source_id, job.kind, job.status, job.progress, job.message, job.created_at, job.updated_at)
|
|
675
|
+
return job
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* @param {string} id
|
|
680
|
+
* @param {{status?:string, progress?:number, message?:string}} patch
|
|
681
|
+
* @returns {any|null}
|
|
682
|
+
*/
|
|
683
|
+
updateJob(id, patch = {}) {
|
|
684
|
+
const db = this._db()
|
|
685
|
+
const existing = db.prepare('SELECT * FROM miniapp_jobs WHERE id = ?').get(String(id))
|
|
686
|
+
if (!existing) return null
|
|
687
|
+
const sets = []
|
|
688
|
+
const params = []
|
|
689
|
+
if (patch.status !== undefined) {
|
|
690
|
+
sets.push('status = ?')
|
|
691
|
+
params.push(toText(patch.status) || existing.status)
|
|
692
|
+
}
|
|
693
|
+
if (patch.progress !== undefined) {
|
|
694
|
+
const progress = Math.max(0, Math.min(100, toInt(patch.progress, existing.progress)))
|
|
695
|
+
sets.push('progress = ?')
|
|
696
|
+
params.push(progress)
|
|
697
|
+
}
|
|
698
|
+
if (patch.message !== undefined) {
|
|
699
|
+
sets.push('message = ?')
|
|
700
|
+
params.push(toText(patch.message))
|
|
701
|
+
}
|
|
702
|
+
if (sets.length === 0) return { ...existing }
|
|
703
|
+
sets.push('updated_at = ?')
|
|
704
|
+
params.push(nowIso())
|
|
705
|
+
params.push(String(id))
|
|
706
|
+
db.prepare(`UPDATE miniapp_jobs SET ${sets.join(', ')} WHERE id = ?`).run(...params)
|
|
707
|
+
return this.getJob(id)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** @param {string} id @returns {any|null} */
|
|
711
|
+
getJob(id) {
|
|
712
|
+
const row = this._db().prepare('SELECT * FROM miniapp_jobs WHERE id = ?').get(String(id))
|
|
713
|
+
return row ? { ...row } : null
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* @param {{sourceId?:string, limit?:number}} [options]
|
|
718
|
+
* @returns {any[]}
|
|
719
|
+
*/
|
|
720
|
+
listJobs({ sourceId, limit = 20 } = {}) {
|
|
721
|
+
const db = this._db()
|
|
722
|
+
const bounded = Math.max(1, Math.min(200, toInt(limit, 20)))
|
|
723
|
+
const rows = sourceId
|
|
724
|
+
? db
|
|
725
|
+
.prepare('SELECT * FROM miniapp_jobs WHERE source_id = ? ORDER BY created_at DESC, id DESC LIMIT ?')
|
|
726
|
+
.all(String(sourceId), bounded)
|
|
727
|
+
: db.prepare('SELECT * FROM miniapp_jobs ORDER BY created_at DESC, id DESC LIMIT ?').all(bounded)
|
|
728
|
+
return rows.map((row) => ({ ...row }))
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** @type {Map<string, MiniappStore>} */
|
|
733
|
+
const stores = new Map()
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Process-wide singleton keyed by database path.
|
|
737
|
+
* @param {{dbPath?:string, env?:NodeJS.ProcessEnv}} [options]
|
|
738
|
+
* @returns {MiniappStore}
|
|
739
|
+
*/
|
|
740
|
+
export function getMiniappStore(options = {}) {
|
|
741
|
+
const env = options.env ?? process.env
|
|
742
|
+
const dbPath = options.dbPath ?? defaultPluginDbPath(env)
|
|
743
|
+
let store = stores.get(dbPath)
|
|
744
|
+
if (!store) {
|
|
745
|
+
store = new MiniappStore({ dbPath, env })
|
|
746
|
+
store.ensure()
|
|
747
|
+
stores.set(dbPath, store)
|
|
748
|
+
}
|
|
749
|
+
return store
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** Close and forget every singleton (tests only). */
|
|
753
|
+
export function resetMiniappStoreForTests() {
|
|
754
|
+
for (const store of stores.values()) store.close()
|
|
755
|
+
stores.clear()
|
|
756
|
+
}
|