@zhin.js/host-http 1.1.2 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/lib/console-rest-pages.js +40 -10
- package/lib/console-rpc-extended/dispatcher.js +3 -1
- package/lib/console-rpc-extended/schedule-rpc.d.ts +2 -0
- package/lib/console-rpc-extended/schedule-rpc.js +20 -3
- package/lib/console-rpc.js +2 -0
- package/lib/http-host.js +52 -32
- package/lib/project-files.js +61 -16
- package/package.json +4 -3
- package/src/console-rest-pages.ts +41 -18
- package/src/console-rpc-extended/dispatcher.ts +4 -1
- package/src/console-rpc-extended/schedule-rpc.ts +19 -3
- package/src/console-rpc.ts +2 -0
- package/src/http-host.ts +49 -31
- package/src/project-files.ts +48 -16
package/README.md
CHANGED
|
@@ -12,6 +12,10 @@ only the `HttpHost` routing port: they may register HTTP/WS routes but cannot li
|
|
|
12
12
|
shared server. Registrations are bound to generation admission, so candidate routes stay invisible
|
|
13
13
|
until snapshot commit and retired routes stop matching immediately while their leases drain.
|
|
14
14
|
|
|
15
|
+
`ProcessHttpHost.close()` is terminal and idempotent. It waits for a pending
|
|
16
|
+
`listen()` operation to settle and releases its TCP listener. Concurrent `listen()`
|
|
17
|
+
calls share one operation; calling `listen()` after close rejects.
|
|
18
|
+
|
|
15
19
|
## Capabilities (this slice)
|
|
16
20
|
|
|
17
21
|
- Path-scoped WebSocket upgrades (`ws(path)`)
|
|
@@ -59,3 +63,10 @@ Extended Console RPC uses `src/console-rpc-extended/index.ts` as its module boun
|
|
|
59
63
|
only maps protocol method names to schedule, inbox, login, Endpoint-management, and Workroom control
|
|
60
64
|
handlers. Shared request parsing and generation-leased Endpoint execution live behind internal helper
|
|
61
65
|
ports, so adding one RPC domain does not enlarge the central dispatcher or couple unrelated domains.
|
|
66
|
+
|
|
67
|
+
Console project-file operations resolve the project root and existing filesystem targets before
|
|
68
|
+
access. Internal symlinks remain usable only when their resolved target is inside an allowed project
|
|
69
|
+
path; links outside the root, blocked path segments, and dangling links are denied. New files are
|
|
70
|
+
checked through their nearest existing parent and checked again after parent creation. File-tree
|
|
71
|
+
and environment-file inventory use the same boundary. This covers existing symlink layouts; it does
|
|
72
|
+
not provide an OS sandbox against a local process concurrently replacing directories during access.
|
|
@@ -54,28 +54,58 @@ function registerLogsRoutes(route, base, ctx) {
|
|
|
54
54
|
};
|
|
55
55
|
const unavailableNote = 'SystemLog 模型不可用(Database 未启动或未注册 DatabaseLogTransport)';
|
|
56
56
|
route('GET', `${base}/logs`, async (_request, response, url) => {
|
|
57
|
-
const
|
|
58
|
-
const
|
|
57
|
+
const pageSize = clampPositiveInt(parsePositiveInt(url.searchParams.get('pageSize') ?? url.searchParams.get('limit'), 100), 100, 1000);
|
|
58
|
+
const requestedPage = clampPositiveInt(parsePositiveInt(url.searchParams.get('page'), 1), 1, Number.MAX_SAFE_INTEGER);
|
|
59
|
+
const level = url.searchParams.get('level');
|
|
60
|
+
const source = url.searchParams.get('source')?.trim();
|
|
61
|
+
const query = url.searchParams.get('q')?.trim();
|
|
62
|
+
const baseWhere = {
|
|
63
|
+
...(level && level !== 'all' ? { level } : {}),
|
|
64
|
+
};
|
|
65
|
+
const where = { ...baseWhere, ...(source ? { source } : {}) };
|
|
59
66
|
const LogModel = getModel();
|
|
60
67
|
if (!LogModel) {
|
|
61
|
-
writeJson(response, 200, { success: true, data: [], total: 0, note: unavailableNote });
|
|
68
|
+
writeJson(response, 200, { success: true, data: [], total: 0, page: 1, pageSize, totalPages: 1, sources: [], note: unavailableNote });
|
|
62
69
|
return;
|
|
63
70
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
// Database $like has no portable ESCAPE contract. Keep q literal (including
|
|
72
|
+
// %/_ and Unicode) and filter actual retained rows on the Host, not wildcard SQL.
|
|
73
|
+
const matchesQuery = (row) => !query || ['message', 'name', 'source']
|
|
74
|
+
.some(field => String(row[field] ?? '').toLowerCase().includes(query.toLowerCase()));
|
|
75
|
+
const selection = LogModel.select().where(where).orderBy('timestamp', 'DESC').orderBy('id', 'DESC');
|
|
76
|
+
const queriedRows = query ? (await selection).filter(matchesQuery) : undefined;
|
|
77
|
+
const total = queriedRows?.length ?? (typeof LogModel.count === 'function'
|
|
78
|
+
? await LogModel.count(where)
|
|
79
|
+
: (await LogModel.select('id').where(where)).length);
|
|
80
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
81
|
+
const page = Math.min(requestedPage, totalPages);
|
|
82
|
+
const offset = (page - 1) * pageSize;
|
|
83
|
+
const logs = queriedRows
|
|
84
|
+
? queriedRows.slice(offset, offset + pageSize)
|
|
85
|
+
: selection.offset
|
|
86
|
+
? await selection.offset(offset).limit(pageSize)
|
|
87
|
+
: (await selection).slice(offset, offset + pageSize);
|
|
88
|
+
// Source counts match level/q, but exclude the selected source and page window.
|
|
89
|
+
const sourceRows = await LogModel.select('source', 'name', 'message').where(baseWhere);
|
|
90
|
+
const counts = new Map();
|
|
91
|
+
for (const row of sourceRows) {
|
|
92
|
+
const name = row.source;
|
|
93
|
+
if (!name || !matchesQuery(row))
|
|
94
|
+
continue;
|
|
95
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
67
96
|
}
|
|
68
|
-
const logs = await selection.orderBy('timestamp', 'DESC').limit(limit);
|
|
69
97
|
writeJson(response, 200, {
|
|
70
98
|
success: true,
|
|
71
99
|
data: logs.map((log) => ({
|
|
100
|
+
id: log.id,
|
|
72
101
|
level: log.level,
|
|
73
102
|
name: log.name,
|
|
74
103
|
message: log.message,
|
|
75
104
|
source: log.source,
|
|
76
105
|
timestamp: log.timestamp instanceof Date ? log.timestamp.toISOString() : log.timestamp,
|
|
77
106
|
})),
|
|
78
|
-
total
|
|
107
|
+
total, page, pageSize, totalPages,
|
|
108
|
+
sources: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([source, count]) => ({ source, count })),
|
|
79
109
|
});
|
|
80
110
|
}, { summary: 'List system logs', tags: ['console', 'logs'] });
|
|
81
111
|
route('DELETE', `${base}/logs`, async (_request, response, _url, authScope) => {
|
|
@@ -195,8 +225,8 @@ function registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl,
|
|
|
195
225
|
version: p.version || '',
|
|
196
226
|
description: p.description || '',
|
|
197
227
|
author: p.author || '',
|
|
198
|
-
isOfficial:
|
|
199
|
-
official:
|
|
228
|
+
isOfficial: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
|
|
229
|
+
official: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
|
|
200
230
|
category: p.category || 'util',
|
|
201
231
|
keywords: p.tags || [],
|
|
202
232
|
npm: p.npm || `https://www.npmjs.com/package/${p.name}`,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assertDemoConsoleRpcAllowed } from '@zhin.js/console-protocol';
|
|
2
|
-
import { addCron, listSchedule, mutateCron } from './schedule-rpc.js';
|
|
2
|
+
import { addCron, listSchedule, mutateCron, validateCron } from './schedule-rpc.js';
|
|
3
3
|
import { actOnRequest, listInbox, listInboxMessages, listPendingRequests, listRecentInboxMessages, mapNoticeRow, mapRequestRow, markInboxConsumed, TABLE_NOTICE, TABLE_REQUEST, } from './inbox-rpc.js';
|
|
4
4
|
import { cancelLogin, listLoginPending, submitLogin } from './login-rpc.js';
|
|
5
5
|
import { deleteFriend, groupWriteOp, listChannels, listFriends, listGroupMembers, listGroups, } from './endpoint-rpc.js';
|
|
@@ -15,6 +15,8 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
|
|
|
15
15
|
case 'schedule:list':
|
|
16
16
|
case 'cron:list':
|
|
17
17
|
return listSchedule(ctx);
|
|
18
|
+
case 'cron:validate':
|
|
19
|
+
return validateCron(d);
|
|
18
20
|
case 'cron:add':
|
|
19
21
|
return addCron(d, ctx);
|
|
20
22
|
case 'cron:remove':
|
|
@@ -3,4 +3,6 @@ export declare function listSchedule(ctx: ConsoleScheduleListCtx): Promise<Exten
|
|
|
3
3
|
type ConsoleScheduleListCtx = ConsoleRpcExtendedCtx;
|
|
4
4
|
export declare function addCron(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;
|
|
5
5
|
export declare function mutateCron(type: 'cron:remove' | 'cron:pause' | 'cron:resume', d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;
|
|
6
|
+
/** Uses the same parser as the persistent schedule engine; never creates a job. */
|
|
7
|
+
export declare function validateCron(d: Record<string, unknown>): ExtendedRpcResult;
|
|
6
8
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseCron } from '@zhin.js/schedule';
|
|
1
2
|
import { errorMessage } from './rpc-values.js';
|
|
2
3
|
const CRON_NOT_WIRED = '持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
|
|
3
4
|
'不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
|
|
@@ -23,7 +24,7 @@ export function listSchedule(ctx) {
|
|
|
23
24
|
...(job.nextExecution
|
|
24
25
|
? { nextExecution: job.nextExecution } : {}),
|
|
25
26
|
}));
|
|
26
|
-
return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent } }));
|
|
27
|
+
return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent, capabilities: { persistent: !!ctx.resolveScheduleEngine?.() && ctx.fullScope } } })).catch((error) => ({ error: errorMessage(error) }));
|
|
27
28
|
}
|
|
28
29
|
catch (error) {
|
|
29
30
|
return { error: errorMessage(error) };
|
|
@@ -51,8 +52,8 @@ async function listPersistentJobs(ctx) {
|
|
|
51
52
|
lastStatus: job.state?.lastStatus,
|
|
52
53
|
}));
|
|
53
54
|
}
|
|
54
|
-
catch {
|
|
55
|
-
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw new Error(`读取持久化任务失败: ${errorMessage(error)}`);
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
export async function addCron(d, ctx) {
|
|
@@ -65,6 +66,12 @@ export async function addCron(d, ctx) {
|
|
|
65
66
|
return { error: 'cronExpression is required' };
|
|
66
67
|
if (!prompt)
|
|
67
68
|
return { error: 'prompt is required' };
|
|
69
|
+
try {
|
|
70
|
+
parseCron(cronExpression);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return { error: `Cron 表达式无效: ${errorMessage(error)}` };
|
|
74
|
+
}
|
|
68
75
|
const label = typeof d.label === 'string' && d.label.trim() ? d.label.trim() : undefined;
|
|
69
76
|
const context = (d.context ?? {});
|
|
70
77
|
const target = context.target ?? context.channel;
|
|
@@ -107,3 +114,13 @@ export async function mutateCron(type, d, ctx) {
|
|
|
107
114
|
}
|
|
108
115
|
}
|
|
109
116
|
// ---------------------------------------------------------------- inbox
|
|
117
|
+
/** Uses the same parser as the persistent schedule engine; never creates a job. */
|
|
118
|
+
export function validateCron(d) {
|
|
119
|
+
try {
|
|
120
|
+
parseCron(String(d.cronExpression ?? '').trim());
|
|
121
|
+
return { data: { valid: true } };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return { data: { valid: false, error: errorMessage(error) } };
|
|
125
|
+
}
|
|
126
|
+
}
|
package/lib/console-rpc.js
CHANGED
|
@@ -652,6 +652,8 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
652
652
|
reachable: endpoint.connected && endpoint.status === 'online',
|
|
653
653
|
connected: endpoint.connected,
|
|
654
654
|
status: endpoint.status,
|
|
655
|
+
admitted: endpoint.admitted,
|
|
656
|
+
transportState: endpoint.transportState,
|
|
655
657
|
phase: endpoint.phase ?? (endpoint.connected ? 'online' : 'pending'),
|
|
656
658
|
pendingLogin: endpoint.pendingLogin ?? false,
|
|
657
659
|
latencyMs: Date.now() - startedAt,
|
package/lib/http-host.js
CHANGED
|
@@ -30,6 +30,7 @@ export function createHttpHost(options = {}) {
|
|
|
30
30
|
let address;
|
|
31
31
|
let closed = false;
|
|
32
32
|
let closeResult;
|
|
33
|
+
let listenResult;
|
|
33
34
|
const secure = options.tls !== undefined;
|
|
34
35
|
const handleRequest = (request, response) => {
|
|
35
36
|
void dispatchHttp(request, response);
|
|
@@ -205,7 +206,10 @@ export function createHttpHost(options = {}) {
|
|
|
205
206
|
if (!auth.ok) {
|
|
206
207
|
if (corsOk && origin)
|
|
207
208
|
applyCors(response, origin, corsOrigins);
|
|
208
|
-
writeJson(response,
|
|
209
|
+
writeJson(response, auth.forbidden ? 403 : 401, {
|
|
210
|
+
success: false,
|
|
211
|
+
error: auth.forbidden ? 'Token scope does not allow this route' : 'Invalid or missing token',
|
|
212
|
+
});
|
|
209
213
|
return;
|
|
210
214
|
}
|
|
211
215
|
const route = matchHttpRoute(httpRoutes, method, pathname);
|
|
@@ -280,7 +284,7 @@ export function createHttpHost(options = {}) {
|
|
|
280
284
|
if (scope === 'demo') {
|
|
281
285
|
const method = (request.method ?? 'GET').toUpperCase();
|
|
282
286
|
if (!isDemoHttpAllowed(method, pathname, apiBase))
|
|
283
|
-
return { ok: false };
|
|
287
|
+
return { ok: false, forbidden: true };
|
|
284
288
|
}
|
|
285
289
|
const principal = tokenRegistry.resolvePrincipal(token);
|
|
286
290
|
return { ok: true, scope, ...(principal ? { principal } : {}) };
|
|
@@ -365,12 +369,24 @@ export function createHttpHost(options = {}) {
|
|
|
365
369
|
return tokenRegistry;
|
|
366
370
|
},
|
|
367
371
|
listen() {
|
|
372
|
+
if (closed)
|
|
373
|
+
return Promise.reject(new Error('HTTP Host is closed'));
|
|
374
|
+
if (listenResult)
|
|
375
|
+
return listenResult;
|
|
368
376
|
if (address)
|
|
369
377
|
return Promise.resolve(address);
|
|
370
|
-
|
|
371
|
-
|
|
378
|
+
listenResult = new Promise((resolve, reject) => {
|
|
379
|
+
const onError = (error) => {
|
|
380
|
+
listenResult = undefined;
|
|
381
|
+
reject(error);
|
|
382
|
+
};
|
|
383
|
+
server.once('error', onError);
|
|
372
384
|
server.listen({ host, port }, () => {
|
|
373
|
-
server.off('error',
|
|
385
|
+
server.off('error', onError);
|
|
386
|
+
if (closed) {
|
|
387
|
+
reject(new Error('HTTP Host closed before listen completed'));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
374
390
|
const bound = server.address();
|
|
375
391
|
const listenPort = typeof bound === 'object' && bound ? bound.port : port;
|
|
376
392
|
const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
@@ -390,6 +406,7 @@ export function createHttpHost(options = {}) {
|
|
|
390
406
|
resolve(address);
|
|
391
407
|
});
|
|
392
408
|
});
|
|
409
|
+
return listenResult;
|
|
393
410
|
},
|
|
394
411
|
close() {
|
|
395
412
|
if (closeResult)
|
|
@@ -397,37 +414,40 @@ export function createHttpHost(options = {}) {
|
|
|
397
414
|
closed = true;
|
|
398
415
|
wsRoutes.clear();
|
|
399
416
|
httpRoutes.length = 0;
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
server.close((serverError) => {
|
|
421
|
-
if (serverError) {
|
|
422
|
-
reject(serverError);
|
|
417
|
+
closeResult = (async () => {
|
|
418
|
+
// A pending listen must settle before deciding whether there is a
|
|
419
|
+
// listener to close. Otherwise it can bind after close has resolved.
|
|
420
|
+
await listenResult?.catch(() => undefined);
|
|
421
|
+
if (!server.listening) {
|
|
422
|
+
address = undefined;
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
await new Promise((resolve, reject) => {
|
|
426
|
+
// Long-lived connections (SSE, keep-alive, WS) never end on their own:
|
|
427
|
+
// destroy tracked sockets and terminate WS clients before close(),
|
|
428
|
+
// otherwise the close callbacks would never fire.
|
|
429
|
+
for (const socket of sockets)
|
|
430
|
+
socket.destroy();
|
|
431
|
+
sockets.clear();
|
|
432
|
+
for (const client of wss.clients)
|
|
433
|
+
client.terminate();
|
|
434
|
+
wss.close((wssError) => {
|
|
435
|
+
if (wssError) {
|
|
436
|
+
reject(wssError);
|
|
423
437
|
return;
|
|
424
438
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
439
|
+
server.close((serverError) => {
|
|
440
|
+
if (serverError) {
|
|
441
|
+
reject(serverError);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
logger.debug(formatCompact({ op: 'http_host_closed' }));
|
|
445
|
+
address = undefined;
|
|
446
|
+
resolve();
|
|
447
|
+
});
|
|
428
448
|
});
|
|
429
449
|
});
|
|
430
|
-
});
|
|
450
|
+
})();
|
|
431
451
|
return closeResult;
|
|
432
452
|
},
|
|
433
453
|
};
|
package/lib/project-files.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { access, lstat, mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
/** Align with Remote Console file manager (paths relative to project root). */
|
|
4
4
|
export const FILE_MANAGER_ALLOWED = Object.freeze([
|
|
@@ -39,8 +39,7 @@ export function isProjectPathAllowed(relativePath) {
|
|
|
39
39
|
if (relativePath.includes('..') || path.isAbsolute(relativePath))
|
|
40
40
|
return false;
|
|
41
41
|
const normalized = relativePath.replace(/\\/gu, '/').replace(/^\.\//u, '');
|
|
42
|
-
|
|
43
|
-
if (FILE_MANAGER_BLOCKED.has(firstSegment))
|
|
42
|
+
if (normalized.split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment)))
|
|
44
43
|
return false;
|
|
45
44
|
return FILE_MANAGER_ALLOWED.some((allowed) => normalized === allowed || normalized.startsWith(`${allowed}/`));
|
|
46
45
|
}
|
|
@@ -49,7 +48,9 @@ export async function buildProjectFileTree(projectRoot) {
|
|
|
49
48
|
for (const entry of FILE_MANAGER_ALLOWED) {
|
|
50
49
|
if (entry.includes('/'))
|
|
51
50
|
continue;
|
|
52
|
-
const absPath =
|
|
51
|
+
const absPath = await resolveAllowedPath(projectRoot, entry);
|
|
52
|
+
if (!absPath)
|
|
53
|
+
continue;
|
|
53
54
|
if (!(await pathExists(absPath)))
|
|
54
55
|
continue;
|
|
55
56
|
const info = await safeStat(absPath);
|
|
@@ -70,7 +71,7 @@ export async function buildProjectFileTree(projectRoot) {
|
|
|
70
71
|
return tree.sort(compareTreeNodes);
|
|
71
72
|
}
|
|
72
73
|
export async function readProjectFile(projectRoot, relativePath) {
|
|
73
|
-
const absPath = resolveAllowedPath(projectRoot, relativePath);
|
|
74
|
+
const absPath = await resolveAllowedPath(projectRoot, relativePath);
|
|
74
75
|
if (!absPath)
|
|
75
76
|
throw new Error(`Access denied: ${relativePath}`);
|
|
76
77
|
if (!(await pathExists(absPath)))
|
|
@@ -85,29 +86,69 @@ export async function readProjectFile(projectRoot, relativePath) {
|
|
|
85
86
|
return { content, size: info.size };
|
|
86
87
|
}
|
|
87
88
|
export async function saveProjectFile(projectRoot, relativePath, content) {
|
|
88
|
-
const absPath = resolveAllowedPath(projectRoot, relativePath);
|
|
89
|
+
const absPath = await resolveAllowedPath(projectRoot, relativePath);
|
|
89
90
|
if (!absPath)
|
|
90
91
|
throw new Error(`Access denied: ${relativePath}`);
|
|
91
92
|
await mkdir(path.dirname(absPath), { recursive: true });
|
|
92
|
-
await
|
|
93
|
+
const checkedPath = await resolveAllowedPath(projectRoot, relativePath);
|
|
94
|
+
if (!checkedPath)
|
|
95
|
+
throw new Error(`Access denied: ${relativePath}`);
|
|
96
|
+
await writeFile(checkedPath, content, 'utf8');
|
|
93
97
|
}
|
|
94
98
|
export async function listEnvFiles(projectRoot) {
|
|
95
99
|
const names = ['.env', '.env.development', '.env.production'];
|
|
96
|
-
const result = await Promise.all(names.map(async (name) =>
|
|
97
|
-
name
|
|
98
|
-
exists: await pathExists(
|
|
99
|
-
}))
|
|
100
|
+
const result = await Promise.all(names.map(async (name) => {
|
|
101
|
+
const resolved = await resolveAllowedPath(projectRoot, name);
|
|
102
|
+
return { name, exists: resolved !== null && await pathExists(resolved) };
|
|
103
|
+
}));
|
|
100
104
|
return Object.freeze(result);
|
|
101
105
|
}
|
|
102
|
-
|
|
106
|
+
/** Resolve existing links, or the nearest existing parent for a new file. */
|
|
107
|
+
async function resolveAllowedPath(projectRoot, relativePath) {
|
|
103
108
|
if (!isProjectPathAllowed(relativePath))
|
|
104
109
|
return null;
|
|
105
|
-
|
|
110
|
+
try {
|
|
111
|
+
const root = await realpath(projectRoot);
|
|
112
|
+
let candidate = path.resolve(root, relativePath);
|
|
113
|
+
const missing = [];
|
|
114
|
+
for (;;) {
|
|
115
|
+
try {
|
|
116
|
+
// A dangling link must fail closed rather than be treated as a missing directory.
|
|
117
|
+
await lstat(candidate);
|
|
118
|
+
const resolved = path.resolve(await realpath(candidate), ...missing);
|
|
119
|
+
const relative = path.relative(root, resolved);
|
|
120
|
+
return isProjectPathAllowed(relative) ? resolved : null;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error.code !== 'ENOENT')
|
|
124
|
+
return null;
|
|
125
|
+
// lstat succeeds for dangling symlinks even though realpath fails.
|
|
126
|
+
try {
|
|
127
|
+
await lstat(candidate);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
catch (missingError) {
|
|
131
|
+
if (missingError.code !== 'ENOENT')
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const parent = path.dirname(candidate);
|
|
135
|
+
if (candidate === root || parent === candidate)
|
|
136
|
+
return null;
|
|
137
|
+
missing.unshift(path.basename(candidate));
|
|
138
|
+
candidate = parent;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
106
145
|
}
|
|
107
146
|
async function buildDirectoryTree(projectRoot, relativePath, maxDepth) {
|
|
108
147
|
if (maxDepth <= 0)
|
|
109
148
|
return [];
|
|
110
|
-
const absDir =
|
|
149
|
+
const absDir = await resolveAllowedPath(projectRoot, relativePath);
|
|
150
|
+
if (!absDir)
|
|
151
|
+
return [];
|
|
111
152
|
const info = await safeStat(absDir);
|
|
112
153
|
if (!info?.isDirectory())
|
|
113
154
|
return [];
|
|
@@ -117,7 +158,11 @@ async function buildDirectoryTree(projectRoot, relativePath, maxDepth) {
|
|
|
117
158
|
if (FILE_MANAGER_BLOCKED.has(entry.name) || entry.name.startsWith('.'))
|
|
118
159
|
continue;
|
|
119
160
|
const childRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
120
|
-
|
|
161
|
+
const childPath = await resolveAllowedPath(projectRoot, childRelative);
|
|
162
|
+
if (!childPath)
|
|
163
|
+
continue;
|
|
164
|
+
const childInfo = await safeStat(childPath);
|
|
165
|
+
if (childInfo?.isDirectory()) {
|
|
121
166
|
result.push({
|
|
122
167
|
name: entry.name,
|
|
123
168
|
path: childRelative,
|
|
@@ -125,7 +170,7 @@ async function buildDirectoryTree(projectRoot, relativePath, maxDepth) {
|
|
|
125
170
|
children: await buildDirectoryTree(projectRoot, childRelative, maxDepth - 1),
|
|
126
171
|
});
|
|
127
172
|
}
|
|
128
|
-
else if (
|
|
173
|
+
else if (childInfo?.isFile()) {
|
|
129
174
|
result.push({ name: entry.name, path: childRelative, type: 'file' });
|
|
130
175
|
}
|
|
131
176
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/host-http",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"ws": "^8.21.1",
|
|
21
|
-
"@zhin.js/console-protocol": "1.1.
|
|
21
|
+
"@zhin.js/console-protocol": "1.1.7",
|
|
22
22
|
"@zhin.js/host-http-contract": "1.1.0",
|
|
23
23
|
"@zhin.js/logger": "1.1.1",
|
|
24
|
-
"@zhin.js/
|
|
24
|
+
"@zhin.js/schedule": "1.1.2",
|
|
25
|
+
"@zhin.js/plugin-runtime": "1.1.12"
|
|
25
26
|
},
|
|
26
27
|
"devDependencies": {
|
|
27
28
|
"@types/node": "^26.1.2",
|
|
@@ -108,6 +108,7 @@ type LogSelectionLike = PromiseLike<LogRow[]> & {
|
|
|
108
108
|
where(where: Record<string, unknown>): LogSelectionLike;
|
|
109
109
|
orderBy(field: string, direction: 'ASC' | 'DESC'): LogSelectionLike;
|
|
110
110
|
limit(count: number): Promise<LogRow[]>;
|
|
111
|
+
offset?(count: number): LogSelectionLike;
|
|
111
112
|
};
|
|
112
113
|
|
|
113
114
|
type LogRow = {
|
|
@@ -187,35 +188,57 @@ function registerLogsRoutes(
|
|
|
187
188
|
const unavailableNote = 'SystemLog 模型不可用(Database 未启动或未注册 DatabaseLogTransport)';
|
|
188
189
|
|
|
189
190
|
route('GET', `${base}/logs`, async (_request, response, url) => {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
);
|
|
194
|
-
const
|
|
191
|
+
const pageSize = clampPositiveInt(parsePositiveInt(url.searchParams.get('pageSize') ?? url.searchParams.get('limit'), 100), 100, 1000);
|
|
192
|
+
const requestedPage = clampPositiveInt(parsePositiveInt(url.searchParams.get('page'), 1), 1, Number.MAX_SAFE_INTEGER);
|
|
193
|
+
const level = url.searchParams.get('level');
|
|
194
|
+
const source = url.searchParams.get('source')?.trim();
|
|
195
|
+
const query = url.searchParams.get('q')?.trim();
|
|
196
|
+
const baseWhere: Record<string, unknown> = {
|
|
197
|
+
...(level && level !== 'all' ? { level } : {}),
|
|
198
|
+
};
|
|
199
|
+
const where = { ...baseWhere, ...(source ? { source } : {}) };
|
|
195
200
|
const LogModel = getModel();
|
|
196
201
|
if (!LogModel) {
|
|
197
|
-
writeJson(response, 200, { success: true, data: [], total: 0, note: unavailableNote });
|
|
202
|
+
writeJson(response, 200, { success: true, data: [], total: 0, page: 1, pageSize, totalPages: 1, sources: [], note: unavailableNote });
|
|
198
203
|
return;
|
|
199
204
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
205
|
+
// Database $like has no portable ESCAPE contract. Keep q literal (including
|
|
206
|
+
// %/_ and Unicode) and filter actual retained rows on the Host, not wildcard SQL.
|
|
207
|
+
const matchesQuery = (row: LogRow): boolean => !query || ['message', 'name', 'source']
|
|
208
|
+
.some(field => String(row[field as keyof LogRow] ?? '').toLowerCase().includes(query.toLowerCase()));
|
|
209
|
+
const selection = LogModel.select().where(where).orderBy('timestamp', 'DESC').orderBy('id', 'DESC');
|
|
210
|
+
const queriedRows = query ? (await selection).filter(matchesQuery) : undefined;
|
|
211
|
+
const total = queriedRows?.length ?? (typeof LogModel.count === 'function'
|
|
212
|
+
? await LogModel.count(where)
|
|
213
|
+
: (await LogModel.select('id').where(where)).length);
|
|
214
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
215
|
+
const page = Math.min(requestedPage, totalPages);
|
|
216
|
+
const offset = (page - 1) * pageSize;
|
|
217
|
+
const logs = queriedRows
|
|
218
|
+
? queriedRows.slice(offset, offset + pageSize)
|
|
219
|
+
: selection.offset
|
|
220
|
+
? await selection.offset(offset).limit(pageSize)
|
|
221
|
+
: (await selection).slice(offset, offset + pageSize);
|
|
222
|
+
// Source counts match level/q, but exclude the selected source and page window.
|
|
223
|
+
const sourceRows = await LogModel.select('source', 'name', 'message').where(baseWhere);
|
|
224
|
+
const counts = new Map<string, number>();
|
|
225
|
+
for (const row of sourceRows) {
|
|
226
|
+
const name = row.source;
|
|
227
|
+
if (!name || !matchesQuery(row)) continue;
|
|
228
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
204
229
|
}
|
|
205
|
-
|
|
206
|
-
const logs = await selection.orderBy('timestamp', 'DESC').limit(limit);
|
|
207
|
-
|
|
208
230
|
writeJson(response, 200, {
|
|
209
231
|
success: true,
|
|
210
232
|
data: logs.map((log) => ({
|
|
233
|
+
id: log.id,
|
|
211
234
|
level: log.level,
|
|
212
235
|
name: log.name,
|
|
213
236
|
message: log.message,
|
|
214
237
|
source: log.source,
|
|
215
|
-
timestamp:
|
|
216
|
-
log.timestamp instanceof Date ? log.timestamp.toISOString() : log.timestamp,
|
|
238
|
+
timestamp: log.timestamp instanceof Date ? log.timestamp.toISOString() : log.timestamp,
|
|
217
239
|
})),
|
|
218
|
-
total
|
|
240
|
+
total, page, pageSize, totalPages,
|
|
241
|
+
sources: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([source, count]) => ({ source, count })),
|
|
219
242
|
});
|
|
220
243
|
}, { summary: 'List system logs', tags: ['console', 'logs'] });
|
|
221
244
|
|
|
@@ -363,8 +386,8 @@ function registerMarketplaceRoutes(
|
|
|
363
386
|
version: p.version || '',
|
|
364
387
|
description: p.description || '',
|
|
365
388
|
author: p.author || '',
|
|
366
|
-
isOfficial:
|
|
367
|
-
official:
|
|
389
|
+
isOfficial: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
|
|
390
|
+
official: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
|
|
368
391
|
category: p.category || 'util',
|
|
369
392
|
keywords: (p.tags as string[]) || [],
|
|
370
393
|
npm: p.npm || `https://www.npmjs.com/package/${p.name}`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assertDemoConsoleRpcAllowed } from '@zhin.js/console-protocol';
|
|
2
2
|
import type { ConsoleRpcExtendedCtx, ExtendedRpcResult } from './contracts.js';
|
|
3
|
-
import { addCron, listSchedule, mutateCron } from './schedule-rpc.js';
|
|
3
|
+
import { addCron, listSchedule, mutateCron, validateCron } from './schedule-rpc.js';
|
|
4
4
|
import {
|
|
5
5
|
actOnRequest, listInbox, listInboxMessages, listPendingRequests, listRecentInboxMessages,
|
|
6
6
|
mapNoticeRow, mapRequestRow, markInboxConsumed, TABLE_NOTICE, TABLE_REQUEST,
|
|
@@ -28,6 +28,9 @@ export async function dispatchExtendedConsoleRpc(
|
|
|
28
28
|
case 'cron:list':
|
|
29
29
|
return listSchedule(ctx);
|
|
30
30
|
|
|
31
|
+
case 'cron:validate':
|
|
32
|
+
return validateCron(d);
|
|
33
|
+
|
|
31
34
|
case 'cron:add':
|
|
32
35
|
return addCron(d, ctx);
|
|
33
36
|
case 'cron:remove':
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseCron } from '@zhin.js/schedule';
|
|
1
2
|
import type { ConsoleRpcExtendedCtx, ConsoleScheduleEngine, ExtendedRpcResult, ScheduleJobRow } from './contracts.js';
|
|
2
3
|
import { errorMessage, strField } from './rpc-values.js';
|
|
3
4
|
|
|
@@ -28,7 +29,7 @@ export function listSchedule(ctx: ConsoleScheduleListCtx): Promise<ExtendedRpcRe
|
|
|
28
29
|
...((job as { nextExecution?: number }).nextExecution
|
|
29
30
|
? { nextExecution: (job as { nextExecution?: number }).nextExecution } : {}),
|
|
30
31
|
}));
|
|
31
|
-
return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent } }));
|
|
32
|
+
return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent, capabilities: { persistent: !!ctx.resolveScheduleEngine?.() && ctx.fullScope } } })).catch((error) => ({ error: errorMessage(error) }));
|
|
32
33
|
} catch (error) {
|
|
33
34
|
return { error: errorMessage(error) };
|
|
34
35
|
}
|
|
@@ -56,8 +57,8 @@ async function listPersistentJobs(ctx: ConsoleRpcExtendedCtx): Promise<Record<st
|
|
|
56
57
|
lastExecutedAt: (job.state as { lastExecutedAt?: number } | undefined)?.lastExecutedAt,
|
|
57
58
|
lastStatus: (job.state as { lastStatus?: string } | undefined)?.lastStatus,
|
|
58
59
|
}));
|
|
59
|
-
} catch {
|
|
60
|
-
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new Error(`读取持久化任务失败: ${errorMessage(error)}`);
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
@@ -71,6 +72,11 @@ export async function addCron(
|
|
|
71
72
|
const prompt = String(d.prompt ?? '').trim();
|
|
72
73
|
if (!cronExpression) return { error: 'cronExpression is required' };
|
|
73
74
|
if (!prompt) return { error: 'prompt is required' };
|
|
75
|
+
try {
|
|
76
|
+
parseCron(cronExpression);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { error: `Cron 表达式无效: ${errorMessage(error)}` };
|
|
79
|
+
}
|
|
74
80
|
const label = typeof d.label === 'string' && d.label.trim() ? d.label.trim() : undefined;
|
|
75
81
|
const context = (d.context ?? {}) as Record<string, unknown>;
|
|
76
82
|
const target = context.target ?? context.channel;
|
|
@@ -115,3 +121,13 @@ export async function mutateCron(
|
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
// ---------------------------------------------------------------- inbox
|
|
124
|
+
|
|
125
|
+
/** Uses the same parser as the persistent schedule engine; never creates a job. */
|
|
126
|
+
export function validateCron(d: Record<string, unknown>): ExtendedRpcResult {
|
|
127
|
+
try {
|
|
128
|
+
parseCron(String(d.cronExpression ?? '').trim());
|
|
129
|
+
return { data: { valid: true } };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { data: { valid: false, error: errorMessage(error) } };
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/console-rpc.ts
CHANGED
|
@@ -812,6 +812,8 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
812
812
|
reachable: endpoint.connected && endpoint.status === 'online',
|
|
813
813
|
connected: endpoint.connected,
|
|
814
814
|
status: endpoint.status,
|
|
815
|
+
admitted: endpoint.admitted,
|
|
816
|
+
transportState: endpoint.transportState,
|
|
815
817
|
phase: endpoint.phase ?? (endpoint.connected ? 'online' : 'pending'),
|
|
816
818
|
pendingLogin: endpoint.pendingLogin ?? false,
|
|
817
819
|
latencyMs: Date.now() - startedAt,
|
package/src/http-host.ts
CHANGED
|
@@ -170,6 +170,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
170
170
|
let address: HttpHostAddress | undefined;
|
|
171
171
|
let closed = false;
|
|
172
172
|
let closeResult: Promise<void> | undefined;
|
|
173
|
+
let listenResult: Promise<HttpHostAddress> | undefined;
|
|
173
174
|
|
|
174
175
|
const secure = options.tls !== undefined;
|
|
175
176
|
const handleRequest = (request: IncomingMessage, response: ServerResponse): void => {
|
|
@@ -345,7 +346,10 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
345
346
|
const auth = authenticateHttp(request, url, pathname);
|
|
346
347
|
if (!auth.ok) {
|
|
347
348
|
if (corsOk && origin) applyCors(response, origin, corsOrigins);
|
|
348
|
-
writeJson(response,
|
|
349
|
+
writeJson(response, auth.forbidden ? 403 : 401, {
|
|
350
|
+
success: false,
|
|
351
|
+
error: auth.forbidden ? 'Token scope does not allow this route' : 'Invalid or missing token',
|
|
352
|
+
});
|
|
349
353
|
return;
|
|
350
354
|
}
|
|
351
355
|
|
|
@@ -407,7 +411,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
407
411
|
request: IncomingMessage,
|
|
408
412
|
url: URL,
|
|
409
413
|
pathname: string,
|
|
410
|
-
): { ok: true; scope: AuthScope; principal?: AuthenticatedTokenPrincipal } | { ok: false } {
|
|
414
|
+
): { ok: true; scope: AuthScope; principal?: AuthenticatedTokenPrincipal } | { ok: false; forbidden?: boolean } {
|
|
411
415
|
if (!requiresHttpAuth(pathname, apiBase, authExempt)) {
|
|
412
416
|
return { ok: true, scope: 'full' };
|
|
413
417
|
}
|
|
@@ -422,7 +426,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
422
426
|
if (!scope) return { ok: false };
|
|
423
427
|
if (scope === 'demo') {
|
|
424
428
|
const method = (request.method ?? 'GET').toUpperCase();
|
|
425
|
-
if (!isDemoHttpAllowed(method, pathname, apiBase)) return { ok: false };
|
|
429
|
+
if (!isDemoHttpAllowed(method, pathname, apiBase)) return { ok: false, forbidden: true };
|
|
426
430
|
}
|
|
427
431
|
const principal = tokenRegistry.resolvePrincipal(token);
|
|
428
432
|
return { ok: true, scope, ...(principal ? { principal } : {}) };
|
|
@@ -534,11 +538,21 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
534
538
|
},
|
|
535
539
|
|
|
536
540
|
listen(): Promise<HttpHostAddress> {
|
|
541
|
+
if (closed) return Promise.reject(new Error('HTTP Host is closed'));
|
|
542
|
+
if (listenResult) return listenResult;
|
|
537
543
|
if (address) return Promise.resolve(address);
|
|
538
|
-
|
|
539
|
-
|
|
544
|
+
listenResult = new Promise((resolve, reject) => {
|
|
545
|
+
const onError = (error: Error): void => {
|
|
546
|
+
listenResult = undefined;
|
|
547
|
+
reject(error);
|
|
548
|
+
};
|
|
549
|
+
server.once('error', onError);
|
|
540
550
|
server.listen({ host, port }, () => {
|
|
541
|
-
server.off('error',
|
|
551
|
+
server.off('error', onError);
|
|
552
|
+
if (closed) {
|
|
553
|
+
reject(new Error('HTTP Host closed before listen completed'));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
542
556
|
const bound = server.address();
|
|
543
557
|
const listenPort = typeof bound === 'object' && bound ? bound.port : port;
|
|
544
558
|
const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
@@ -562,6 +576,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
562
576
|
resolve(address);
|
|
563
577
|
});
|
|
564
578
|
});
|
|
579
|
+
return listenResult;
|
|
565
580
|
},
|
|
566
581
|
|
|
567
582
|
close(): Promise<void> {
|
|
@@ -569,35 +584,38 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
569
584
|
closed = true;
|
|
570
585
|
wsRoutes.clear();
|
|
571
586
|
httpRoutes.length = 0;
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
server.close((serverError) => {
|
|
591
|
-
if (serverError) {
|
|
592
|
-
reject(serverError);
|
|
587
|
+
closeResult = (async () => {
|
|
588
|
+
// A pending listen must settle before deciding whether there is a
|
|
589
|
+
// listener to close. Otherwise it can bind after close has resolved.
|
|
590
|
+
await listenResult?.catch(() => undefined);
|
|
591
|
+
if (!server.listening) {
|
|
592
|
+
address = undefined;
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
await new Promise<void>((resolve, reject) => {
|
|
596
|
+
// Long-lived connections (SSE, keep-alive, WS) never end on their own:
|
|
597
|
+
// destroy tracked sockets and terminate WS clients before close(),
|
|
598
|
+
// otherwise the close callbacks would never fire.
|
|
599
|
+
for (const socket of sockets) socket.destroy();
|
|
600
|
+
sockets.clear();
|
|
601
|
+
for (const client of wss.clients) client.terminate();
|
|
602
|
+
wss.close((wssError) => {
|
|
603
|
+
if (wssError) {
|
|
604
|
+
reject(wssError);
|
|
593
605
|
return;
|
|
594
606
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
607
|
+
server.close((serverError) => {
|
|
608
|
+
if (serverError) {
|
|
609
|
+
reject(serverError);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
logger.debug(formatCompact({ op: 'http_host_closed' }));
|
|
613
|
+
address = undefined;
|
|
614
|
+
resolve();
|
|
615
|
+
});
|
|
598
616
|
});
|
|
599
617
|
});
|
|
600
|
-
});
|
|
618
|
+
})();
|
|
601
619
|
return closeResult;
|
|
602
620
|
},
|
|
603
621
|
};
|
package/src/project-files.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { access, lstat, mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
/** Align with Remote Console file manager (paths relative to project root). */
|
|
@@ -48,8 +48,7 @@ export type FileTreeNode = {
|
|
|
48
48
|
export function isProjectPathAllowed(relativePath: string): boolean {
|
|
49
49
|
if (relativePath.includes('..') || path.isAbsolute(relativePath)) return false;
|
|
50
50
|
const normalized = relativePath.replace(/\\/gu, '/').replace(/^\.\//u, '');
|
|
51
|
-
|
|
52
|
-
if (FILE_MANAGER_BLOCKED.has(firstSegment)) return false;
|
|
51
|
+
if (normalized.split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment))) return false;
|
|
53
52
|
return FILE_MANAGER_ALLOWED.some(
|
|
54
53
|
(allowed) => normalized === allowed || normalized.startsWith(`${allowed}/`),
|
|
55
54
|
);
|
|
@@ -59,7 +58,8 @@ export async function buildProjectFileTree(projectRoot: string): Promise<FileTre
|
|
|
59
58
|
const tree: FileTreeNode[] = [];
|
|
60
59
|
for (const entry of FILE_MANAGER_ALLOWED) {
|
|
61
60
|
if (entry.includes('/')) continue;
|
|
62
|
-
const absPath =
|
|
61
|
+
const absPath = await resolveAllowedPath(projectRoot, entry);
|
|
62
|
+
if (!absPath) continue;
|
|
63
63
|
if (!(await pathExists(absPath))) continue;
|
|
64
64
|
const info = await safeStat(absPath);
|
|
65
65
|
if (!info) continue;
|
|
@@ -81,7 +81,7 @@ export async function readProjectFile(
|
|
|
81
81
|
projectRoot: string,
|
|
82
82
|
relativePath: string,
|
|
83
83
|
): Promise<{ content: string; size: number }> {
|
|
84
|
-
const absPath = resolveAllowedPath(projectRoot, relativePath);
|
|
84
|
+
const absPath = await resolveAllowedPath(projectRoot, relativePath);
|
|
85
85
|
if (!absPath) throw new Error(`Access denied: ${relativePath}`);
|
|
86
86
|
if (!(await pathExists(absPath))) throw new Error(`File not found: ${relativePath}`);
|
|
87
87
|
const info = await safeStat(absPath);
|
|
@@ -98,26 +98,54 @@ export async function saveProjectFile(
|
|
|
98
98
|
relativePath: string,
|
|
99
99
|
content: string,
|
|
100
100
|
): Promise<void> {
|
|
101
|
-
const absPath = resolveAllowedPath(projectRoot, relativePath);
|
|
101
|
+
const absPath = await resolveAllowedPath(projectRoot, relativePath);
|
|
102
102
|
if (!absPath) throw new Error(`Access denied: ${relativePath}`);
|
|
103
103
|
await mkdir(path.dirname(absPath), { recursive: true });
|
|
104
|
-
await
|
|
104
|
+
const checkedPath = await resolveAllowedPath(projectRoot, relativePath);
|
|
105
|
+
if (!checkedPath) throw new Error(`Access denied: ${relativePath}`);
|
|
106
|
+
await writeFile(checkedPath, content, 'utf8');
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
export async function listEnvFiles(
|
|
108
110
|
projectRoot: string,
|
|
109
111
|
): Promise<readonly { name: string; exists: boolean }[]> {
|
|
110
112
|
const names = ['.env', '.env.development', '.env.production'] as const;
|
|
111
|
-
const result = await Promise.all(names.map(async (name) =>
|
|
112
|
-
name
|
|
113
|
-
exists: await pathExists(
|
|
114
|
-
}))
|
|
113
|
+
const result = await Promise.all(names.map(async (name) => {
|
|
114
|
+
const resolved = await resolveAllowedPath(projectRoot, name);
|
|
115
|
+
return { name, exists: resolved !== null && await pathExists(resolved) };
|
|
116
|
+
}));
|
|
115
117
|
return Object.freeze(result);
|
|
116
118
|
}
|
|
117
119
|
|
|
118
|
-
|
|
120
|
+
/** Resolve existing links, or the nearest existing parent for a new file. */
|
|
121
|
+
async function resolveAllowedPath(projectRoot: string, relativePath: string): Promise<string | null> {
|
|
119
122
|
if (!isProjectPathAllowed(relativePath)) return null;
|
|
120
|
-
|
|
123
|
+
try {
|
|
124
|
+
const root = await realpath(projectRoot);
|
|
125
|
+
let candidate = path.resolve(root, relativePath);
|
|
126
|
+
const missing: string[] = [];
|
|
127
|
+
for (;;) {
|
|
128
|
+
try {
|
|
129
|
+
// A dangling link must fail closed rather than be treated as a missing directory.
|
|
130
|
+
await lstat(candidate);
|
|
131
|
+
const resolved = path.resolve(await realpath(candidate), ...missing);
|
|
132
|
+
const relative = path.relative(root, resolved);
|
|
133
|
+
return isProjectPathAllowed(relative) ? resolved : null;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return null;
|
|
136
|
+
// lstat succeeds for dangling symlinks even though realpath fails.
|
|
137
|
+
try { await lstat(candidate); return null; } catch (missingError) {
|
|
138
|
+
if ((missingError as NodeJS.ErrnoException).code !== 'ENOENT') return null;
|
|
139
|
+
}
|
|
140
|
+
const parent = path.dirname(candidate);
|
|
141
|
+
if (candidate === root || parent === candidate) return null;
|
|
142
|
+
missing.unshift(path.basename(candidate));
|
|
143
|
+
candidate = parent;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
121
149
|
}
|
|
122
150
|
|
|
123
151
|
async function buildDirectoryTree(
|
|
@@ -126,7 +154,8 @@ async function buildDirectoryTree(
|
|
|
126
154
|
maxDepth: number,
|
|
127
155
|
): Promise<FileTreeNode[]> {
|
|
128
156
|
if (maxDepth <= 0) return [];
|
|
129
|
-
const absDir =
|
|
157
|
+
const absDir = await resolveAllowedPath(projectRoot, relativePath);
|
|
158
|
+
if (!absDir) return [];
|
|
130
159
|
const info = await safeStat(absDir);
|
|
131
160
|
if (!info?.isDirectory()) return [];
|
|
132
161
|
const entries = await readdir(absDir, { withFileTypes: true });
|
|
@@ -134,14 +163,17 @@ async function buildDirectoryTree(
|
|
|
134
163
|
for (const entry of entries) {
|
|
135
164
|
if (FILE_MANAGER_BLOCKED.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
136
165
|
const childRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
137
|
-
|
|
166
|
+
const childPath = await resolveAllowedPath(projectRoot, childRelative);
|
|
167
|
+
if (!childPath) continue;
|
|
168
|
+
const childInfo = await safeStat(childPath);
|
|
169
|
+
if (childInfo?.isDirectory()) {
|
|
138
170
|
result.push({
|
|
139
171
|
name: entry.name,
|
|
140
172
|
path: childRelative,
|
|
141
173
|
type: 'directory',
|
|
142
174
|
children: await buildDirectoryTree(projectRoot, childRelative, maxDepth - 1),
|
|
143
175
|
});
|
|
144
|
-
} else if (
|
|
176
|
+
} else if (childInfo?.isFile()) {
|
|
145
177
|
result.push({ name: entry.name, path: childRelative, type: 'file' });
|
|
146
178
|
}
|
|
147
179
|
}
|