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:
Anso
2026-05-12 15:49:51 -04:00
committed by GitHub
parent 19cdb3681d
commit 69b6ac1f3b
8 changed files with 327 additions and 39 deletions
@@ -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);
});
});
+106 -8
View File
@@ -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');
}
});
+9 -9
View File
@@ -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();
});
});