mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
fix: harden stack file explorer operations (#1028)
* fix: harden stack file explorer operations * fix: update Docker toolchain to Go 1.26.3 * fix: repair Dockerfile tr argument split across lines * fix: bump protobufjs to clear npm audit high-severity advisories
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
* PUT /:stackName/files/content (Skipper+)
|
||||
* DELETE /:stackName/files (Skipper+)
|
||||
* POST /:stackName/files/folder (Skipper+)
|
||||
* PATCH /:stackName/files/rename (Skipper+)
|
||||
* PUT /:stackName/files/permissions (Skipper+)
|
||||
*
|
||||
* Covers: auth gating, tier gating (Community vs paid), input validation,
|
||||
* upload size limit, and happy-path 204/200 responses.
|
||||
@@ -98,6 +100,30 @@ describe('GET /api/stacks/:stackName/files', () => {
|
||||
expect(names).toContain('.env');
|
||||
});
|
||||
|
||||
it('emits diagnostic logs only when developer_mode is enabled', async () => {
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined);
|
||||
|
||||
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '0');
|
||||
await request(app)
|
||||
.get(`/api/stacks/${STACK}/files`)
|
||||
.set('Cookie', adminCookie);
|
||||
expect(debugSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('[Files:diag]'),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '1');
|
||||
await request(app)
|
||||
.get(`/api/stacks/${STACK}/files`)
|
||||
.set('Cookie', adminCookie);
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[Files:diag]'),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '0');
|
||||
});
|
||||
|
||||
it('returns 400 for an invalid stack name containing path traversal', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/stacks/../evil/files')
|
||||
@@ -188,6 +214,14 @@ describe('GET /api/stacks/:stackName/files/download', () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 400 INVALID_PATH when path query parameter is missing', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files/download`)
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_PATH');
|
||||
});
|
||||
|
||||
it('streams the file for a paid tier user', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files/download`)
|
||||
@@ -225,6 +259,27 @@ describe('POST /api/stacks/:stackName/files/upload', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects upload filenames with path separators', async () => {
|
||||
const boundary = '----sencho-test-boundary';
|
||||
const body = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="file"; filename="../evil.txt"',
|
||||
'Content-Type: text/plain',
|
||||
'',
|
||||
'data',
|
||||
`--${boundary}--`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/upload`)
|
||||
.set('Cookie', adminCookie)
|
||||
.set('Content-Type', `multipart/form-data; boundary=${boundary}`)
|
||||
.send(body);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Invalid filename');
|
||||
});
|
||||
|
||||
it('returns 413 TOO_LARGE when file exceeds 25 MB', async () => {
|
||||
// 26 MB buffer
|
||||
const bigFile = Buffer.alloc(26 * 1024 * 1024, 0x61);
|
||||
@@ -291,6 +346,15 @@ describe('PUT /api/stacks/:stackName/files/content', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 INVALID_PATH when path query parameter is missing', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ content: 'hello' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_PATH');
|
||||
});
|
||||
|
||||
it('returns 204 and writes the file for a paid tier admin', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
@@ -304,6 +368,36 @@ describe('PUT /api/stacks/:stackName/files/content', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /:stackName/files/rename ───────────────────────────────────────────
|
||||
|
||||
describe('PATCH /api/stacks/:stackName/files/rename', () => {
|
||||
it('returns 409 ALREADY_EXISTS when destination exists', async () => {
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'rename-source.txt'), 'source');
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'rename-target.txt'), 'target');
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/stacks/${STACK}/files/rename`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: 'rename-source.txt', to: 'rename-target.txt' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('ALREADY_EXISTS');
|
||||
});
|
||||
});
|
||||
|
||||
// ── PUT /:stackName/files/permissions ────────────────────────────────────────
|
||||
|
||||
describe('PUT /api/stacks/:stackName/files/permissions', () => {
|
||||
it('returns 400 INVALID_PATH for invalid chmod modes', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: 'compose.yaml' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 0o1000 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_PATH');
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE /:stackName/files ──────────────────────────────────────────────────
|
||||
|
||||
describe('DELETE /api/stacks/:stackName/files', () => {
|
||||
@@ -444,4 +538,21 @@ describe('permission gating', () => {
|
||||
.set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('viewer receives 403 from PATCH /files/rename', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/api/stacks/${STACK}/files/rename`)
|
||||
.set('Cookie', viewerCookie)
|
||||
.send({ from: 'compose.yaml', to: 'compose-renamed.yaml' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('viewer receives 403 from PUT /files/permissions', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: 'compose.yaml' })
|
||||
.set('Cookie', viewerCookie)
|
||||
.send({ mode: 0o644 });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,7 @@ export async function resolveAllEnvFilePaths(nodeId: number, stackName: string):
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 25 * 1024 * 1024, files: 1 },
|
||||
preservePath: true,
|
||||
});
|
||||
|
||||
function getRelPath(req: Request): string {
|
||||
@@ -821,7 +822,14 @@ stacksRouter.get('/:stackName/backup', async (req: Request, res: Response) => {
|
||||
|
||||
// ── File explorer endpoints ──
|
||||
|
||||
type FsErrorCode = 'INVALID_PATH' | 'SYMLINK_ESCAPE' | 'IS_DIRECTORY' | 'NOT_EMPTY' | 'NOT_FOUND' | 'TOO_LARGE';
|
||||
type FsErrorCode =
|
||||
| 'INVALID_PATH'
|
||||
| 'SYMLINK_ESCAPE'
|
||||
| 'IS_DIRECTORY'
|
||||
| 'NOT_EMPTY'
|
||||
| 'NOT_FOUND'
|
||||
| 'TOO_LARGE'
|
||||
| 'ALREADY_EXISTS';
|
||||
|
||||
function sendFsError(
|
||||
res: Response,
|
||||
@@ -839,23 +847,61 @@ function sendFsError(
|
||||
if (e.code === 'NOT_EMPTY') {
|
||||
return res.status(409).json({ error: e.message, code: e.code as FsErrorCode });
|
||||
}
|
||||
if (e.code === 'EEXIST') {
|
||||
return res.status(409).json({ error: e.message, code: 'ALREADY_EXISTS' satisfies FsErrorCode });
|
||||
}
|
||||
if (e.code === 'ENOTDIR') {
|
||||
return res.status(400).json({ error: 'Target path is not a directory', code: 'INVALID_PATH' satisfies FsErrorCode });
|
||||
}
|
||||
if (e.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: opts.notFoundMessage ?? 'File not found', code: 'NOT_FOUND' });
|
||||
}
|
||||
console.error(`[files] ${fallback}:`, e.message);
|
||||
console.error(`[files] ${fallback}:`, sanitizeForLog(e.message));
|
||||
return res.status(500).json({ error: fallback });
|
||||
}
|
||||
|
||||
function logFileOperation(level: 'info' | 'warn', message: string, details: Record<string, unknown>): void {
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(details).map(([key, value]) => [key, sanitizeForLog(value)]),
|
||||
);
|
||||
const log = level === 'warn' ? console.warn : console.log;
|
||||
log(`[Files] ${message}`, cleaned);
|
||||
}
|
||||
|
||||
function fsErrorCode(err: unknown): string {
|
||||
const code = (err as NodeJS.ErrnoException & { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : 'UNKNOWN';
|
||||
}
|
||||
|
||||
function logFileDiag(message: string, details: Record<string, unknown>): void {
|
||||
if (DatabaseService.getInstance().getGlobalSettings().developer_mode !== '1') return;
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(details).map(([key, value]) => [key, sanitizeForLog(value)]),
|
||||
);
|
||||
console.debug(`[Files:diag] ${message}`, cleaned);
|
||||
}
|
||||
|
||||
function isSafeUploadFilename(rawName: string): boolean {
|
||||
if (!rawName || rawName === '.' || rawName === '..') return false;
|
||||
if (rawName.includes('\0') || rawName.includes('/') || rawName.includes('\\')) return false;
|
||||
if (/^[a-zA-Z]:/.test(rawName) || path.isAbsolute(rawName)) return false;
|
||||
return path.basename(rawName) === rawName;
|
||||
}
|
||||
|
||||
stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
const relPath = getRelPath(req);
|
||||
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
|
||||
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('list start', { stackName, relPath, nodeId: req.nodeId });
|
||||
try {
|
||||
const entries = await FileSystemService.getInstance(req.nodeId).listStackDirectory(stackName, relPath);
|
||||
logFileDiag('list complete', { stackName, relPath, nodeId: req.nodeId, entries: entries.length, elapsedMs: Date.now() - startedAt });
|
||||
return res.json(entries);
|
||||
} catch (err: unknown) {
|
||||
logFileOperation('warn', 'list failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
||||
return sendFsError(res, err, 'Failed to list directory');
|
||||
}
|
||||
});
|
||||
@@ -867,10 +913,22 @@ stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response
|
||||
if (!isValidRelativeStackPath(relPath)) {
|
||||
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('read start', { stackName, relPath, nodeId: req.nodeId });
|
||||
try {
|
||||
const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath);
|
||||
logFileDiag('read complete', {
|
||||
stackName,
|
||||
relPath,
|
||||
nodeId: req.nodeId,
|
||||
binary: result.binary,
|
||||
oversized: result.oversized,
|
||||
size: result.size,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (err: unknown) {
|
||||
logFileOperation('warn', 'read failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
||||
return sendFsError(res, err, 'Failed to read file');
|
||||
}
|
||||
});
|
||||
@@ -879,9 +937,12 @@ stacksRouter.get('/:stackName/files/download', async (req: Request, res: Respons
|
||||
if (!requirePaid(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
const relPath = getRelPath(req);
|
||||
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
|
||||
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
|
||||
if (!isValidRelativeStackPath(relPath)) {
|
||||
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('download start', { stackName, relPath, nodeId: req.nodeId });
|
||||
try {
|
||||
const result = await FileSystemService.getInstance(req.nodeId).streamStackFile(stackName, relPath);
|
||||
res.setHeader('Content-Type', result.mime);
|
||||
@@ -890,14 +951,16 @@ stacksRouter.get('/:stackName/files/download', async (req: Request, res: Respons
|
||||
const safeFilename = result.filename.replace(/[\\"]/g, '');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`);
|
||||
result.stream.on('error', (streamErr) => {
|
||||
console.error('[files] stream error:', streamErr);
|
||||
console.error('[files] stream error:', sanitizeForLog(getErrorMessage(streamErr, 'unknown')));
|
||||
if (!res.headersSent) res.status(500).end();
|
||||
else res.destroy();
|
||||
});
|
||||
req.on('close', () => result.stream.destroy());
|
||||
logFileDiag('download stream opened', { stackName, relPath, nodeId: req.nodeId, size: result.size, elapsedMs: Date.now() - startedAt });
|
||||
result.stream.pipe(res);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
logFileOperation('warn', 'download failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
||||
return sendFsError(res, err, 'Failed to download file');
|
||||
}
|
||||
});
|
||||
@@ -924,15 +987,20 @@ stacksRouter.post(
|
||||
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
|
||||
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
|
||||
}
|
||||
const safeName = path.basename(req.file.originalname);
|
||||
if (!safeName || safeName === '.' || safeName === '..') {
|
||||
const originalName = req.file.originalname;
|
||||
if (!isSafeUploadFilename(originalName)) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
const targetRelPath = relPath ? `${relPath}/${safeName}` : safeName;
|
||||
const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName;
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('upload start', { stackName, relPath: targetRelPath, nodeId: req.nodeId, size: req.file.size });
|
||||
try {
|
||||
await FileSystemService.getInstance(req.nodeId).writeStackFileBuffer(stackName, targetRelPath, req.file.buffer);
|
||||
logFileOperation('info', 'upload complete', { nodeId: req.nodeId, size: req.file.size });
|
||||
logFileDiag('upload timing', { stackName, relPath: targetRelPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
||||
return res.status(204).send();
|
||||
} catch (err: unknown) {
|
||||
logFileOperation('warn', 'upload failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
||||
return sendFsError(res, err, 'Failed to upload file', { notFoundMessage: 'Target directory not found' });
|
||||
}
|
||||
},
|
||||
@@ -943,17 +1011,23 @@ stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
const relPath = getRelPath(req);
|
||||
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
|
||||
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
|
||||
if (!isValidRelativeStackPath(relPath)) {
|
||||
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
|
||||
}
|
||||
const { content } = req.body as { content?: unknown };
|
||||
if (typeof content !== 'string') {
|
||||
return res.status(400).json({ error: '"content" must be a string' });
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8') });
|
||||
try {
|
||||
await FileSystemService.getInstance(req.nodeId).writeStackFile(stackName, relPath, content);
|
||||
logFileOperation('info', 'write complete', { nodeId: req.nodeId });
|
||||
logFileDiag('write timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
||||
return res.status(204).send();
|
||||
} catch (err: unknown) {
|
||||
logFileOperation('warn', 'write failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
||||
return sendFsError(res, err, 'Failed to write file');
|
||||
}
|
||||
});
|
||||
@@ -968,10 +1042,15 @@ stacksRouter.delete('/:stackName/files', async (req: Request, res: Response) =>
|
||||
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
|
||||
}
|
||||
const recursive = req.query.recursive === '1';
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('delete start', { stackName, relPath, recursive, nodeId: req.nodeId });
|
||||
try {
|
||||
await FileSystemService.getInstance(req.nodeId).deleteStackPath(stackName, relPath, recursive);
|
||||
logFileOperation('info', 'delete complete', { nodeId: req.nodeId, recursive });
|
||||
logFileDiag('delete timing', { stackName, relPath, recursive, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
||||
return res.status(204).send();
|
||||
} catch (err: unknown) {
|
||||
logFileOperation('warn', 'delete failed', { nodeId: req.nodeId, recursive, errorCode: fsErrorCode(err) });
|
||||
return sendFsError(res, err, 'Failed to delete path');
|
||||
}
|
||||
});
|
||||
@@ -985,10 +1064,15 @@ stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response
|
||||
if (!isValidRelativeStackPath(relPath)) {
|
||||
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('mkdir start', { stackName, relPath, nodeId: req.nodeId });
|
||||
try {
|
||||
await FileSystemService.getInstance(req.nodeId).mkdirStackPath(stackName, relPath);
|
||||
logFileOperation('info', 'mkdir complete', { nodeId: req.nodeId });
|
||||
logFileDiag('mkdir timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
||||
return res.status(204).send();
|
||||
} catch (err: unknown) {
|
||||
logFileOperation('warn', 'mkdir failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
||||
return sendFsError(res, err, 'Failed to create folder');
|
||||
}
|
||||
});
|
||||
@@ -1010,10 +1094,15 @@ stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Respons
|
||||
if (!isValidRelativeStackPath(to)) {
|
||||
return res.status(400).json({ error: 'Invalid destination path', code: 'INVALID_PATH' });
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('rename start', { stackName, from, to, nodeId: req.nodeId });
|
||||
try {
|
||||
await FileSystemService.getInstance(req.nodeId).renameStackPath(stackName, from, to);
|
||||
logFileOperation('info', 'rename complete', { nodeId: req.nodeId });
|
||||
logFileDiag('rename timing', { stackName, from, to, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
||||
return res.status(204).send();
|
||||
} catch (err: unknown) {
|
||||
logFileOperation('warn', 'rename failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
||||
return sendFsError(res, err, 'Failed to rename');
|
||||
}
|
||||
});
|
||||
@@ -1025,10 +1114,14 @@ stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Resp
|
||||
if (!isValidRelativeStackPath(relPath)) {
|
||||
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('permissions read start', { stackName, relPath, nodeId: req.nodeId });
|
||||
try {
|
||||
const result = await FileSystemService.getInstance(req.nodeId).getStackEntryMode(stackName, relPath);
|
||||
logFileDiag('permissions read complete', { stackName, relPath, nodeId: req.nodeId, mode: result.octal, elapsedMs: Date.now() - startedAt });
|
||||
return res.json(result);
|
||||
} catch (err: unknown) {
|
||||
logFileOperation('warn', 'permissions read failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
||||
return sendFsError(res, err, 'Failed to read permissions');
|
||||
}
|
||||
});
|
||||
@@ -1046,10 +1139,15 @@ stacksRouter.put('/:stackName/files/permissions', async (req: Request, res: Resp
|
||||
if (typeof mode !== 'number') {
|
||||
return res.status(400).json({ error: '"mode" must be a number' });
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('chmod start', { stackName, relPath, nodeId: req.nodeId, mode });
|
||||
try {
|
||||
await FileSystemService.getInstance(req.nodeId).chmodStackPath(stackName, relPath, mode);
|
||||
logFileOperation('info', 'chmod complete', { nodeId: req.nodeId, mode });
|
||||
logFileDiag('chmod timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
||||
return res.status(204).send();
|
||||
} catch (err: unknown) {
|
||||
logFileOperation('warn', 'chmod failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
||||
return sendFsError(res, err, 'Failed to set permissions');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ Click **Save** to write the file to disk. Navigating away from the file before s
|
||||
|
||||
The toolbar **New folder** button at the top of the tree creates a folder in the currently selected directory (the parent of the file you have open, or the stack root if nothing is open). The button is hidden on Community.
|
||||
|
||||
Right-click any folder for **New File** and **New Folder** entries that scope to the right-clicked folder.
|
||||
Right-click any folder for **New File** and **New Folder** entries that scope to the right-clicked folder. These write controls appear only when your account has stack edit permission and the active tier is Skipper or Admiral.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-file-explorer/new-file-dialog.png" alt="New file modal scoped to the nginx folder, with the file name field populated and a Create button" />
|
||||
@@ -111,7 +111,7 @@ The dashed **Upload file** affordance at the top of the tree opens a file picker
|
||||
| Target directory | The currently selected directory, or the stack root if no file is open. |
|
||||
| Same-name files | Overwritten without prompt. |
|
||||
|
||||
On Community the upload affordance is hidden entirely.
|
||||
On Community, and for users without stack edit permission, the upload affordance is hidden entirely.
|
||||
|
||||
<Tip>
|
||||
For bulk transfers or files above 25 MB, use `scp` or `rsync` from your workstation directly to the stack directory on the host.
|
||||
@@ -125,7 +125,7 @@ When a file is selected on Skipper+, the right pane action bar shows **Download*
|
||||
|
||||
Right-click any file or folder and choose **Rename**. The dialog accepts a new name following the same rules as creation.
|
||||
|
||||
The rename is in-place; cross-directory moves are not supported. To move an entry between directories, copy it via the host shell or upload to the new location and delete the original.
|
||||
Rename appears only when your account has stack edit permission and the active tier is Skipper or Admiral. The rename is in-place; cross-directory moves are not supported. To move an entry between directories, copy it via the host shell or upload to the new location and delete the original.
|
||||
|
||||
## Permissions (chmod)
|
||||
|
||||
@@ -143,7 +143,7 @@ On Community the dialog opens read-only: the toggles render the current state an
|
||||
|
||||
## Deleting (Skipper+)
|
||||
|
||||
There are three delete entry points. All three open the same confirmation modal.
|
||||
There are three delete entry points. All three require stack edit permission and a Skipper or Admiral tier, and all three open the same confirmation modal.
|
||||
|
||||
- **Toolbar delete.** With a file open in the viewer, click **Delete** in the right-pane action bar.
|
||||
- **Context-menu delete.** Right-click any file or folder in the tree and choose **Delete**.
|
||||
@@ -171,10 +171,10 @@ When the entry is one of the five protected names, the modal asks you to type th
|
||||
|
||||
| Right-click target | Skipper+ entries | Community admin entries |
|
||||
|---|---|---|
|
||||
| Folder | New File, New Folder, Rename, Delete | Rename, Delete |
|
||||
| File | Rename, Permissions, Delete | Rename, Permissions, Delete |
|
||||
| Folder | New File, New Folder, Rename, Delete | No write entries |
|
||||
| File | Rename, Permissions, Delete | Permissions |
|
||||
|
||||
On Community, the New File and New Folder entries on a folder are hidden. Rename and Delete are gated by the `stack:edit` permission, so admins still see them, but the underlying API rejects the call with a 403 toast on a non-paid tier. Use Skipper+ for any write action; the Permissions dialog opens for everyone but only Skipper+ can save changes.
|
||||
On Community, write actions are hidden in the file explorer. The Permissions dialog opens for everyone, but only Skipper and Admiral users can save changes.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -197,7 +197,7 @@ On Community, the New File and New Folder entries on a folder are hidden. Rename
|
||||
<Accordion title="The tree shows 'Showing 500 of N - refine in shell'">
|
||||
Each directory render is capped at 500 entries to keep the tree responsive. The first 500 entries alphabetically are shown. To work with the entries past the cap, drop into a host shell with `cd` into the stack directory.
|
||||
</Accordion>
|
||||
<Accordion title="Rename or Delete returned a 403">
|
||||
Rename and Delete in the file context menu are write operations that require Skipper+. The menu items appear for any user with the `stack:edit` permission so a Community admin can see them, but the API rejects the call. Upgrade to Skipper+ to enable these actions, or perform the rename or delete from a host shell.
|
||||
<Accordion title="Write controls are missing">
|
||||
Upload, create, rename, chmod save, and delete require stack edit permission and a Skipper or Admiral tier. Community users can browse, preview text files, and inspect permissions in read-only mode.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -50,6 +50,7 @@ interface FilePermissionsDialogProps {
|
||||
relPath: string;
|
||||
entryName: string;
|
||||
isPaid: boolean;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function FilePermissionsDialog({
|
||||
@@ -59,6 +60,7 @@ export function FilePermissionsDialog({
|
||||
relPath,
|
||||
entryName,
|
||||
isPaid,
|
||||
canEdit,
|
||||
}: FilePermissionsDialogProps) {
|
||||
const [mode, setMode] = useState<number>(0o644);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -101,6 +103,7 @@ export function FilePermissionsDialog({
|
||||
};
|
||||
|
||||
const octal = mode.toString(8).padStart(3, '0');
|
||||
const canModify = isPaid && canEdit;
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose} size="sm">
|
||||
@@ -138,15 +141,15 @@ export function FilePermissionsDialog({
|
||||
<button
|
||||
key={bit.label}
|
||||
type="button"
|
||||
disabled={!isPaid || saving}
|
||||
disabled={!canModify || saving}
|
||||
onClick={() => setMode((m) => toggleBit(m, totalShift))}
|
||||
className={cn(
|
||||
'mx-auto flex h-7 w-7 items-center justify-center rounded-md border text-xs font-mono transition-colors',
|
||||
checked
|
||||
? 'border-primary/60 bg-primary/10 text-primary'
|
||||
: 'border-border bg-muted/30 text-muted-foreground',
|
||||
isPaid && !saving && 'hover:border-primary/50 cursor-pointer',
|
||||
(!isPaid || saving) && 'opacity-50 cursor-not-allowed'
|
||||
canModify && !saving && 'hover:border-primary/50 cursor-pointer',
|
||||
(!canModify || saving) && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
aria-label={`${cat.label} ${bit.label} ${checked ? 'on' : 'off'}`}
|
||||
>
|
||||
@@ -169,11 +172,11 @@ export function FilePermissionsDialog({
|
||||
<ModalFooter
|
||||
secondary={
|
||||
<Button variant="outline" size="sm" onClick={() => handleClose(false)} disabled={saving}>
|
||||
{isPaid ? 'Cancel' : 'Close'}
|
||||
{canModify ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
}
|
||||
primary={
|
||||
isPaid ? (
|
||||
canModify ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleSave()}
|
||||
|
||||
@@ -35,6 +35,7 @@ export function FileTreeContextMenu({
|
||||
children,
|
||||
}: FileTreeContextMenuProps) {
|
||||
const isDir = entry.type === 'directory';
|
||||
const canWrite = canEdit && isPaid;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -42,7 +43,7 @@ export function FileTreeContextMenu({
|
||||
<ContextMenuContent className="min-w-[180px]">
|
||||
{isDir ? (
|
||||
<>
|
||||
{isPaid && (
|
||||
{canWrite && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onRequestNewFile(relPath)}
|
||||
@@ -59,13 +60,13 @@ export function FileTreeContextMenu({
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
{canWrite && (
|
||||
<ContextMenuItem onSelect={() => onRequestRename(relPath)}>
|
||||
<Pencil className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
<span>Rename</span>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canEdit && (
|
||||
{canWrite && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
@@ -80,7 +81,7 @@ export function FileTreeContextMenu({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{canEdit && (
|
||||
{canWrite && (
|
||||
<ContextMenuItem onSelect={() => onRequestRename(relPath)}>
|
||||
<Pencil className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
<span>Rename</span>
|
||||
@@ -90,7 +91,7 @@ export function FileTreeContextMenu({
|
||||
<Lock className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
<span>Permissions</span>
|
||||
</ContextMenuItem>
|
||||
{canEdit && (
|
||||
{canWrite && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
|
||||
@@ -9,18 +9,20 @@ const MAX_BYTES = 25 * 1024 * 1024; // 25 MB
|
||||
interface FileUploadDropzoneProps {
|
||||
stackName: string;
|
||||
currentDir: string;
|
||||
canEdit: boolean;
|
||||
onUploaded: () => void;
|
||||
}
|
||||
|
||||
export function FileUploadDropzone({
|
||||
stackName,
|
||||
currentDir,
|
||||
canEdit,
|
||||
onUploaded,
|
||||
}: FileUploadDropzoneProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!isPaid) return null;
|
||||
if (!isPaid || !canEdit) return null;
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
if (file.size > MAX_BYTES) {
|
||||
|
||||
@@ -150,10 +150,11 @@ export function StackFileExplorer({
|
||||
<FileUploadDropzone
|
||||
stackName={stackName}
|
||||
currentDir={currentDir}
|
||||
canEdit={canEdit}
|
||||
onUploaded={refresh}
|
||||
/>
|
||||
</div>
|
||||
{isPaid && (
|
||||
{isPaid && canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -207,16 +208,18 @@ export function StackFileExplorer({
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
data-testid="file-action-delete"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
|
||||
Delete
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
data-testid="file-action-delete"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
@@ -297,6 +300,7 @@ export function StackFileExplorer({
|
||||
relPath={permissionsRelPath}
|
||||
entryName={permissionsEntryName}
|
||||
isPaid={isPaid}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FileUploadDropzone } from '../FileUploadDropzone';
|
||||
|
||||
const licenseState = { isPaid: true };
|
||||
|
||||
vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => licenseState,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stackFilesApi', () => ({
|
||||
uploadStackFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
loading: vi.fn(() => 'loading-id'),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('FileUploadDropzone', () => {
|
||||
beforeEach(() => {
|
||||
licenseState.isPaid = true;
|
||||
});
|
||||
|
||||
it('renders upload control for paid users with stack edit permission', () => {
|
||||
render(
|
||||
<FileUploadDropzone
|
||||
stackName="app"
|
||||
currentDir=""
|
||||
canEdit
|
||||
onUploaded={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides upload control when the user cannot edit the stack', () => {
|
||||
render(
|
||||
<FileUploadDropzone
|
||||
stackName="app"
|
||||
currentDir=""
|
||||
canEdit={false}
|
||||
onUploaded={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides upload control on Community tier', () => {
|
||||
licenseState.isPaid = false;
|
||||
|
||||
render(
|
||||
<FileUploadDropzone
|
||||
stackName="app"
|
||||
currentDir=""
|
||||
canEdit
|
||||
onUploaded={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user