agentgui 1.0.958 → 1.0.959
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/.claude/workflows/gui-completion.js +88 -0
- package/AGENTS.md +18 -0
- package/PUNCHLIST-COMPLETION.md +266 -0
- package/lib/http-handler.js +196 -16
- package/package.json +1 -1
- package/scripts/validate-mutations.mjs +40 -0
- package/site/app/js/app.js +771 -104
- package/site/app/js/backend.js +61 -3
- package/site/app/vendor/anentrypoint-design/247420.css +110 -0
- package/site/app/vendor/anentrypoint-design/247420.js +12 -12
package/lib/http-handler.js
CHANGED
|
@@ -37,6 +37,56 @@ function confineToRoots(inputPath, allowRoots) {
|
|
|
37
37
|
return { ok: true, realPath };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// The allowlist the Files surface operates within: server cwd + Claude
|
|
41
|
+
// projects dir, widened via FS_ROOTS (path-separated). One construction so
|
|
42
|
+
// /api/list,file,download and the mutation routes can never drift apart.
|
|
43
|
+
function fsAllowRoots() {
|
|
44
|
+
return [
|
|
45
|
+
process.env.STARTUP_CWD || process.cwd(),
|
|
46
|
+
process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
|
|
47
|
+
...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
|
|
48
|
+
].map(r => path.normalize(r));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A new file/dir name must be a single path component: no separators, no
|
|
52
|
+
// traversal, no NTFS alternate-data-stream colon, no reserved Windows device
|
|
53
|
+
// names, no trailing dot/space (Windows silently strips them, aliasing two
|
|
54
|
+
// names onto one entry). Returns the trimmed name or null when inadmissible.
|
|
55
|
+
function sanitizeEntryName(name) {
|
|
56
|
+
if (typeof name !== 'string') return null;
|
|
57
|
+
const n = name.trim();
|
|
58
|
+
if (!n || n.length > 255) return null;
|
|
59
|
+
if (/[\\/:*?"<>|\x00-\x1f]/.test(n)) return null;
|
|
60
|
+
if (n === '.' || n === '..') return null;
|
|
61
|
+
if (/[. ]$/.test(n)) return null;
|
|
62
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(n)) return null;
|
|
63
|
+
return n;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Read a request body with a hard size cap; resolves a Buffer or rejects with
|
|
67
|
+
// .code='TOO_LARGE' so the caller can answer 413 without buffering the rest.
|
|
68
|
+
function readBody(req, maxBytes) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const chunks = []; let total = 0;
|
|
71
|
+
req.on('data', (c) => {
|
|
72
|
+
total += c.length;
|
|
73
|
+
if (total > maxBytes) { const e = new Error('body too large'); e.code = 'TOO_LARGE'; req.destroy(); reject(e); return; }
|
|
74
|
+
chunks.push(c);
|
|
75
|
+
});
|
|
76
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
77
|
+
req.on('error', reject);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// True when the resolved path IS one of the allowlist roots - the roots
|
|
82
|
+
// themselves are never mutation targets (rename/delete of a root would orphan
|
|
83
|
+
// the whole surface).
|
|
84
|
+
function isAllowRoot(realPath, allowRoots) {
|
|
85
|
+
const isWindows = os.platform() === 'win32';
|
|
86
|
+
const p = isWindows ? realPath.toLowerCase() : realPath;
|
|
87
|
+
return allowRoots.some(r => (isWindows ? r.toLowerCase() : r) === p);
|
|
88
|
+
}
|
|
89
|
+
|
|
40
90
|
export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
|
|
41
91
|
return async function httpHandler(req, res) {
|
|
42
92
|
// CORS: when PASSWORD is set the server holds credentials a cross-origin
|
|
@@ -116,6 +166,28 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
116
166
|
}
|
|
117
167
|
|
|
118
168
|
const pathOnly = req.url.split('?')[0];
|
|
169
|
+
|
|
170
|
+
// CSRF guard on every state-changing method. Without PASSWORD the server
|
|
171
|
+
// is open and advertises a wildcard ACAO, so a cross-site page could POST
|
|
172
|
+
// a form at the mutation routes (rename/delete/mkdir/upload) on localhost.
|
|
173
|
+
// A simple cross-site form cannot set Sec-Fetch-Site:same-origin, send
|
|
174
|
+
// application/json, or attach custom headers - so: accept when the
|
|
175
|
+
// browser says same-origin/none (direct nav, same-origin fetch), when the
|
|
176
|
+
// body is declared JSON, or when an Authorization header rode along (a
|
|
177
|
+
// credentialed programmatic client). Reject the rest with 403.
|
|
178
|
+
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
|
|
179
|
+
const sfs = req.headers['sec-fetch-site'];
|
|
180
|
+
const ct = (req.headers['content-type'] || '').toLowerCase();
|
|
181
|
+
const sameSite = !sfs || sfs === 'same-origin' || sfs === 'none';
|
|
182
|
+
const jsonBody = ct.startsWith('application/json') || ct.startsWith('application/octet-stream') || ct === '';
|
|
183
|
+
const authed = !!req.headers['authorization'];
|
|
184
|
+
if (!sameSite && !jsonBody && !authed) {
|
|
185
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
186
|
+
res.end(JSON.stringify({ error: 'cross-site request rejected' }));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
119
191
|
if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/') || pathOnly.startsWith('/v1/history') || (BASE_URL && pathOnly.startsWith(BASE_URL + '/v1/history'))) return expressApp(req, res);
|
|
120
192
|
|
|
121
193
|
if (req.url === '/favicon.ico' || req.url === BASE_URL + '/favicon.ico') {
|
|
@@ -147,7 +219,31 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
147
219
|
try { queries._db.prepare('SELECT 1').get(); } catch (e) { dbStatus = { ok: false, error: e.message }; }
|
|
148
220
|
const queueSizes = {};
|
|
149
221
|
for (const [k, v] of messageQueues) queueSizes[k] = v.length;
|
|
150
|
-
sendJSON(req, res, 200, {
|
|
222
|
+
sendJSON(req, res, 200, {
|
|
223
|
+
status: 'ok', version: PKG_VERSION, uptime: process.uptime(), agents: discoveredAgents.length,
|
|
224
|
+
activeExecutions: activeExecutions.size, wsClients: getWss()?.clients?.size ?? 0,
|
|
225
|
+
memory: process.memoryUsage(), acp: getACPStatus(), db: dbStatus, queueSizes,
|
|
226
|
+
// Read-only server facts for the settings server-info panel.
|
|
227
|
+
projectsDir: process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
|
|
228
|
+
allowRoots: fsAllowRoots(),
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Existence/shape probe for a proposed chat working directory, confined
|
|
234
|
+
// to the same allowlist as the Files surface (an unconfined stat would be
|
|
235
|
+
// a filesystem oracle). Returns {ok, dir} or a 403/404 with plain copy.
|
|
236
|
+
if (routePath.startsWith('/api/stat') && req.method === 'GET') {
|
|
237
|
+
const rawS = routePath.split('?')[0].slice('/api/stat'.length).replace(/^\//, '');
|
|
238
|
+
const decodedPath = rawS ? decodeURIComponent(rawS) : '';
|
|
239
|
+
const conf = confineToRoots(decodedPath, fsAllowRoots());
|
|
240
|
+
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: conf.reason }); return; }
|
|
241
|
+
try {
|
|
242
|
+
const st = fs.statSync(conf.realPath);
|
|
243
|
+
sendJSON(req, res, 200, { ok: true, dir: st.isDirectory(), path: conf.realPath });
|
|
244
|
+
} catch (err) {
|
|
245
|
+
sendJSON(req, res, err.code === 'ENOENT' ? 404 : 403, { error: err.message });
|
|
246
|
+
}
|
|
151
247
|
return;
|
|
152
248
|
}
|
|
153
249
|
|
|
@@ -191,11 +287,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
191
287
|
if (routePath.startsWith('/api/list')) {
|
|
192
288
|
const rawQ = routePath.split('?')[0].slice('/api/list'.length).replace(/^\//, '');
|
|
193
289
|
const decodedPath = rawQ ? decodeURIComponent(rawQ) : '';
|
|
194
|
-
const allowRoots =
|
|
195
|
-
process.env.STARTUP_CWD || process.cwd(),
|
|
196
|
-
process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
|
|
197
|
-
...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
|
|
198
|
-
].map(r => path.normalize(r));
|
|
290
|
+
const allowRoots = fsAllowRoots();
|
|
199
291
|
// Empty path = the first allow-root (a sane default landing dir).
|
|
200
292
|
const reqPath = !decodedPath ? allowRoots[0] : decodedPath;
|
|
201
293
|
const conf = confineToRoots(reqPath, allowRoots);
|
|
@@ -264,11 +356,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
264
356
|
if (routePath.startsWith('/api/file/')) {
|
|
265
357
|
const rawF = routePath.split('?')[0].slice('/api/file/'.length);
|
|
266
358
|
const decodedPath = decodeURIComponent(rawF);
|
|
267
|
-
const allowRoots =
|
|
268
|
-
process.env.STARTUP_CWD || process.cwd(),
|
|
269
|
-
process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
|
|
270
|
-
...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
|
|
271
|
-
].map(r => path.normalize(r));
|
|
359
|
+
const allowRoots = fsAllowRoots();
|
|
272
360
|
const conf = confineToRoots(decodedPath, allowRoots);
|
|
273
361
|
if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
|
|
274
362
|
const normalizedPath = conf.realPath;
|
|
@@ -309,11 +397,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
309
397
|
if (routePath.startsWith('/api/download/')) {
|
|
310
398
|
const rawD = routePath.split('?')[0].slice('/api/download/'.length);
|
|
311
399
|
const decodedPath = decodeURIComponent(rawD);
|
|
312
|
-
const allowRoots =
|
|
313
|
-
process.env.STARTUP_CWD || process.cwd(),
|
|
314
|
-
process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
|
|
315
|
-
...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
|
|
316
|
-
].map(r => path.normalize(r));
|
|
400
|
+
const allowRoots = fsAllowRoots();
|
|
317
401
|
const conf = confineToRoots(decodedPath, allowRoots);
|
|
318
402
|
if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
|
|
319
403
|
const normalizedPath = conf.realPath;
|
|
@@ -337,6 +421,102 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
337
421
|
return;
|
|
338
422
|
}
|
|
339
423
|
|
|
424
|
+
// --- File mutations ----------------------------------------------------
|
|
425
|
+
// The Files surface is a real manager (fsbrowse-grade): rename, delete,
|
|
426
|
+
// mkdir, upload. Every route re-confines via confineToRoots (realpath, so
|
|
427
|
+
// a symlink inside a root cannot point a mutation outside it), refuses
|
|
428
|
+
// the roots themselves as targets, and sanitizes any NEW name to a single
|
|
429
|
+
// path component (sanitizeEntryName). All are POST/PUT-only with JSON or
|
|
430
|
+
// raw-byte bodies; errors map to plain machine codes the client renders
|
|
431
|
+
// as human copy.
|
|
432
|
+
|
|
433
|
+
// POST /api/rename {path, newName} -> {ok, path}
|
|
434
|
+
if (routePath.split('?')[0] === '/api/rename' && req.method === 'POST') {
|
|
435
|
+
let body;
|
|
436
|
+
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
437
|
+
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
438
|
+
const allowRoots = fsAllowRoots();
|
|
439
|
+
const conf = confineToRoots(String(body.path || ''), allowRoots);
|
|
440
|
+
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
441
|
+
if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot rename an allowed root' }); return; }
|
|
442
|
+
const newName = sanitizeEntryName(body.newName);
|
|
443
|
+
if (!newName) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
|
|
444
|
+
const target = path.join(path.dirname(conf.realPath), newName);
|
|
445
|
+
// The target stays in the same (already-confined) directory by
|
|
446
|
+
// construction, but re-check anyway so the invariant is local.
|
|
447
|
+
const tConf = confineToRoots(path.dirname(target), allowRoots);
|
|
448
|
+
if (!tConf.ok) { sendJSON(req, res, 403, { error: 'forbidden: target outside allowed roots' }); return; }
|
|
449
|
+
if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
450
|
+
try { fs.renameSync(conf.realPath, target); sendJSON(req, res, 200, { ok: true, path: target }); }
|
|
451
|
+
catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err.message }); }
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// POST /api/delete {path, recursive?} -> {ok}. Deleting a non-empty dir
|
|
456
|
+
// requires recursive:true (the client confirms first).
|
|
457
|
+
if (routePath.split('?')[0] === '/api/delete' && req.method === 'POST') {
|
|
458
|
+
let body;
|
|
459
|
+
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
460
|
+
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
461
|
+
const allowRoots = fsAllowRoots();
|
|
462
|
+
const conf = confineToRoots(String(body.path || ''), allowRoots);
|
|
463
|
+
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
464
|
+
if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot delete an allowed root' }); return; }
|
|
465
|
+
try {
|
|
466
|
+
const st = fs.lstatSync(conf.realPath);
|
|
467
|
+
if (st.isDirectory()) {
|
|
468
|
+
if (body.recursive === true) fs.rmSync(conf.realPath, { recursive: true });
|
|
469
|
+
else fs.rmdirSync(conf.realPath); // throws ENOTEMPTY unless empty
|
|
470
|
+
} else {
|
|
471
|
+
fs.unlinkSync(conf.realPath);
|
|
472
|
+
}
|
|
473
|
+
sendJSON(req, res, 200, { ok: true });
|
|
474
|
+
} catch (err) {
|
|
475
|
+
const code = err.code === 'ENOTEMPTY' ? 409 : (err.code === 'EACCES' || err.code === 'EPERM' ? 403 : (err.code === 'ENOENT' ? 404 : 400));
|
|
476
|
+
sendJSON(req, res, code, { error: err.code === 'ENOTEMPTY' ? 'directory is not empty' : err.message });
|
|
477
|
+
}
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// POST /api/mkdir {dir, name} -> {ok, path}. dir must exist inside roots.
|
|
482
|
+
if (routePath.split('?')[0] === '/api/mkdir' && req.method === 'POST') {
|
|
483
|
+
let body;
|
|
484
|
+
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
485
|
+
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
486
|
+
const allowRoots = fsAllowRoots();
|
|
487
|
+
const conf = confineToRoots(String(body.dir || ''), allowRoots);
|
|
488
|
+
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
489
|
+
const name = sanitizeEntryName(body.name);
|
|
490
|
+
if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
|
|
491
|
+
const target = path.join(conf.realPath, name);
|
|
492
|
+
if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
493
|
+
try { fs.mkdirSync(target); sendJSON(req, res, 200, { ok: true, path: target }); }
|
|
494
|
+
catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err.message }); }
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// PUT /api/upload-file?dir=<enc>&name=<enc> with raw file bytes as the
|
|
499
|
+
// body (no multipart dependency; the client sends fetch(file)). 50MB cap,
|
|
500
|
+
// never overwrites unless ?overwrite=1. Distinct path from the legacy
|
|
501
|
+
// express-mounted /api/upload/:conversationId.
|
|
502
|
+
if (routePath.split('?')[0] === '/api/upload-file' && req.method === 'PUT') {
|
|
503
|
+
let qs;
|
|
504
|
+
try { qs = new URL(req.url, 'http://localhost').searchParams; } catch { qs = new URLSearchParams(); }
|
|
505
|
+
const allowRoots = fsAllowRoots();
|
|
506
|
+
const conf = confineToRoots(qs.get('dir') || '', allowRoots);
|
|
507
|
+
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
508
|
+
const name = sanitizeEntryName(qs.get('name'));
|
|
509
|
+
if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
|
|
510
|
+
const target = path.join(conf.realPath, name);
|
|
511
|
+
if (fs.existsSync(target) && qs.get('overwrite') !== '1') { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
512
|
+
let buf;
|
|
513
|
+
try { buf = await readBody(req, 50 * 1024 * 1024); }
|
|
514
|
+
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: e.code === 'TOO_LARGE' ? 'file too large (50MB cap)' : 'upload failed' }); return; }
|
|
515
|
+
try { fs.writeFileSync(target, buf); sendJSON(req, res, 200, { ok: true, path: target, size: buf.length }); }
|
|
516
|
+
catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err.message }); }
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
340
520
|
if (routePath.startsWith('/api/image/')) {
|
|
341
521
|
const imagePath = routePath.slice('/api/image/'.length);
|
|
342
522
|
const decodedPath = decodeURIComponent(imagePath);
|
package/package.json
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Live security validation of the confined file-mutation endpoints.
|
|
2
|
+
// Run with the server up: node scripts/validate-mutations.mjs
|
|
3
|
+
const BASE = process.env.BASE || 'http://localhost:3000';
|
|
4
|
+
const J = { 'Content-Type': 'application/json' };
|
|
5
|
+
const results = [];
|
|
6
|
+
async function post(route, body, headers = J) {
|
|
7
|
+
const r = await fetch(BASE + route, { method: 'POST', headers, body: typeof body === 'string' ? body : JSON.stringify(body) });
|
|
8
|
+
return { status: r.status, body: await r.text() };
|
|
9
|
+
}
|
|
10
|
+
async function check(name, expected, actual) {
|
|
11
|
+
const ok = actual.status === expected;
|
|
12
|
+
results.push({ name, expected, got: actual.status, ok, body: actual.body.slice(0, 80) });
|
|
13
|
+
}
|
|
14
|
+
const ROOT = 'C:\\dev\\agentgui';
|
|
15
|
+
// security cases
|
|
16
|
+
await check('rename-traversal', 403, await post('/api/rename', { path: ROOT + '\\..\\..\\Windows\\win.ini', newName: 'x.txt' }));
|
|
17
|
+
await check('delete-out-of-roots', 403, await post('/api/delete', { path: 'C:\\Windows\\Temp\\x.txt' }));
|
|
18
|
+
await check('delete-root-refused', 403, await post('/api/delete', { path: ROOT }));
|
|
19
|
+
await check('mkdir-reserved-name', 400, await post('/api/mkdir', { dir: ROOT, name: 'CON' }));
|
|
20
|
+
await check('mkdir-name-with-separator', 400, await post('/api/mkdir', { dir: ROOT, name: 'a/b' }));
|
|
21
|
+
await check('mkdir-name-trailing-dot', 400, await post('/api/mkdir', { dir: ROOT, name: 'evil.' }));
|
|
22
|
+
await check('mkdir-ads-colon', 400, await post('/api/mkdir', { dir: ROOT, name: 'a:b' }));
|
|
23
|
+
await check('csrf-cross-site-form', 403, await post('/api/delete', 'path=x', { 'Content-Type': 'application/x-www-form-urlencoded', 'Sec-Fetch-Site': 'cross-site' }));
|
|
24
|
+
// round trip
|
|
25
|
+
await check('mkdir-ok', 200, await post('/api/mkdir', { dir: ROOT, name: '_wtest' }));
|
|
26
|
+
const up = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(ROOT + '\\_wtest') + '&name=up.txt', { method: 'PUT', body: 'hello upload' });
|
|
27
|
+
results.push({ name: 'upload-ok', expected: 200, got: up.status, ok: up.status === 200 });
|
|
28
|
+
const up2 = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(ROOT + '\\_wtest') + '&name=up.txt', { method: 'PUT', body: 'x' });
|
|
29
|
+
results.push({ name: 'upload-conflict-409', expected: 409, got: up2.status, ok: up2.status === 409 });
|
|
30
|
+
await check('rename-ok', 200, await post('/api/rename', { path: ROOT + '\\_wtest\\up.txt', newName: 'up2.txt' }));
|
|
31
|
+
await check('rename-conflict-409', 409, await post('/api/rename', { path: ROOT + '\\_wtest\\up2.txt', newName: 'up2.txt' }));
|
|
32
|
+
await check('delete-nonempty-no-recursive-409', 409, await post('/api/delete', { path: ROOT + '\\_wtest' }));
|
|
33
|
+
await check('delete-recursive-ok', 200, await post('/api/delete', { path: ROOT + '\\_wtest', recursive: true }));
|
|
34
|
+
const st = await fetch(BASE + '/api/stat/' + encodeURIComponent(ROOT));
|
|
35
|
+
results.push({ name: 'stat-ok', expected: 200, got: st.status, ok: st.status === 200 });
|
|
36
|
+
const st2 = await fetch(BASE + '/api/stat/' + encodeURIComponent('C:\\Windows'));
|
|
37
|
+
results.push({ name: 'stat-outside-403', expected: 403, got: st2.status, ok: st2.status === 403 });
|
|
38
|
+
let fail = 0;
|
|
39
|
+
for (const r of results) { if (!r.ok) fail++; console.log((r.ok ? 'PASS' : 'FAIL') + ' ' + r.name + ' expected=' + r.expected + ' got=' + r.got + (r.ok ? '' : ' body=' + (r.body || ''))); }
|
|
40
|
+
process.exit(fail ? 1 : 0);
|