@wangxiaocuo/dsh-session-trash 0.0.0-stage → 0.6.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/LICENSE +21 -0
- package/README.en.md +204 -0
- package/README.md +202 -2
- package/client.js +2231 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +10 -0
- package/index.js +129 -0
- package/lib/cleanup.js +342 -0
- package/lib/eligibility.js +310 -0
- package/lib/errors.js +91 -0
- package/lib/fsops.js +433 -0
- package/lib/http.js +351 -0
- package/lib/inventory.js +426 -0
- package/lib/members.js +95 -0
- package/lib/paths.js +39 -0
- package/lib/state.js +155 -0
- package/lib/store.js +606 -0
- package/lib/trash-service.js +1591 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
package/lib/http.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { TrashError, statusOf, toWireError } from './errors.js'
|
|
2
|
+
|
|
3
|
+
/** Prefix shared by every route this plugin owns. */
|
|
4
|
+
export const API_PREFIX = '/plugins/dsh-session-trash/api'
|
|
5
|
+
|
|
6
|
+
/** Request header that proves a same-origin scripted call. */
|
|
7
|
+
export const CSRF_HEADER = 'x-dsh-session-trash'
|
|
8
|
+
|
|
9
|
+
/** Largest accepted JSON request body. */
|
|
10
|
+
export const MAX_BODY_BYTES = 64 * 1024
|
|
11
|
+
|
|
12
|
+
/** Budget for one native reveal request. */
|
|
13
|
+
const REVEAL_TIMEOUT_MS = 10_000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @returns {AbortSignal} a signal that aborts the reveal after its budget.
|
|
17
|
+
*/
|
|
18
|
+
function revealSignal() {
|
|
19
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
20
|
+
return AbortSignal.timeout(REVEAL_TIMEOUT_MS)
|
|
21
|
+
}
|
|
22
|
+
const controller = new AbortController()
|
|
23
|
+
const handle = setTimeout(() => controller.abort(), REVEAL_TIMEOUT_MS)
|
|
24
|
+
handle.unref?.()
|
|
25
|
+
return controller.signal
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {import('node:http').IncomingMessage} req incoming request.
|
|
30
|
+
* @returns {Headers} a Fetch-shaped header bag for the connection service.
|
|
31
|
+
*/
|
|
32
|
+
function headerBag(req) {
|
|
33
|
+
const headers = new Headers()
|
|
34
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
35
|
+
if (value === undefined) continue
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
for (const item of value) headers.append(name, item)
|
|
38
|
+
} else {
|
|
39
|
+
headers.set(name, value)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return headers
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {import('node:http').ServerResponse} res response to complete.
|
|
47
|
+
* @param {number} status HTTP status.
|
|
48
|
+
* @param {unknown} body JSON-serializable body.
|
|
49
|
+
* @returns {void}
|
|
50
|
+
*/
|
|
51
|
+
function sendJson(res, status, body) {
|
|
52
|
+
if (res.headersSent || res.writableEnded) return
|
|
53
|
+
const payload = JSON.stringify(body)
|
|
54
|
+
res.writeHead(status, {
|
|
55
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
56
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
57
|
+
'Cache-Control': 'no-store',
|
|
58
|
+
'X-Content-Type-Options': 'nosniff',
|
|
59
|
+
})
|
|
60
|
+
res.end(payload)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {import('node:http').ServerResponse} res response to complete.
|
|
65
|
+
* @param {unknown} error thrown failure.
|
|
66
|
+
* @returns {void}
|
|
67
|
+
*/
|
|
68
|
+
function sendError(res, error) {
|
|
69
|
+
sendJson(res, statusOf(error), { ok: false, error: toWireError(error) })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {import('node:http').IncomingMessage} req request whose body to read.
|
|
74
|
+
* @param {number} limit maximum accepted bytes.
|
|
75
|
+
* @returns {Promise<Buffer>} the complete body.
|
|
76
|
+
*/
|
|
77
|
+
function readBody(req, limit) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const chunks = []
|
|
80
|
+
let size = 0
|
|
81
|
+
let failed = false
|
|
82
|
+
req.on('data', (chunk) => {
|
|
83
|
+
if (failed) return
|
|
84
|
+
size += chunk.length
|
|
85
|
+
if (size > limit) {
|
|
86
|
+
failed = true
|
|
87
|
+
chunks.length = 0
|
|
88
|
+
reject(new TrashError('INVALID_REQUEST', 'Request body is too large.'))
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
chunks.push(chunk)
|
|
92
|
+
})
|
|
93
|
+
req.on('end', () => {
|
|
94
|
+
if (!failed) resolve(Buffer.concat(chunks))
|
|
95
|
+
})
|
|
96
|
+
req.on('error', (error) => {
|
|
97
|
+
if (!failed) reject(error)
|
|
98
|
+
})
|
|
99
|
+
req.on('aborted', () => {
|
|
100
|
+
if (!failed) reject(new TrashError('INVALID_REQUEST', 'Request was aborted.'))
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {Buffer} buffer raw request body.
|
|
107
|
+
* @returns {Record<string, unknown>} the decoded JSON object.
|
|
108
|
+
*/
|
|
109
|
+
function parseJsonObject(buffer) {
|
|
110
|
+
if (buffer.length === 0) return {}
|
|
111
|
+
let parsed
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(buffer.toString('utf8'))
|
|
114
|
+
} catch {
|
|
115
|
+
throw new TrashError('INVALID_REQUEST', 'Request body is not valid JSON.')
|
|
116
|
+
}
|
|
117
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
118
|
+
throw new TrashError('INVALID_REQUEST', 'Request body must be a JSON object.')
|
|
119
|
+
}
|
|
120
|
+
return parsed
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {import('node:http').IncomingMessage} req request whose JSON body to read.
|
|
125
|
+
* @returns {Promise<Record<string, unknown>>} the validated JSON object.
|
|
126
|
+
*/
|
|
127
|
+
async function readJsonRequest(req) {
|
|
128
|
+
if (!String(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
|
|
129
|
+
throw new TrashError('INVALID_REQUEST', 'Content-Type must be application/json.')
|
|
130
|
+
}
|
|
131
|
+
return parseJsonObject(await readBody(req, MAX_BODY_BYTES))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {Record<string, unknown>} body decoded request body.
|
|
136
|
+
* @returns {string} a validated session identity.
|
|
137
|
+
*/
|
|
138
|
+
function requireSessionId(body) {
|
|
139
|
+
const sessionId = body.sessionId
|
|
140
|
+
if (typeof sessionId !== 'string' || sessionId === '' || sessionId.length > 256) {
|
|
141
|
+
throw new TrashError('INVALID_REQUEST', 'sessionId must be a non-empty string.')
|
|
142
|
+
}
|
|
143
|
+
return sessionId
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Register the plugin HTTP surface: state, menu preflight, trash/restore,
|
|
148
|
+
* permanent deletion (single and bulk), index maintenance, reveal, and
|
|
149
|
+
* cleanup.
|
|
150
|
+
*
|
|
151
|
+
* @param {{
|
|
152
|
+
* ctx: import('@deepseek-ai/cordis').Context,
|
|
153
|
+
* eligibility: { evaluate: Function, invalidate: Function },
|
|
154
|
+
* stateService: { snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> },
|
|
155
|
+
* trashService: {
|
|
156
|
+
* trash: (sessionId: string) => Promise<object>,
|
|
157
|
+
* restore: (sessionId: string, mode?: string) => Promise<object>,
|
|
158
|
+
* purge: (sessionId: string) => Promise<object>,
|
|
159
|
+
* purgeAll: () => Promise<object>,
|
|
160
|
+
* forget: (sessionId: string) => Promise<object>,
|
|
161
|
+
* reveal: (sessionId: string, signal?: AbortSignal) => Promise<object>,
|
|
162
|
+
* rebuild: () => Promise<object>,
|
|
163
|
+
* },
|
|
164
|
+
* cleanupService?: { runNow: (options?: object) => Promise<object>, maybeSweepOnPageOpen: () => void },
|
|
165
|
+
* ready: Promise<unknown>,
|
|
166
|
+
* config: object,
|
|
167
|
+
* logger: object,
|
|
168
|
+
* }} deps host services.
|
|
169
|
+
* @returns {void}
|
|
170
|
+
*/
|
|
171
|
+
export function registerApi({ ctx, eligibility, stateService, trashService, cleanupService, ready, config, logger }) {
|
|
172
|
+
/**
|
|
173
|
+
* Apply the shared trust, ownership, and CSRF checks.
|
|
174
|
+
*
|
|
175
|
+
* @param {import('node:http').IncomingMessage} req incoming request.
|
|
176
|
+
* @param {import('node:http').ServerResponse} res response to complete on refusal.
|
|
177
|
+
* @param {{ csrf: boolean }} options whether the scripted-call header is required.
|
|
178
|
+
* @returns {boolean} whether the request may proceed.
|
|
179
|
+
*/
|
|
180
|
+
function authorize(req, res, { csrf }) {
|
|
181
|
+
const headers = headerBag(req)
|
|
182
|
+
let rejection
|
|
183
|
+
try {
|
|
184
|
+
rejection = ctx.connection.requestRejection({ headers })
|
|
185
|
+
} catch (error) {
|
|
186
|
+
logger.warn(`connection rejection check failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
187
|
+
sendError(res, new TrashError('FORBIDDEN', 'The request could not be authenticated.'))
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
if (rejection !== undefined) {
|
|
191
|
+
sendJson(res, rejection, { ok: false, error: { code: 'FORBIDDEN', message: 'The request is not trusted.' } })
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
let admission
|
|
195
|
+
try {
|
|
196
|
+
admission = ctx.connection.admit({ headers })
|
|
197
|
+
} catch (error) {
|
|
198
|
+
logger.warn(`connection admission failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
199
|
+
sendError(res, new TrashError('FORBIDDEN', 'The request could not be admitted.'))
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
if (admission.rejection !== undefined) {
|
|
203
|
+
sendJson(res, admission.rejection, {
|
|
204
|
+
ok: false,
|
|
205
|
+
error: { code: 'FORBIDDEN', message: 'The request is not authenticated.' },
|
|
206
|
+
})
|
|
207
|
+
return false
|
|
208
|
+
}
|
|
209
|
+
if (admission.peer?.principal?.kind !== 'owner') {
|
|
210
|
+
sendJson(res, 403, { ok: false, error: { code: 'FORBIDDEN', message: 'Only the owner may use the trash.' } })
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
if (csrf && headers.get(CSRF_HEADER) !== '1') {
|
|
214
|
+
sendJson(res, 403, { ok: false, error: { code: 'FORBIDDEN', message: `Missing the ${CSRF_HEADER} header.` } })
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {string} pathname route path below the API prefix.
|
|
222
|
+
* @param {string} method allowed HTTP method.
|
|
223
|
+
* @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>} handler route body.
|
|
224
|
+
* @returns {void}
|
|
225
|
+
*/
|
|
226
|
+
function route(pathname, method, handler) {
|
|
227
|
+
const path = `${API_PREFIX}/${pathname}`
|
|
228
|
+
ctx.effect(
|
|
229
|
+
() =>
|
|
230
|
+
ctx.webServer.register({
|
|
231
|
+
kind: 'exact',
|
|
232
|
+
path,
|
|
233
|
+
async handler(req, res) {
|
|
234
|
+
const startedAt = Date.now()
|
|
235
|
+
try {
|
|
236
|
+
if (req.method !== method) {
|
|
237
|
+
res.setHeader('Allow', method)
|
|
238
|
+
sendJson(res, 405, {
|
|
239
|
+
ok: false,
|
|
240
|
+
error: { code: 'METHOD_NOT_ALLOWED', message: `Use ${method} for this endpoint.` },
|
|
241
|
+
})
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
if (!authorize(req, res, { csrf: method !== 'GET' })) return
|
|
245
|
+
await ready
|
|
246
|
+
await handler(req, res)
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error instanceof TrashError) {
|
|
249
|
+
logger.debug(`${method} ${path} -> ${error.code} (${Date.now() - startedAt}ms)`)
|
|
250
|
+
} else {
|
|
251
|
+
logger.error(`${method} ${path} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`)
|
|
252
|
+
}
|
|
253
|
+
sendError(res, error)
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
`session-trash: route ${method} ${path}`,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
route('state', 'GET', async (_req, res) => {
|
|
262
|
+
sendJson(res, 200, await stateService.snapshot())
|
|
263
|
+
try {
|
|
264
|
+
cleanupService?.maybeSweepOnPageOpen()
|
|
265
|
+
} catch (error) {
|
|
266
|
+
logger.warn(`cannot schedule the page-open trash sweep: ${error instanceof Error ? error.message : String(error)}`)
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
route('preflight', 'POST', async (req, res) => {
|
|
271
|
+
const body = await readJsonRequest(req)
|
|
272
|
+
const sessionId = requireSessionId(body)
|
|
273
|
+
const verdict = await eligibility.evaluate(sessionId)
|
|
274
|
+
if (config.debug) {
|
|
275
|
+
logger.debug(`preflight ${sessionId} -> ${verdict.eligible ? 'eligible' : verdict.code}`)
|
|
276
|
+
}
|
|
277
|
+
if (verdict.eligible === true) {
|
|
278
|
+
const members = Array.isArray(verdict.members) ? verdict.members : []
|
|
279
|
+
sendJson(res, 200, { eligible: true, descendants: Math.max(0, members.length - 1) })
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
sendJson(res, 200, verdict)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
route('trash', 'POST', async (req, res) => {
|
|
286
|
+
const body = await readJsonRequest(req)
|
|
287
|
+
const sessionId = requireSessionId(body)
|
|
288
|
+
const entry = await trashService.trash(sessionId)
|
|
289
|
+
sendJson(res, 200, { ok: true, entry: await stateService.describe(entry) })
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
route('restore', 'POST', async (req, res) => {
|
|
293
|
+
const body = await readJsonRequest(req)
|
|
294
|
+
const sessionId = requireSessionId(body)
|
|
295
|
+
const mode = body.mode
|
|
296
|
+
if (mode !== undefined && mode !== 'normal' && mode !== 'orphan') {
|
|
297
|
+
throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
|
|
298
|
+
}
|
|
299
|
+
const session = await trashService.restore(sessionId, mode ?? 'normal')
|
|
300
|
+
sendJson(res, 200, { ok: true, session })
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
route('purge', 'POST', async (req, res) => {
|
|
304
|
+
const body = await readJsonRequest(req)
|
|
305
|
+
const sessionId = requireSessionId(body)
|
|
306
|
+
const result = await trashService.purge(sessionId)
|
|
307
|
+
sendJson(res, 200, { ok: true, sessionId: result.sessionId })
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
route('purge-all', 'POST', async (_req, res) => {
|
|
311
|
+
const result = await trashService.purgeAll()
|
|
312
|
+
sendJson(res, 200, {
|
|
313
|
+
ok: true,
|
|
314
|
+
purged: result.purged ?? 0,
|
|
315
|
+
forgotten: result.forgotten ?? 0,
|
|
316
|
+
failed: result.failed ?? 0,
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
route('forget', 'POST', async (req, res) => {
|
|
321
|
+
const body = await readJsonRequest(req)
|
|
322
|
+
const sessionId = requireSessionId(body)
|
|
323
|
+
const result = await trashService.forget(sessionId)
|
|
324
|
+
sendJson(res, 200, { ok: true, sessionId: result.sessionId })
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
route('reveal', 'POST', async (req, res) => {
|
|
328
|
+
const body = await readJsonRequest(req)
|
|
329
|
+
const sessionId = requireSessionId(body)
|
|
330
|
+
await trashService.reveal(sessionId, revealSignal())
|
|
331
|
+
sendJson(res, 200, { ok: true, opened: true })
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
route('cleanup', 'POST', async (_req, res) => {
|
|
335
|
+
const result = await cleanupService.runNow({ manual: true, reason: 'manual' })
|
|
336
|
+
const diagnostics =
|
|
337
|
+
typeof cleanupService.diagnostics === 'function' ? cleanupService.diagnostics() : undefined
|
|
338
|
+
sendJson(res, 200, {
|
|
339
|
+
ok: true,
|
|
340
|
+
purged: result.purged ?? 0,
|
|
341
|
+
failed: result.failed ?? 0,
|
|
342
|
+
settled: result.changed ?? 0,
|
|
343
|
+
...(diagnostics === undefined ? {} : { cleanup: diagnostics }),
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
route('rebuild', 'POST', async (_req, res) => {
|
|
348
|
+
const result = await trashService.rebuild()
|
|
349
|
+
sendJson(res, 200, { ok: true, entries: result.entries, unindexed: result.unindexed })
|
|
350
|
+
})
|
|
351
|
+
}
|