diff --git a/backend/src/__tests__/stack-files-routes.test.ts b/backend/src/__tests__/stack-files-routes.test.ts index be876d6d..292e865a 100644 --- a/backend/src/__tests__/stack-files-routes.test.ts +++ b/backend/src/__tests__/stack-files-routes.test.ts @@ -595,4 +595,39 @@ describe('permission gating', () => { .send({ mode: 0o644 }); expect(res.status).toBe(403); }); + + it('viewer with global stack:read can GET /files', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files`) + .set('Cookie', viewerCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('viewer with global stack:read can GET /files/content', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/content`) + .query({ path: 'compose.yaml' }) + .set('Cookie', viewerCookie); + expect(res.status).toBe(200); + expect(typeof res.body.content).toBe('string'); + }); + + it('viewer with global stack:read can GET /files/download', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/download`) + .query({ path: 'compose.yaml' }) + .set('Cookie', viewerCookie); + expect(res.status).toBe(200); + expect(res.headers['content-disposition']).toMatch(/attachment/); + }); + + it('viewer with global stack:read can GET /files/permissions', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/permissions`) + .query({ path: 'compose.yaml' }) + .set('Cookie', viewerCookie); + expect(res.status).toBe(200); + expect(typeof res.body.octal).toBe('string'); + }); }); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 40b7bbac..f3f7d5c5 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -1370,6 +1370,7 @@ function isSafeUploadFilename(rawName: string): boolean { stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; const relPath = getRelPath(req); if (relPath !== '' && !isValidRelativeStackPath(relPath)) { return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); @@ -1388,6 +1389,7 @@ stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => { stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; const relPath = getRelPath(req); if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' }); if (!isValidRelativeStackPath(relPath)) { @@ -1415,6 +1417,7 @@ stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response stacksRouter.get('/:stackName/files/download', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; const relPath = getRelPath(req); if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' }); if (!isValidRelativeStackPath(relPath)) { @@ -1583,6 +1586,7 @@ stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Respons stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; const relPath = getRelPath(req); if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' }); if (!isValidRelativeStackPath(relPath)) { diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index e3f6ddde..b99aafec 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -319,6 +319,13 @@ export function EditorView({ const safeContent = content || ''; const safeEnvContent = envContent || ''; const isRunning = safeContainers.some(c => c.State === 'running'); + const canRead = can('stack:read', 'stack', stackName); + + useEffect(() => { + if (activeTab === 'files' && !canRead) { + setActiveTab('compose'); + } + }, [activeTab, canRead, setActiveTab]); return ( @@ -712,12 +719,14 @@ export function EditorView({ .env - - - - Files - - + {canRead && ( + + + + Files + + + )} @@ -801,7 +810,7 @@ export function EditorView({
- {activeTab === 'files' ? ( + {activeTab === 'files' && canRead ? ( { setEditingCompose(true); setActiveTab('compose'); }} - onOpenFiles={() => { setEditingCompose(true); setActiveTab('files'); }} + onOpenFiles={canRead ? () => { setEditingCompose(true); setActiveTab('files'); } : undefined} onOpenGitSource={() => setGitSourceOpen(true)} onApplyUpdate={() => { void updateStack(); }} canEdit={can('stack:edit', 'stack', stackName)}