@hunan36/dsh-ssh-helper 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +330 -2
- package/README.zh.md +331 -0
- package/client.js +2786 -0
- package/cordis.patch.yml +13 -0
- package/docs/screenshot-accounts.png +0 -0
- package/docs/screenshot-activity.png +0 -0
- package/docs/screenshot-panel.png +0 -0
- package/docs/screenshot-resource-detail.png +0 -0
- package/host/accounts.js +688 -0
- package/host/api.js +440 -0
- package/host/atomic.js +160 -0
- package/host/discover.js +440 -0
- package/host/gate.js +264 -0
- package/host/history.js +276 -0
- package/host/prompt.js +103 -0
- package/host/resources.js +604 -0
- package/host/shell.js +194 -0
- package/host/ssh.js +460 -0
- package/host/tools.js +1086 -0
- package/host/update.js +992 -0
- package/host/upload.js +399 -0
- package/icon.svg +19 -0
- package/index.js +141 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +75 -4
package/host/api.js
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP API for the dsh-ssh-helper Settings section.
|
|
3
|
+
*
|
|
4
|
+
* One prefix route, `/ssh-helper/api`, answers
|
|
5
|
+
* `POST /ssh-helper/api/<method>` with a JSON body and a JSON envelope:
|
|
6
|
+
* `{ ok: true, data }` on success and `{ ok: false, error: { code, message } }`
|
|
7
|
+
* on failure. Requests are fenced by `ctx.connection.requestRejection`, bodies
|
|
8
|
+
* are bounded to 1 MiB, and no handler ever returns a password, private key or
|
|
9
|
+
* passphrase.
|
|
10
|
+
*
|
|
11
|
+
* @module host/api
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Route prefix owned by this plugin. */
|
|
15
|
+
const API_PREFIX = '/ssh-helper/api'
|
|
16
|
+
/** Maximum accepted JSON body size (1 MiB). */
|
|
17
|
+
const MAX_BODY_BYTES = 1024 * 1024
|
|
18
|
+
/** Message shown for unexpected internal failures. */
|
|
19
|
+
const INTERNAL_MESSAGE = '服务器内部错误'
|
|
20
|
+
/** Recent history entries returned by `resources/get`. */
|
|
21
|
+
const RECENT_HISTORY_LIMIT = 20
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create one tagged error.
|
|
25
|
+
* @param {string} code - machine-readable code.
|
|
26
|
+
* @param {string} message - friendly Chinese message.
|
|
27
|
+
* @returns {Error & { code: string }} tagged error.
|
|
28
|
+
*/
|
|
29
|
+
function apiError(code, message) {
|
|
30
|
+
const error = new Error(message)
|
|
31
|
+
error.code = code
|
|
32
|
+
return error
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {unknown} value @returns {boolean} whether the value is a plain object. */
|
|
36
|
+
function isObject(value) {
|
|
37
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a response is still writable.
|
|
42
|
+
* @param {object} res - ServerResponse-like object.
|
|
43
|
+
* @returns {boolean} writability.
|
|
44
|
+
*/
|
|
45
|
+
function canWrite(res) {
|
|
46
|
+
if (!res || typeof res !== 'object') return false
|
|
47
|
+
if (res.headersSent === true || res.writableEnded === true || res.destroyed === true) return false
|
|
48
|
+
return typeof res.writeHead === 'function' && typeof res.end === 'function'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Write one JSON response, tolerating a client that already went away.
|
|
53
|
+
* @param {object} res - ServerResponse-like object.
|
|
54
|
+
* @param {number} status - HTTP status.
|
|
55
|
+
* @param {object} body - JSON body.
|
|
56
|
+
*/
|
|
57
|
+
function writeJson(res, status, body) {
|
|
58
|
+
if (!canWrite(res)) {
|
|
59
|
+
try {
|
|
60
|
+
res.destroy?.()
|
|
61
|
+
} catch {
|
|
62
|
+
/* nothing left to release */
|
|
63
|
+
}
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
let payload
|
|
67
|
+
try {
|
|
68
|
+
payload = JSON.stringify(body)
|
|
69
|
+
} catch {
|
|
70
|
+
payload = JSON.stringify({ ok: false, error: { code: 'internal', message: INTERNAL_MESSAGE } })
|
|
71
|
+
status = 500
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
75
|
+
res.end(payload)
|
|
76
|
+
} catch {
|
|
77
|
+
try {
|
|
78
|
+
res.destroy?.()
|
|
79
|
+
} catch {
|
|
80
|
+
/* response already gone */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Write the success envelope.
|
|
87
|
+
* @param {object} res - response.
|
|
88
|
+
* @param {object} data - payload.
|
|
89
|
+
*/
|
|
90
|
+
function writeOk(res, data) {
|
|
91
|
+
writeJson(res, 200, { ok: true, data })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Write the failure envelope.
|
|
96
|
+
* @param {object} res - response.
|
|
97
|
+
* @param {number} status - HTTP status.
|
|
98
|
+
* @param {string} code - machine-readable code.
|
|
99
|
+
* @param {string} message - friendly Chinese message.
|
|
100
|
+
*/
|
|
101
|
+
function writeFail(res, status, code, message) {
|
|
102
|
+
writeJson(res, status, { ok: false, error: { code, message } })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Map one thrown value to the API failure envelope.
|
|
107
|
+
* @param {object} ctx - host plugin context (logger optional).
|
|
108
|
+
* @param {object} res - response.
|
|
109
|
+
* @param {unknown} error - thrown value.
|
|
110
|
+
*/
|
|
111
|
+
function writeError(ctx, res, error) {
|
|
112
|
+
const code = error && typeof error.code === 'string' ? error.code : ''
|
|
113
|
+
const message = error && typeof error.message === 'string' ? error.message : ''
|
|
114
|
+
if (code === 'invalid-input' || code === 'invalid-path') return writeFail(res, 400, code, message || '请求参数无效')
|
|
115
|
+
if (code === 'bad-request') return writeFail(res, 400, code, message || '请求格式无效')
|
|
116
|
+
if (code === 'unsupported-media-type') return writeFail(res, 415, code, message || '请求 content-type 不受支持')
|
|
117
|
+
if (code === 'conflict') return writeFail(res, 409, code, message || '请求存在冲突')
|
|
118
|
+
if (code === 'busy') return writeFail(res, 409, code, message || '操作正在进行中')
|
|
119
|
+
if (code === 'not-found' || code === 'plan-not-found' || code === 'no-backup') {
|
|
120
|
+
return writeFail(res, 404, code, message || '资源不存在')
|
|
121
|
+
}
|
|
122
|
+
if (code === 'plan-expired') return writeFail(res, 410, code, message || '计划已过期')
|
|
123
|
+
try {
|
|
124
|
+
ctx?.logger?.warn?.(`dsh-ssh-helper: API request failed${message ? `: ${message}` : ''}`)
|
|
125
|
+
} catch {
|
|
126
|
+
/* logging is best effort */
|
|
127
|
+
}
|
|
128
|
+
return writeFail(res, 500, 'internal', INTERNAL_MESSAGE)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read and parse the bounded JSON request body.
|
|
133
|
+
*
|
|
134
|
+
* A body larger than `MAX_BODY_BYTES` destroys the request socket instead of
|
|
135
|
+
* only abandoning the remainder, so an attacker cannot keep streaming bytes at
|
|
136
|
+
* a bounded buffer.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} req - IncomingMessage-like async iterable.
|
|
139
|
+
* @returns {Promise<object>} parsed body (`{}` for an empty body).
|
|
140
|
+
*/
|
|
141
|
+
async function readJsonBody(req) {
|
|
142
|
+
const chunks = []
|
|
143
|
+
let total = 0
|
|
144
|
+
let oversized = false
|
|
145
|
+
for await (const chunk of req) {
|
|
146
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
147
|
+
total += buffer.length
|
|
148
|
+
if (total > MAX_BODY_BYTES) {
|
|
149
|
+
oversized = true
|
|
150
|
+
break
|
|
151
|
+
}
|
|
152
|
+
chunks.push(buffer)
|
|
153
|
+
}
|
|
154
|
+
if (oversized) {
|
|
155
|
+
// Do not merely abandon the remainder: drop the socket so the sender stops
|
|
156
|
+
// streaming instead of being throttled at the 1 MiB boundary.
|
|
157
|
+
try {
|
|
158
|
+
req.destroy?.()
|
|
159
|
+
} catch {
|
|
160
|
+
/* the socket is already gone */
|
|
161
|
+
}
|
|
162
|
+
throw apiError('bad-request', '请求体超过 1 MiB 限制')
|
|
163
|
+
}
|
|
164
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
165
|
+
if (text.trim() === '') return {}
|
|
166
|
+
let parsed
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(text)
|
|
169
|
+
} catch {
|
|
170
|
+
throw apiError('bad-request', '请求体不是有效的 JSON')
|
|
171
|
+
}
|
|
172
|
+
if (!isObject(parsed)) throw apiError('bad-request', '请求体必须是 JSON 对象')
|
|
173
|
+
return parsed
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether one request declares a JSON body.
|
|
178
|
+
*
|
|
179
|
+
* The check is conservative: an absent or unrelated `content-type` is rejected.
|
|
180
|
+
* A browser cannot send `application/json` cross-origin without a preflight, so
|
|
181
|
+
* this is a second fence behind `requestRejection` and it keeps form-encoded or
|
|
182
|
+
* plain-text traffic away from the JSON parser.
|
|
183
|
+
*
|
|
184
|
+
* @param {object} req - IncomingMessage-like object.
|
|
185
|
+
* @returns {boolean} whether the request declares `application/json`.
|
|
186
|
+
*/
|
|
187
|
+
function hasJsonContentType(req) {
|
|
188
|
+
const headers = req && typeof req === 'object' ? req.headers : undefined
|
|
189
|
+
const raw = headers && typeof headers === 'object' ? headers['content-type'] : undefined
|
|
190
|
+
const contentType = typeof raw === 'string' ? raw.trim() : ''
|
|
191
|
+
return /^application\/json\b/i.test(contentType)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Require one non-empty string id.
|
|
196
|
+
* @param {object} body - request body.
|
|
197
|
+
* @param {string} [label] - error label.
|
|
198
|
+
* @returns {string} trimmed id.
|
|
199
|
+
*/
|
|
200
|
+
function requireId(body, label = 'id') {
|
|
201
|
+
const id = body && typeof body === 'object' ? body.id : undefined
|
|
202
|
+
if (typeof id !== 'string' || id.trim() === '') throw apiError('invalid-input', `缺少 ${label}`)
|
|
203
|
+
return id.trim()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Run a connectivity test without failing the API call for a reachable
|
|
208
|
+
* failure: a failed connection is a successful response with `success: false`.
|
|
209
|
+
* @param {object} ssh - ssh client.
|
|
210
|
+
* @param {object} record - full record including credentials.
|
|
211
|
+
* @returns {Promise<{ success: boolean, latencyMs: number, message: string, output?: string }>} test result.
|
|
212
|
+
*/
|
|
213
|
+
async function runConnectionTest(ssh, record) {
|
|
214
|
+
const startedAt = Date.now()
|
|
215
|
+
try {
|
|
216
|
+
const probed = await ssh.probe(record)
|
|
217
|
+
return {
|
|
218
|
+
success: true,
|
|
219
|
+
latencyMs: typeof probed.latencyMs === 'number' ? probed.latencyMs : Date.now() - startedAt,
|
|
220
|
+
message: '连接成功',
|
|
221
|
+
output: typeof probed.output === 'string' ? probed.output : '',
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
const message = error && typeof error.message === 'string' && error.message !== '' ? error.message : '连接失败'
|
|
225
|
+
return { success: false, latencyMs: Date.now() - startedAt, message }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Build the method table.
|
|
231
|
+
* @param {{ accounts: object, resources: object, history: object, ssh: object }} services - stores and client.
|
|
232
|
+
* @returns {Record<string, (body: object) => Promise<object>>} method handlers.
|
|
233
|
+
*/
|
|
234
|
+
function createMethods({ accounts, resources, history, ssh }) {
|
|
235
|
+
return {
|
|
236
|
+
/**
|
|
237
|
+
* @returns {Promise<{ accounts: object[], legacy: { available: boolean, count: number } }>} accounts and legacy info.
|
|
238
|
+
*/
|
|
239
|
+
async 'accounts/list'() {
|
|
240
|
+
const info = accounts.legacyInfo()
|
|
241
|
+
// `legacyInfo().source` is an absolute host path; it must not reach the
|
|
242
|
+
// browser, so only the two countable fields are projected here.
|
|
243
|
+
return {
|
|
244
|
+
accounts: accounts.list(),
|
|
245
|
+
legacy: { available: info.available === true, count: typeof info.count === 'number' ? info.count : 0 },
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
/**
|
|
249
|
+
* @param {object} body - flat account payload with optional `id`.
|
|
250
|
+
* @returns {Promise<{ account: object }>} saved account view.
|
|
251
|
+
*/
|
|
252
|
+
async 'accounts/save'(body) {
|
|
253
|
+
const id = typeof body.id === 'string' && body.id.trim() !== '' ? body.id.trim() : ''
|
|
254
|
+
const account = id === '' ? await accounts.create(body) : await accounts.update(id, body)
|
|
255
|
+
return { account }
|
|
256
|
+
},
|
|
257
|
+
/**
|
|
258
|
+
* @param {object} body - `{ id }`.
|
|
259
|
+
* @returns {Promise<{ deleted: true }>} deletion marker.
|
|
260
|
+
*/
|
|
261
|
+
async 'accounts/delete'(body) {
|
|
262
|
+
const id = requireId(body, '账户 id')
|
|
263
|
+
const deleted = await accounts.remove(id)
|
|
264
|
+
if (!deleted) throw apiError('not-found', '账户不存在')
|
|
265
|
+
return { deleted: true }
|
|
266
|
+
},
|
|
267
|
+
/**
|
|
268
|
+
* @param {object} body - `{ id }`.
|
|
269
|
+
* @returns {Promise<object>} connectivity test result.
|
|
270
|
+
*/
|
|
271
|
+
async 'accounts/test'(body) {
|
|
272
|
+
const id = requireId(body, '账户 id')
|
|
273
|
+
const record = accounts.getSecretRecord(id)
|
|
274
|
+
if (!record) throw apiError('not-found', '账户不存在')
|
|
275
|
+
return runConnectionTest(ssh, record)
|
|
276
|
+
},
|
|
277
|
+
/**
|
|
278
|
+
* @param {object} body - account draft.
|
|
279
|
+
* @returns {Promise<object>} connectivity test result.
|
|
280
|
+
*/
|
|
281
|
+
async 'accounts/test-draft'(body) {
|
|
282
|
+
const record = await accounts.testDraft(body)
|
|
283
|
+
return runConnectionTest(ssh, record)
|
|
284
|
+
},
|
|
285
|
+
/** @returns {Promise<{ imported: number, skipped: number, total: number }>} import summary. */
|
|
286
|
+
async 'accounts/import'() {
|
|
287
|
+
return accounts.importLegacy()
|
|
288
|
+
},
|
|
289
|
+
/**
|
|
290
|
+
* @param {object} body - optional `{ server }` filter.
|
|
291
|
+
* @returns {Promise<{ resources: object[] }>} resource views.
|
|
292
|
+
*/
|
|
293
|
+
async 'resources/list'(body) {
|
|
294
|
+
return { resources: resources.list({ server: typeof body.server === 'string' ? body.server : undefined }) }
|
|
295
|
+
},
|
|
296
|
+
/**
|
|
297
|
+
* @param {object} body - `{ id }` or `{ resource }`.
|
|
298
|
+
* @returns {Promise<{ resource: object, history: object[] }>} resource and recent history.
|
|
299
|
+
*/
|
|
300
|
+
async 'resources/get'(body) {
|
|
301
|
+
const query =
|
|
302
|
+
typeof body.id === 'string' && body.id.trim() !== ''
|
|
303
|
+
? body.id.trim()
|
|
304
|
+
: typeof body.resource === 'string' && body.resource.trim() !== ''
|
|
305
|
+
? body.resource.trim()
|
|
306
|
+
: ''
|
|
307
|
+
if (query === '') throw apiError('invalid-input', '缺少资源 id')
|
|
308
|
+
const resource = resources.find(query)
|
|
309
|
+
if (!resource) throw apiError('not-found', '资源不存在')
|
|
310
|
+
return { resource, history: history.list({ resourceId: resource.id, limit: RECENT_HISTORY_LIMIT }) }
|
|
311
|
+
},
|
|
312
|
+
/**
|
|
313
|
+
* @param {object} body - flat resource payload with optional `id`.
|
|
314
|
+
* @returns {Promise<{ resource: object }>} saved resource view.
|
|
315
|
+
*/
|
|
316
|
+
async 'resources/save'(body) {
|
|
317
|
+
return { resource: await resources.save(body) }
|
|
318
|
+
},
|
|
319
|
+
/**
|
|
320
|
+
* @param {object} body - `{ id }`.
|
|
321
|
+
* @returns {Promise<{ deleted: true }>} deletion marker.
|
|
322
|
+
*/
|
|
323
|
+
async 'resources/delete'(body) {
|
|
324
|
+
const id = requireId(body, '资源 id')
|
|
325
|
+
const deleted = await resources.remove(id)
|
|
326
|
+
if (!deleted) throw apiError('not-found', '资源不存在')
|
|
327
|
+
return { deleted: true }
|
|
328
|
+
},
|
|
329
|
+
/**
|
|
330
|
+
* @param {object} body - `{ resource?, limit? }`.
|
|
331
|
+
* @returns {Promise<{ history: object[] }>} history entries.
|
|
332
|
+
*/
|
|
333
|
+
async 'history/list'(body) {
|
|
334
|
+
const rawResource = typeof body.resource === 'string' ? body.resource.trim() : ''
|
|
335
|
+
let resourceId
|
|
336
|
+
if (rawResource !== '') {
|
|
337
|
+
const resource = resources.find(rawResource)
|
|
338
|
+
resourceId = resource ? resource.id : rawResource
|
|
339
|
+
}
|
|
340
|
+
const limit =
|
|
341
|
+
typeof body.limit === 'number' && Number.isFinite(body.limit) ? Math.min(200, Math.max(1, Math.round(body.limit))) : undefined
|
|
342
|
+
return { history: history.list({ resourceId, limit }) }
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Register the `/ssh-helper/api` prefix route.
|
|
349
|
+
* @param {object} ctx - host plugin context (`webServer`, `connection`).
|
|
350
|
+
* @param {{ accounts: object, resources: object, history: object, ssh: object }} services - stores and client.
|
|
351
|
+
* @returns {() => void} route disposer.
|
|
352
|
+
*/
|
|
353
|
+
export function registerApiRoutes(ctx, { accounts, resources, history, ssh } = {}) {
|
|
354
|
+
if (!ctx || !ctx.webServer || typeof ctx.webServer.register !== 'function') {
|
|
355
|
+
throw new Error('dsh-ssh-helper: webServer service is unavailable')
|
|
356
|
+
}
|
|
357
|
+
if (!accounts || !resources || !history || !ssh) throw new Error('dsh-ssh-helper: API services are unavailable')
|
|
358
|
+
const methods = createMethods({ accounts, resources, history, ssh })
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Request handler for the prefix route.
|
|
362
|
+
* @param {object} req - IncomingMessage.
|
|
363
|
+
* @param {object} res - ServerResponse.
|
|
364
|
+
* @returns {Promise<void>} resolves once the response settled.
|
|
365
|
+
*/
|
|
366
|
+
async function handle(req, res) {
|
|
367
|
+
let closed = false
|
|
368
|
+
const markClosed = () => {
|
|
369
|
+
closed = true
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
res?.once?.('close', markClosed)
|
|
373
|
+
req?.once?.('error', markClosed)
|
|
374
|
+
} catch {
|
|
375
|
+
/* event registration is best effort */
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const connection = ctx.connection
|
|
379
|
+
let rejected = true
|
|
380
|
+
try {
|
|
381
|
+
rejected = !connection || typeof connection.requestRejection !== 'function' ? true : connection.requestRejection(req) !== undefined
|
|
382
|
+
} catch {
|
|
383
|
+
rejected = true
|
|
384
|
+
}
|
|
385
|
+
if (rejected) {
|
|
386
|
+
writeFail(res, 403, 'forbidden', '请求被拒绝')
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
if (req.method !== 'POST') {
|
|
390
|
+
writeFail(res, 405, 'method-not-allowed', '仅支持 POST 请求')
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
let method = ''
|
|
394
|
+
try {
|
|
395
|
+
const pathname = new URL(typeof req.url === 'string' && req.url !== '' ? req.url : '/', 'http://dsh.internal').pathname
|
|
396
|
+
if (pathname.startsWith(`${API_PREFIX}/`)) method = pathname.slice(API_PREFIX.length + 1)
|
|
397
|
+
} catch {
|
|
398
|
+
method = ''
|
|
399
|
+
}
|
|
400
|
+
if (method === '' || method.startsWith('/') || method.endsWith('/') || method.includes('//')) {
|
|
401
|
+
writeFail(res, 404, 'not-found', '未知的 API 方法')
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
const handler = Object.hasOwn(methods, method) ? methods[method] : undefined
|
|
405
|
+
if (typeof handler !== 'function') {
|
|
406
|
+
writeFail(res, 404, 'not-found', `未知的 API 方法 "${method}"`)
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
if (!hasJsonContentType(req)) {
|
|
410
|
+
writeFail(res, 415, 'unsupported-media-type', '请求 content-type 必须是 application/json')
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
let body
|
|
414
|
+
try {
|
|
415
|
+
body = await readJsonBody(req)
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (!closed) writeError(ctx, res, error)
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
if (closed) return
|
|
421
|
+
try {
|
|
422
|
+
const data = await handler(body)
|
|
423
|
+
if (!closed) writeOk(res, data)
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (!closed) writeError(ctx, res, error)
|
|
426
|
+
}
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (!closed) writeError(ctx, res, error)
|
|
429
|
+
} finally {
|
|
430
|
+
try {
|
|
431
|
+
res?.off?.('close', markClosed)
|
|
432
|
+
req?.off?.('error', markClosed)
|
|
433
|
+
} catch {
|
|
434
|
+
/* listener cleanup is best effort */
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return ctx.webServer.register({ kind: 'prefix', path: API_PREFIX, handler: handle })
|
|
440
|
+
}
|
package/host/atomic.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic JSON file storage.
|
|
3
|
+
*
|
|
4
|
+
* Every data file of the plugin goes through this module. Writes are serialized
|
|
5
|
+
* through one promise chain, written to `<file>.tmp` with mode `0600`, then
|
|
6
|
+
* renamed over the live file so a reader never observes a half-written JSON
|
|
7
|
+
* document. A file whose content is not valid JSON is moved aside to
|
|
8
|
+
* `<file>.corrupt-<timestamp>` and treated as absent, which lets the plugin
|
|
9
|
+
* start from an empty store instead of crashing.
|
|
10
|
+
*
|
|
11
|
+
* @module host/atomic
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { chmodSync, copyFileSync, readFileSync, renameSync } from 'node:fs'
|
|
15
|
+
import { chmod, mkdir, open, rename } from 'node:fs/promises'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
|
|
18
|
+
/** POSIX mode for data files and their temporary siblings. */
|
|
19
|
+
const FILE_MODE = 0o600
|
|
20
|
+
/** POSIX mode for the plugin data directory (owner-only). */
|
|
21
|
+
const DIR_MODE = 0o700
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Move an unreadable file aside without throwing.
|
|
25
|
+
*
|
|
26
|
+
* The quarantine copy can hold the credential JSON, so it is tightened to
|
|
27
|
+
* `0600` on both paths: `renameSync` preserves the mode of the source file
|
|
28
|
+
* (which a foreign writer may have created loosely) and `copyFileSync` creates
|
|
29
|
+
* the copy with the process umask.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} filePath - absolute path of the unreadable file.
|
|
32
|
+
* @returns {void}
|
|
33
|
+
*/
|
|
34
|
+
function quarantine(filePath) {
|
|
35
|
+
const backup = `${filePath}.corrupt-${Date.now()}`
|
|
36
|
+
try {
|
|
37
|
+
renameSync(filePath, backup)
|
|
38
|
+
chmodSync(backup, FILE_MODE)
|
|
39
|
+
return
|
|
40
|
+
} catch {
|
|
41
|
+
/* fall through: rename can fail across odd filesystems or permissions */
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
copyFileSync(filePath, backup)
|
|
45
|
+
chmodSync(backup, FILE_MODE)
|
|
46
|
+
} catch {
|
|
47
|
+
/* best effort: the caller still starts from an empty in-memory store */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create one atomically written JSON file handle.
|
|
53
|
+
*
|
|
54
|
+
* @param {{ dir: string, filename: string }} options - directory and file name.
|
|
55
|
+
* @returns {{
|
|
56
|
+
* path: string,
|
|
57
|
+
* read: () => unknown,
|
|
58
|
+
* write: (value: unknown) => Promise<void>,
|
|
59
|
+
* update: (mutate: (current: unknown) => unknown | Promise<unknown>) => Promise<unknown>,
|
|
60
|
+
* }} file handle.
|
|
61
|
+
*/
|
|
62
|
+
export function createJsonFile({ dir, filename } = {}) {
|
|
63
|
+
if (typeof dir !== 'string' || dir.trim() === '') throw new TypeError('createJsonFile: dir is required')
|
|
64
|
+
if (typeof filename !== 'string' || filename.trim() === '') throw new TypeError('createJsonFile: filename is required')
|
|
65
|
+
|
|
66
|
+
const filePath = path.join(path.resolve(dir), filename)
|
|
67
|
+
const tmpPath = `${filePath}.tmp`
|
|
68
|
+
/** @type {Promise<unknown>} tail of the serialized write chain. */
|
|
69
|
+
let chain = Promise.resolve()
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read and parse the file synchronously. A missing file or invalid JSON
|
|
73
|
+
* yields `undefined`; invalid JSON is quarantined first.
|
|
74
|
+
* @returns {unknown} parsed JSON value or undefined.
|
|
75
|
+
*/
|
|
76
|
+
function read() {
|
|
77
|
+
let text
|
|
78
|
+
try {
|
|
79
|
+
text = readFileSync(filePath, 'utf8')
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (!error || error.code !== 'ENOENT') quarantine(filePath)
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
if (text.trim() === '') return undefined
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(text)
|
|
87
|
+
} catch {
|
|
88
|
+
quarantine(filePath)
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Write one already-serialized text body atomically.
|
|
95
|
+
*
|
|
96
|
+
* The temporary file is created with the final mode up front, so the
|
|
97
|
+
* credential-bearing body is never readable by other local users, not even
|
|
98
|
+
* for the instant between creation and an after-the-fact `chmod`.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} text - JSON text.
|
|
101
|
+
* @returns {Promise<void>} resolves after the rename.
|
|
102
|
+
*/
|
|
103
|
+
async function writeText(text) {
|
|
104
|
+
await mkdir(path.dirname(filePath), { recursive: true, mode: DIR_MODE })
|
|
105
|
+
const handle = await open(tmpPath, 'w', FILE_MODE)
|
|
106
|
+
try {
|
|
107
|
+
await handle.writeFile(text, 'utf8')
|
|
108
|
+
} finally {
|
|
109
|
+
await handle.close()
|
|
110
|
+
}
|
|
111
|
+
await rename(tmpPath, filePath)
|
|
112
|
+
await chmod(filePath, FILE_MODE).catch(() => {})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Enqueue one task behind every earlier write/update.
|
|
117
|
+
* @template T
|
|
118
|
+
* @param {() => Promise<T>} task - task to run.
|
|
119
|
+
* @returns {Promise<T>} task outcome.
|
|
120
|
+
*/
|
|
121
|
+
function enqueue(task) {
|
|
122
|
+
const run = chain.then(task, task)
|
|
123
|
+
chain = run.then(
|
|
124
|
+
() => undefined,
|
|
125
|
+
() => undefined,
|
|
126
|
+
)
|
|
127
|
+
return run
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Write a value atomically.
|
|
132
|
+
* @param {unknown} value - JSON-serializable value.
|
|
133
|
+
* @returns {Promise<void>} resolves after the rename.
|
|
134
|
+
*/
|
|
135
|
+
function write(value) {
|
|
136
|
+
const raw = JSON.stringify(value, null, 2)
|
|
137
|
+
if (typeof raw !== 'string') throw new TypeError('createJsonFile.write: value is not JSON-serializable')
|
|
138
|
+
return enqueue(() => writeText(`${raw}\n`))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Read the current value, await the mutation and write the result. Returning
|
|
143
|
+
* `undefined` from `mutate` skips the write.
|
|
144
|
+
* @param {(current: unknown) => unknown | Promise<unknown>} mutate - transformer.
|
|
145
|
+
* @returns {Promise<unknown>} the mutation result.
|
|
146
|
+
*/
|
|
147
|
+
function update(mutate) {
|
|
148
|
+
return enqueue(async () => {
|
|
149
|
+
const current = read()
|
|
150
|
+
const next = await mutate(current)
|
|
151
|
+
if (next !== undefined) {
|
|
152
|
+
const text = `${JSON.stringify(next, null, 2)}\n`
|
|
153
|
+
if (typeof text === 'string') await writeText(text)
|
|
154
|
+
}
|
|
155
|
+
return next
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { path: filePath, read, write, update }
|
|
160
|
+
}
|