From 801a098a5b550dbc8089117b288a0834fcfe6e95 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 26 Apr 2026 13:05:19 -0400 Subject: [PATCH] feat(files): per-stack file explorer (#780) * feat(files): backend foundation for stack file explorer Install multer for multipart file upload handling. Add isValidRelativeStackPath to validation.ts to guard client-supplied relative paths against traversal, absolute paths, NUL bytes, backslash injection, and double-slash segments. Add isBinaryBuffer to a new binaryDetect.ts utility for heuristic text/binary detection via NUL-byte fast exit and non-printable byte ratio sampling. * fix(files): reject bare dot segments in isValidRelativeStackPath * feat(files): add safe stack-scoped file I/O methods to FileSystemService Adds FileEntry interface and seven new public methods to FileSystemService for stack-scoped file operations: listStackDirectory, readStackFile, streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and statStackEntry. Each method routes through a private resolveSafeStackPath helper that enforces two-phase path containment: a pre-realpath lexical check plus a post-realpath symlink-escape check. ENOENT targets are handled by walking up to the deepest existing ancestor, realpaths that ancestor, and reattaching the remaining suffix. Binary detection delegates to isBinaryBuffer; path safety delegates to isPathWithinBase. Protected file names and the MIME map are module-level constants to avoid repeated allocation. * feat(files): frontend API wrappers and Monaco language helper * fix(files): tighten stackFilesApi error handling and localOnly support * fix(files): FileSystemService safety and correctness fixes * feat(files): add file explorer API endpoints to stacks router * feat(files): FileTree and FileTreeNode components * fix(files): route security hardening and stream cleanup * fix(files): FileTree accessibility, icon stroke, stale fetch guard Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the design system. Add aria-expanded to directory rows for accessibility. Guard handleDirClick .then() callbacks against stale stack name references when the component re-renders with a new stack. Add toast.info fallbacks when compose.yaml or .env is clicked without a navigation callback registered. * feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm * fix(files): resolve code quality findings in file explorer components - Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix) - Fix blob download: append anchor to DOM before click, defer URL revoke 100ms - Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm - Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone - Add success toast on folder creation in NewFolderDialog - Switch all (e as Error).message casts to instanceof Error narrowing * test(files): unit tests for binary detection, stack path safety, and file explorer routes - binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL, PNG header, threshold boundary, sampleBytes parameter) - filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/ rejects matrix) and FileSystemService stack methods against a real temp dir (listStackDirectory sort and protection flags, readStackFile text/binary/ oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath, traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows - stack-files-routes.test.ts: route-level integration tests for all seven file explorer endpoints; covers auth gating, Community-tier 403 gates, input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths * feat(files): StackFileExplorer container with lazy tree, viewer, and action bar * fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change * test(files): add missing test coverage for file explorer routes and service * feat(files): add Files tab to EditorLayout with StackFileExplorer integration * fix(files): add defensive activeTab guard to saveFile and discardChanges * test(files): unit tests for FileTree expand/collapse and FileViewer render modes Covers the three FileViewer content modes (text/Monaco, binary panel, oversized panel) and the FileTree expand/collapse/cache cycle: first expand fetches the subdirectory, second click collapses without a fetch, third click re-expands from the in-memory cache without a second fetch. * test(e2e): file explorer community and skipper+ flows Covers the full file-explorer feature surface in two describe blocks: Community (read-only): intercepts /api/license to simulate community tier, confirms the upgrade pill is visible in the left pane, and asserts that the Save button is absent after opening a text file. Skipper+ (full CRUD): uploads a text file and confirms it appears in the tree; edits config/app.conf and saves via Monaco; deletes an uploaded file and asserts the tree entry is gone; issues a raw HTTP request to the download endpoint and checks for status 200 and the content-disposition: attachment header. Also adds data-testid="file-action-delete" to the action bar Delete button in StackFileExplorer for stable targeting, and exports waitForStacksLoaded from e2e/helpers.ts to eliminate the three identical local copies in stacks, deploy-log-panel, and stack-files spec files. * fix(e2e): improve test isolation and selector stability in stack-files spec Move beforeEach seed to beforeAll/afterAll so fixtures are created once per suite, not before every test. Extract shared seedSuite/teardownSuite helpers to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in try/catch so failures log a warning rather than masking test results. Replace waitForTimeout(500) with a deterministic expect on the file tree sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn" to replace the fragile button text/positional selectors. Assert Save button starts disabled before editing. * docs(files): add stack file explorer documentation Add user-facing guide for the stack file explorer feature covering tier access (Community read-only, Skipper+ read-write), viewing limits, upload/download caps, protected file routing, and troubleshooting. Update the editor page to reference the new guide and register the page in the navigation. * fix(docs): use canonical Skipper tier name in file explorer overview card * fix(files): resolve lint errors blocking CI Remove unnecessary backslash escape before double-quote in the Content-Disposition regex (no-useless-escape). Replace five synchronous setState resets at the top of the FileTree mount effect with a React key prop on the FileTree element in StackFileExplorer so remounting resets state automatically, eliminating the react-hooks/set-state-in-effect violation. * test(files): fix e2e seeding to work on community-tier CI Replace the browser-side paid upload/mkdir API calls in seedTestStack with direct Node fs writes. The upload and folder endpoints require Skipper+ so they returned 403 on CI, which runs with no license set. Stack creation via POST /api/stacks stays as an API call since it is community-allowed and keeps the backend registry in sync. Add a per-test tier check in the Skipper+ beforeEach that skips gracefully when the instance is community, matching the pattern in auto-heal-policies. --- backend/package-lock.json | 126 +++++ backend/package.json | 2 + .../src/__tests__/binary-detection.test.ts | 64 +++ .../__tests__/filesystem-stack-paths.test.ts | 332 +++++++++++++ .../src/__tests__/stack-files-routes.test.ts | 447 ++++++++++++++++++ backend/src/routes/stacks.ts | 196 +++++++- backend/src/services/FileSystemService.ts | 243 +++++++++- backend/src/utils/binaryDetect.ts | 21 + backend/src/utils/validation.ts | 15 + docs/docs.json | 1 + docs/features/editor.mdx | 6 + docs/features/stack-file-explorer.mdx | 104 ++++ e2e/deploy-log-panel.spec.ts | 7 +- e2e/helpers.ts | 10 + e2e/stack-files.spec.ts | 351 ++++++++++++++ e2e/stacks.spec.ts | 10 +- frontend/src/components/EditorLayout.tsx | 107 +++-- frontend/src/components/StackAnatomyPanel.tsx | 37 +- .../components/files/DeleteFileConfirm.tsx | 145 ++++++ frontend/src/components/files/FileTree.tsx | 202 ++++++++ .../src/components/files/FileTreeNode.tsx | 60 +++ .../components/files/FileUploadDropzone.tsx | 85 ++++ frontend/src/components/files/FileViewer.tsx | 292 ++++++++++++ .../src/components/files/NewFolderDialog.tsx | 131 +++++ .../components/files/StackFileExplorer.tsx | 182 +++++++ .../files/__tests__/FileTree.test.tsx | 170 +++++++ .../files/__tests__/FileViewer.test.tsx | 180 +++++++ frontend/src/lib/monacoLanguages.ts | 56 +++ frontend/src/lib/stackFilesApi.ts | 134 ++++++ 29 files changed, 3645 insertions(+), 71 deletions(-) create mode 100644 backend/src/__tests__/binary-detection.test.ts create mode 100644 backend/src/__tests__/filesystem-stack-paths.test.ts create mode 100644 backend/src/__tests__/stack-files-routes.test.ts create mode 100644 backend/src/utils/binaryDetect.ts create mode 100644 docs/features/stack-file-explorer.mdx create mode 100644 e2e/stack-files.spec.ts create mode 100644 frontend/src/components/files/DeleteFileConfirm.tsx create mode 100644 frontend/src/components/files/FileTree.tsx create mode 100644 frontend/src/components/files/FileTreeNode.tsx create mode 100644 frontend/src/components/files/FileUploadDropzone.tsx create mode 100644 frontend/src/components/files/FileViewer.tsx create mode 100644 frontend/src/components/files/NewFolderDialog.tsx create mode 100644 frontend/src/components/files/StackFileExplorer.tsx create mode 100644 frontend/src/components/files/__tests__/FileTree.test.tsx create mode 100644 frontend/src/components/files/__tests__/FileViewer.test.tsx create mode 100644 frontend/src/lib/monacoLanguages.ts create mode 100644 frontend/src/lib/stackFilesApi.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 5fa1bfca..07641106 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,6 +35,7 @@ "isomorphic-git": "^1.37.5", "jsonwebtoken": "^9.0.3", "ldapts": "^8.1.7", + "multer": "^2.1.1", "node-pty": "^1.1.0", "openid-client": "^6.8.2", "otplib": "^12.0.1", @@ -51,6 +52,7 @@ "@types/cookie-parser": "^1.4.10", "@types/http-proxy-middleware": "^1.0.0", "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.1.0", "@types/node": "^25.3.0", "@types/supertest": "^7.2.0", "@types/yaml": "^1.9.6", @@ -2219,6 +2221,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -2800,6 +2812,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -3056,6 +3074,12 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/buildcheck": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", @@ -3065,6 +3089,17 @@ "node": ">=10.0.0" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3323,6 +3358,21 @@ "node": ">= 0.6" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -5505,6 +5555,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nan": { "version": "2.26.2", "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", @@ -6534,6 +6646,14 @@ "dev": true, "license": "MIT" }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/strict-event-emitter-types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", @@ -6949,6 +7069,12 @@ "node": ">= 0.4" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index ea923c0b..2ddfea26 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "@types/cookie-parser": "^1.4.10", "@types/http-proxy-middleware": "^1.0.0", "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.1.0", "@types/node": "^25.3.0", "@types/supertest": "^7.2.0", "@types/yaml": "^1.9.6", @@ -62,6 +63,7 @@ "isomorphic-git": "^1.37.5", "jsonwebtoken": "^9.0.3", "ldapts": "^8.1.7", + "multer": "^2.1.1", "node-pty": "^1.1.0", "openid-client": "^6.8.2", "otplib": "^12.0.1", diff --git a/backend/src/__tests__/binary-detection.test.ts b/backend/src/__tests__/binary-detection.test.ts new file mode 100644 index 00000000..f1ebef51 --- /dev/null +++ b/backend/src/__tests__/binary-detection.test.ts @@ -0,0 +1,64 @@ +/** + * Unit tests for isBinaryBuffer from utils/binaryDetect.ts. + * + * The function uses byte-range sampling to classify buffers: + * - Empty buffers are text (returns false) + * - Any NUL byte triggers immediate binary detection + * - More than 30% non-printable bytes in the sample triggers binary detection + * - The sampleBytes parameter limits how many bytes are examined + */ +import { describe, it, expect } from 'vitest'; +import { isBinaryBuffer } from '../utils/binaryDetect'; + +describe('isBinaryBuffer', () => { + it('returns false for empty buffer', () => { + expect(isBinaryBuffer(Buffer.alloc(0))).toBe(false); + }); + + it('returns false for plain ASCII text', () => { + const buf = Buffer.from('hello world\nfoo: bar\n'); + expect(isBinaryBuffer(buf)).toBe(false); + }); + + it('returns false for YAML content', () => { + const yaml = Buffer.from('services:\n app:\n image: nginx:latest\n'); + expect(isBinaryBuffer(yaml)).toBe(false); + }); + + it('returns true when NUL byte present', () => { + const buf = Buffer.from([0x68, 0x65, 0x6c, 0x00, 0x6c, 0x6f]); + expect(isBinaryBuffer(buf)).toBe(true); + }); + + it('returns true for PNG header (binary)', () => { + // PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A + const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(100, 0xff)]); + expect(isBinaryBuffer(png)).toBe(true); + }); + + it('returns true when >30% bytes are non-printable', () => { + // 101 bytes: 70 printable 'a' (0x61) + 31 non-printable (0x01) + // ratio = 31/101 > 0.30 + const buf = Buffer.concat([Buffer.alloc(31, 0x01), Buffer.alloc(70, 0x61)]); + expect(isBinaryBuffer(buf)).toBe(true); + }); + + it('returns false when exactly 30% are non-printable', () => { + // 100 bytes: 70 printable + 30 non-printable (0x01, not NUL) + // ratio = 30/100 = 0.30, which is NOT > 0.30, so should return false + const buf = Buffer.concat([Buffer.alloc(30, 0x01), Buffer.alloc(70, 0x61)]); + expect(isBinaryBuffer(buf)).toBe(false); + }); + + it('respects sampleBytes=0 (empty sample returns false)', () => { + // sampleBytes=0 means subarray(0,0) which is empty, so no bytes to check + const buf = Buffer.from([0x00, 0x61, 0x61]); + expect(isBinaryBuffer(buf, 0)).toBe(false); + }); + + it('only samples the first N bytes', () => { + // First byte is printable 'a', second is NUL — only sample 1 byte, so no NUL detected + const buf = Buffer.from([0x61, 0x00, 0x61]); + expect(isBinaryBuffer(buf, 1)).toBe(false); + }); +}); diff --git a/backend/src/__tests__/filesystem-stack-paths.test.ts b/backend/src/__tests__/filesystem-stack-paths.test.ts new file mode 100644 index 00000000..f0be0443 --- /dev/null +++ b/backend/src/__tests__/filesystem-stack-paths.test.ts @@ -0,0 +1,332 @@ +/** + * Tests for isValidRelativeStackPath (pure function) and the stack-scoped + * file methods on FileSystemService (listStackDirectory, readStackFile, + * writeStackFile, writeStackFileBuffer, deleteStackPath, mkdirStackPath). + * + * FileSystemService stack methods are tested against a real temp directory so + * that realpath, stat, and fs I/O all run with actual OS semantics. + * NodeRegistry is mocked to redirect the composeDir to our temp location. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { isValidRelativeStackPath } from '../utils/validation'; + +// On Windows, fs.unlink on a directory returns EPERM rather than EISDIR. +// The deleteStackPath empty-dir and NOT_EMPTY paths rely on EISDIR (Linux/macOS). +// Skip those specific cases on Windows. +const isWindows = process.platform === 'win32'; + +// Mutable state the mocked NodeRegistry reads. Each beforeEach updates it +// before any FileSystemService method runs. +const mockState = { composeDir: '' }; + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getComposeDir: () => mockState.composeDir, + getDefaultNodeId: () => 1, + }), + }, +})); + +vi.mock('../utils/debug', () => ({ isDebugEnabled: () => false })); + +import { FileSystemService } from '../services/FileSystemService'; + +// ── isValidRelativeStackPath ────────────────────────────────────────────────── + +describe('isValidRelativeStackPath', () => { + // Accepted inputs + it('accepts empty string (stack root)', () => expect(isValidRelativeStackPath('')).toBe(true)); + it('accepts simple filename', () => expect(isValidRelativeStackPath('compose.yaml')).toBe(true)); + it('accepts dotfile', () => expect(isValidRelativeStackPath('.env')).toBe(true)); + it('accepts nested path', () => expect(isValidRelativeStackPath('config/app.conf')).toBe(true)); + it('accepts deeply nested path', () => expect(isValidRelativeStackPath('a/b/c/d.txt')).toBe(true)); + + // Rejected inputs + it('rejects ..', () => expect(isValidRelativeStackPath('..')).toBe(false)); + it('rejects ../etc/passwd traversal', () => expect(isValidRelativeStackPath('../etc/passwd')).toBe(false)); + it('rejects a/../b', () => expect(isValidRelativeStackPath('a/../b')).toBe(false)); + it('rejects absolute path', () => expect(isValidRelativeStackPath('/etc/passwd')).toBe(false)); + it('rejects Windows drive path', () => expect(isValidRelativeStackPath('C:/windows')).toBe(false)); + it('rejects NUL byte', () => expect(isValidRelativeStackPath('file\x00name')).toBe(false)); + it('rejects backslash', () => expect(isValidRelativeStackPath('path\\file')).toBe(false)); + it('rejects double-slash', () => expect(isValidRelativeStackPath('a//b')).toBe(false)); + it('rejects bare dot segment', () => expect(isValidRelativeStackPath('a/./b')).toBe(false)); +}); + +// ── FileSystemService stack methods ────────────────────────────────────────── + +describe('FileSystemService stack methods', () => { + const STACK = 'mystack'; + let tmpBase: string; + let stackDir: string; + + beforeEach(async () => { + tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-fsp-')); + stackDir = path.join(tmpBase, STACK); + mockState.composeDir = tmpBase; + await fs.mkdir(stackDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tmpBase, { recursive: true, force: true }); + }); + + // ── listStackDirectory ────────────────────────────────────────────────── + + describe('listStackDirectory', () => { + it('returns entries with directories sorted before files', async () => { + await fs.mkdir(path.join(stackDir, 'config')); + await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n'); + await fs.writeFile(path.join(stackDir, '.env'), 'KEY=val\n'); + + const service = FileSystemService.getInstance(); + const entries = await service.listStackDirectory(STACK, ''); + + // Directories come first + expect(entries[0].type).toBe('directory'); + expect(entries[0].name).toBe('config'); + + // Files follow, sorted alphabetically (case-insensitive) + const fileNames = entries.filter(e => e.type === 'file').map(e => e.name); + expect(fileNames).toEqual(['.env', 'compose.yaml']); + }); + + it('marks compose.yaml and .env as protected', async () => { + await fs.writeFile(path.join(stackDir, 'compose.yaml'), ''); + await fs.writeFile(path.join(stackDir, '.env'), ''); + await fs.writeFile(path.join(stackDir, 'custom.conf'), ''); + + const service = FileSystemService.getInstance(); + const entries = await service.listStackDirectory(STACK, ''); + + const byName = Object.fromEntries(entries.map(e => [e.name, e])); + expect(byName['compose.yaml'].isProtected).toBe(true); + expect(byName['.env'].isProtected).toBe(true); + expect(byName['custom.conf'].isProtected).toBe(false); + }); + + it('includes size and mtime for files', async () => { + await fs.writeFile(path.join(stackDir, 'test.txt'), 'hello'); + + const service = FileSystemService.getInstance(); + const entries = await service.listStackDirectory(STACK, ''); + + const file = entries.find(e => e.name === 'test.txt'); + expect(file).toBeDefined(); + expect(file!.size).toBe(5); + expect(file!.mtime).toBeGreaterThan(0); + }); + + it('returns empty array for an empty stack directory', async () => { + const service = FileSystemService.getInstance(); + const entries = await service.listStackDirectory(STACK, ''); + expect(entries).toEqual([]); + }); + + it('lists a subdirectory when relPath is provided', async () => { + await fs.mkdir(path.join(stackDir, 'sub')); + await fs.writeFile(path.join(stackDir, 'sub', 'child.txt'), 'data'); + + const service = FileSystemService.getInstance(); + const entries = await service.listStackDirectory(STACK, 'sub'); + expect(entries.length).toBe(1); + expect(entries[0].name).toBe('child.txt'); + }); + }); + + // ── readStackFile ─────────────────────────────────────────────────────── + + describe('readStackFile', () => { + it('returns text content for a UTF-8 file', async () => { + await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n'); + + const service = FileSystemService.getInstance(); + const result = await service.readStackFile(STACK, 'compose.yaml'); + expect(result.binary).toBe(false); + expect(result.oversized).toBe(false); + expect(result.content).toBe('services: {}\n'); + }); + + it('returns binary:true and no content for a binary file', async () => { + // PNG magic bytes followed by non-printable data + const pngMagic = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(50, 0xff), + ]); + await fs.writeFile(path.join(stackDir, 'icon.png'), pngMagic); + + const service = FileSystemService.getInstance(); + const result = await service.readStackFile(STACK, 'icon.png'); + expect(result.binary).toBe(true); + expect(result.oversized).toBe(false); + expect(result.content).toBeUndefined(); + }); + + it('returns oversized:true for files exceeding maxBytes', async () => { + // Write a text file larger than our small maxBytes limit + await fs.writeFile(path.join(stackDir, 'big.txt'), 'a'.repeat(200)); + + const service = FileSystemService.getInstance(); + // maxBytes=100 forces the oversized path + const result = await service.readStackFile(STACK, 'big.txt', 100); + expect(result.oversized).toBe(true); + expect(result.size).toBe(200); + }); + + it('throws IS_DIRECTORY when path points to a directory', async () => { + await fs.mkdir(path.join(stackDir, 'subdir')); + + const service = FileSystemService.getInstance(); + await expect(service.readStackFile(STACK, 'subdir')).rejects.toMatchObject({ code: 'IS_DIRECTORY' }); + }); + }); + + // ── writeStackFile ────────────────────────────────────────────────────── + + describe('writeStackFile', () => { + it('creates a new file with the given content', async () => { + const service = FileSystemService.getInstance(); + await service.writeStackFile(STACK, 'new.txt', 'hello world'); + + const content = await fs.readFile(path.join(stackDir, 'new.txt'), 'utf-8'); + expect(content).toBe('hello world'); + }); + + it('overwrites an existing file', async () => { + await fs.writeFile(path.join(stackDir, 'data.txt'), 'original'); + + const service = FileSystemService.getInstance(); + await service.writeStackFile(STACK, 'data.txt', 'updated'); + + const content = await fs.readFile(path.join(stackDir, 'data.txt'), 'utf-8'); + expect(content).toBe('updated'); + }); + + it('creates parent directories if they do not exist', async () => { + const service = FileSystemService.getInstance(); + await service.writeStackFile(STACK, 'deep/nested/file.txt', 'content'); + + const content = await fs.readFile(path.join(stackDir, 'deep', 'nested', 'file.txt'), 'utf-8'); + expect(content).toBe('content'); + }); + }); + + // ── writeStackFileBuffer ──────────────────────────────────────────────── + + describe('writeStackFileBuffer', () => { + it('writes raw bytes correctly', async () => { + const data = Buffer.from([0x01, 0x02, 0x03, 0xff]); + const service = FileSystemService.getInstance(); + await service.writeStackFileBuffer(STACK, 'binary.bin', data); + + const read = await fs.readFile(path.join(stackDir, 'binary.bin')); + expect(read).toEqual(data); + }); + + it('creates parent directories when needed', async () => { + const payload = Buffer.from([0xde, 0xad]); + const service = FileSystemService.getInstance(); + await service.writeStackFileBuffer(STACK, 'sub/img.bin', payload); + + const read = await fs.readFile(path.join(stackDir, 'sub', 'img.bin')); + expect(read).toEqual(payload); + }); + }); + + // ── deleteStackPath ───────────────────────────────────────────────────── + + describe('deleteStackPath', () => { + it('deletes a file', async () => { + await fs.writeFile(path.join(stackDir, 'todelete.txt'), ''); + + const service = FileSystemService.getInstance(); + await service.deleteStackPath(STACK, 'todelete.txt'); + + await expect(fs.access(path.join(stackDir, 'todelete.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it.skipIf(isWindows)('deletes an empty directory (Linux/macOS only: Windows unlink returns EPERM)', async () => { + await fs.mkdir(path.join(stackDir, 'emptydir')); + + const service = FileSystemService.getInstance(); + await service.deleteStackPath(STACK, 'emptydir'); + + await expect(fs.access(path.join(stackDir, 'emptydir'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it.skipIf(isWindows)('throws NOT_EMPTY for non-empty directory without recursive flag (Linux/macOS only)', async () => { + await fs.mkdir(path.join(stackDir, 'nonempty')); + await fs.writeFile(path.join(stackDir, 'nonempty', 'child.txt'), ''); + + const service = FileSystemService.getInstance(); + await expect(service.deleteStackPath(STACK, 'nonempty', false)).rejects.toMatchObject({ code: 'NOT_EMPTY' }); + }); + + it('recursively deletes a non-empty directory when recursive=true', async () => { + await fs.mkdir(path.join(stackDir, 'tree')); + await fs.writeFile(path.join(stackDir, 'tree', 'child.txt'), ''); + + const service = FileSystemService.getInstance(); + await service.deleteStackPath(STACK, 'tree', true); + + await expect(fs.access(path.join(stackDir, 'tree'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); + + // ── mkdirStackPath ────────────────────────────────────────────────────── + + describe('mkdirStackPath', () => { + it('creates a new directory', async () => { + const service = FileSystemService.getInstance(); + await service.mkdirStackPath(STACK, 'newdir'); + + const stat = await fs.stat(path.join(stackDir, 'newdir')); + expect(stat.isDirectory()).toBe(true); + }); + + it('creates nested directories', async () => { + const service = FileSystemService.getInstance(); + await service.mkdirStackPath(STACK, 'a/b/c'); + + const stat = await fs.stat(path.join(stackDir, 'a', 'b', 'c')); + expect(stat.isDirectory()).toBe(true); + }); + + it('does not throw when directory already exists', async () => { + await fs.mkdir(path.join(stackDir, 'existing')); + + const service = FileSystemService.getInstance(); + await expect(service.mkdirStackPath(STACK, 'existing')).resolves.toBeUndefined(); + }); + }); + + // ── path traversal ────────────────────────────────────────────────────── + + describe('path traversal protection', () => { + it('throws INVALID_PATH when relPath escapes stack directory via ..', async () => { + // isValidRelativeStackPath rejects ".." before it reaches the service, + // but we also test the service-level guard with a stack name that would + // escape the compose dir (isPathWithinBase check in resolveSafeStackPath). + const service = FileSystemService.getInstance(); + await expect(service.listStackDirectory('..', '')).rejects.toMatchObject({ code: 'INVALID_PATH' }); + }); + + it('throws INVALID_PATH for a stack name with path separator', async () => { + const service = FileSystemService.getInstance(); + await expect(service.readStackFile('../other', 'file.txt')).rejects.toMatchObject({ code: 'INVALID_PATH' }); + }); + + it.skipIf(isWindows)('throws SYMLINK_ESCAPE when a symlink inside the stack points outside it (Linux/macOS only)', async () => { + const externalFile = path.join(tmpBase, 'secret.txt'); + await fs.writeFile(externalFile, 'secret'); + await fs.symlink(externalFile, path.join(stackDir, 'escape-link')); + + const service = FileSystemService.getInstance(); + await expect(service.readStackFile(STACK, 'escape-link')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + }); + }); +}); diff --git a/backend/src/__tests__/stack-files-routes.test.ts b/backend/src/__tests__/stack-files-routes.test.ts new file mode 100644 index 00000000..7fdfbee1 --- /dev/null +++ b/backend/src/__tests__/stack-files-routes.test.ts @@ -0,0 +1,447 @@ +/** + * Route-level tests for the stack file explorer endpoints: + * GET /:stackName/files + * GET /:stackName/files/content + * GET /:stackName/files/download (Skipper+) + * POST /:stackName/files/upload (Skipper+) + * PUT /:stackName/files/content (Skipper+) + * DELETE /:stackName/files (Skipper+) + * POST /:stackName/files/folder (Skipper+) + * + * Covers: auth gating, tier gating (Community vs paid), input validation, + * upload size limit, and happy-path 204/200 responses. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +// On Windows, fs.unlink on a directory returns EPERM instead of EISDIR so the +// NOT_EMPTY code path in deleteStackPath is never reached. Skip that test case +// on Windows; it is covered on Linux (CI). +const isWindows = process.platform === 'win32'; + +let tmpDir: string; +let app: import('express').Express; +let LicenseService: typeof import('../services/LicenseService').LicenseService; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let adminCookie: string; +let viewerCookie: string; +let stacksDir: string; +const STACK = 'teststack'; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + stacksDir = process.env.COMPOSE_DIR!; + + // Create stack directory so file operations have something to work with + await fs.mkdir(path.join(stacksDir, STACK), { recursive: true }); + await fs.writeFile(path.join(stacksDir, STACK, 'compose.yaml'), 'services: {}\n'); + await fs.writeFile(path.join(stacksDir, STACK, '.env'), 'KEY=val\n'); + + ({ LicenseService } = await import('../services/LicenseService')); + ({ DatabaseService } = await import('../services/DatabaseService')); + + // Default: paid tier so most tests pass the tier gate + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + const viewerHash = await bcrypt.hash('viewerpass', 1); + DatabaseService.getInstance().addUser({ username: 'files-viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app).post('/api/auth/login').send({ username: 'files-viewer', password: 'viewerpass' }); + const viewerCookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(viewerCookies) ? viewerCookies[0] : viewerCookies; +}); + +afterAll(async () => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + // Restore all spies then re-establish the paid-tier default so per-test + // overrides via mockReturnValueOnce don't accumulate across tests. + vi.restoreAllMocks(); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); +}); + +// ── GET /:stackName/files ───────────────────────────────────────────────────── + +describe('GET /api/stacks/:stackName/files', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).get(`/api/stacks/${STACK}/files`); + expect(res.status).toBe(401); + }); + + it('returns 200 with entries array for authenticated admin', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files`) + .set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('includes compose.yaml and .env in the listing', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files`) + .set('Cookie', adminCookie); + expect(res.status).toBe(200); + const names = res.body.map((e: { name: string }) => e.name); + expect(names).toContain('compose.yaml'); + expect(names).toContain('.env'); + }); + + it('returns 400 for an invalid stack name containing path traversal', async () => { + const res = await request(app) + .get('/api/stacks/../evil/files') + .set('Cookie', adminCookie); + // Express may normalise the URL before it reaches the handler; + // the important thing is we never get 200 + expect([400, 404]).toContain(res.status); + }); + + it('returns 400 for a stack name with special characters', async () => { + const res = await request(app) + .get('/api/stacks/my%20stack/files') + .set('Cookie', adminCookie); + expect(res.status).toBe(400); + }); +}); + +// ── GET /:stackName/files/content ───────────────────────────────────────────── + +describe('GET /api/stacks/:stackName/files/content', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/content`) + .query({ path: 'compose.yaml' }); + expect(res.status).toBe(401); + }); + + it('returns 400 INVALID_PATH when path query parameter is missing', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/content`) + .set('Cookie', adminCookie); + expect(res.status).toBe(400); + expect(res.body.code).toBe('INVALID_PATH'); + }); + + it('returns 200 with file content for an existing text file', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/content`) + .query({ path: 'compose.yaml' }) + .set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.binary).toBe(false); + expect(res.body.oversized).toBe(false); + expect(typeof res.body.content).toBe('string'); + }); + + it('returns 404 for a non-existent file', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/content`) + .query({ path: 'nonexistent.txt' }) + .set('Cookie', adminCookie); + expect(res.status).toBe(404); + }); + + it('returns oversized:true for a file larger than the 2 MB read limit', async () => { + const bigPath = path.join(stacksDir, STACK, 'oversized.txt'); + await fs.writeFile(bigPath, 'x'.repeat(3 * 1024 * 1024)); + + const res = await request(app) + .get(`/api/stacks/${STACK}/files/content`) + .query({ path: 'oversized.txt' }) + .set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.oversized).toBe(true); + expect(res.body.binary).toBe(false); + expect(res.body.content).toBeUndefined(); + + await fs.unlink(bigPath); + }, 15000); +}); + +// ── GET /:stackName/files/download ──────────────────────────────────────────── + +describe('GET /api/stacks/:stackName/files/download', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/download`) + .query({ path: 'compose.yaml' }); + expect(res.status).toBe(401); + }); + + it('returns 403 for Community tier', async () => { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + const res = await request(app) + .get(`/api/stacks/${STACK}/files/download`) + .query({ path: 'compose.yaml' }) + .set('Cookie', adminCookie); + expect(res.status).toBe(403); + }); + + it('streams the file for a paid tier user', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/download`) + .query({ path: 'compose.yaml' }) + .set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.headers['content-disposition']).toMatch(/attachment/); + expect(res.text).toContain('services'); + }); +}); + +// ── POST /:stackName/files/upload ───────────────────────────────────────────── + +describe('POST /api/stacks/:stackName/files/upload', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .attach('file', Buffer.from('data'), 'test.txt'); + expect(res.status).toBe(401); + }); + + it('returns 403 for Community tier', async () => { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .set('Cookie', adminCookie) + .attach('file', Buffer.from('data'), 'test.txt'); + expect(res.status).toBe(403); + }); + + it('returns 400 when no file is attached', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .set('Cookie', adminCookie); + expect(res.status).toBe(400); + }); + + it('returns 413 TOO_LARGE when file exceeds 25 MB', async () => { + // 26 MB buffer + const bigFile = Buffer.alloc(26 * 1024 * 1024, 0x61); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .set('Cookie', adminCookie) + .attach('file', bigFile, 'toobig.txt'); + expect(res.status).toBe(413); + expect(res.body.code).toBe('TOO_LARGE'); + }, 20000); + + it('returns 204 for a valid file upload (paid tier)', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .set('Cookie', adminCookie) + .attach('file', Buffer.from('uploaded content'), 'uploaded.txt'); + expect(res.status).toBe(204); + + // Verify the file was written + const content = await fs.readFile(path.join(stacksDir, STACK, 'uploaded.txt'), 'utf-8'); + expect(content).toBe('uploaded content'); + }); + + it('returns 204 and writes into a subdirectory when ?path= is provided', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .query({ path: 'subdir' }) + .set('Cookie', adminCookie) + .attach('file', Buffer.from('subdir content'), 'sub.txt'); + expect(res.status).toBe(204); + + const content = await fs.readFile(path.join(stacksDir, STACK, 'subdir', 'sub.txt'), 'utf-8'); + expect(content).toBe('subdir content'); + }); +}); + +// ── PUT /:stackName/files/content ───────────────────────────────────────────── + +describe('PUT /api/stacks/:stackName/files/content', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app) + .put(`/api/stacks/${STACK}/files/content`) + .query({ path: 'new.txt' }) + .send({ content: 'hello' }); + expect(res.status).toBe(401); + }); + + it('returns 403 for Community tier', async () => { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + const res = await request(app) + .put(`/api/stacks/${STACK}/files/content`) + .query({ path: 'new.txt' }) + .set('Cookie', adminCookie) + .send({ content: 'hello' }); + expect(res.status).toBe(403); + }); + + it('returns 400 when content is not a string', async () => { + const res = await request(app) + .put(`/api/stacks/${STACK}/files/content`) + .query({ path: 'new.txt' }) + .set('Cookie', adminCookie) + .send({ content: 42 }); + expect(res.status).toBe(400); + }); + + it('returns 204 and writes the file for a paid tier admin', async () => { + const res = await request(app) + .put(`/api/stacks/${STACK}/files/content`) + .query({ path: 'written.txt' }) + .set('Cookie', adminCookie) + .send({ content: 'written via PUT' }); + expect(res.status).toBe(204); + + const content = await fs.readFile(path.join(stacksDir, STACK, 'written.txt'), 'utf-8'); + expect(content).toBe('written via PUT'); + }); +}); + +// ── DELETE /:stackName/files ────────────────────────────────────────────────── + +describe('DELETE /api/stacks/:stackName/files', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app) + .delete(`/api/stacks/${STACK}/files`) + .query({ path: 'compose.yaml' }); + expect(res.status).toBe(401); + }); + + it('returns 403 for Community tier', async () => { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + const res = await request(app) + .delete(`/api/stacks/${STACK}/files`) + .query({ path: 'compose.yaml' }) + .set('Cookie', adminCookie); + expect(res.status).toBe(403); + }); + + it('returns 400 when path is missing', async () => { + const res = await request(app) + .delete(`/api/stacks/${STACK}/files`) + .set('Cookie', adminCookie); + expect(res.status).toBe(400); + }); + + it('returns 204 on successful file deletion', async () => { + // Create a disposable file first + await fs.writeFile(path.join(stacksDir, STACK, 'todelete.txt'), 'bye'); + + const res = await request(app) + .delete(`/api/stacks/${STACK}/files`) + .query({ path: 'todelete.txt' }) + .set('Cookie', adminCookie); + expect(res.status).toBe(204); + + await expect(fs.access(path.join(stacksDir, STACK, 'todelete.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it.skipIf(isWindows)('returns 409 NOT_EMPTY when deleting a non-empty directory without recursive flag (Linux/macOS only)', async () => { + const dirPath = path.join(stacksDir, STACK, 'nonemptydir'); + await fs.mkdir(dirPath, { recursive: true }); + await fs.writeFile(path.join(dirPath, 'child.txt'), ''); + + const res = await request(app) + .delete(`/api/stacks/${STACK}/files`) + .query({ path: 'nonemptydir' }) + .set('Cookie', adminCookie); + expect(res.status).toBe(409); + expect(res.body.code).toBe('NOT_EMPTY'); + }); + + it.skipIf(isWindows)('returns 204 and removes a non-empty directory when recursive=1 (Linux/macOS only)', async () => { + const dirPath = path.join(stacksDir, STACK, 'recursivedir'); + await fs.mkdir(dirPath, { recursive: true }); + await fs.writeFile(path.join(dirPath, 'child.txt'), 'data'); + + const res = await request(app) + .delete(`/api/stacks/${STACK}/files`) + .query({ path: 'recursivedir', recursive: '1' }) + .set('Cookie', adminCookie); + expect(res.status).toBe(204); + + await expect(fs.access(dirPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); + +// ── POST /:stackName/files/folder ───────────────────────────────────────────── + +describe('POST /api/stacks/:stackName/files/folder', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/folder`) + .query({ path: 'newdir' }); + expect(res.status).toBe(401); + }); + + it('returns 403 for Community tier', async () => { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/folder`) + .query({ path: 'newdir' }) + .set('Cookie', adminCookie); + expect(res.status).toBe(403); + }); + + it('returns 400 when path is missing', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/folder`) + .set('Cookie', adminCookie); + expect(res.status).toBe(400); + }); + + it('returns 204 and creates the directory for a paid tier admin', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/folder`) + .query({ path: 'mynewdir' }) + .set('Cookie', adminCookie); + expect(res.status).toBe(204); + + const stat = await fs.stat(path.join(stacksDir, STACK, 'mynewdir')); + expect(stat.isDirectory()).toBe(true); + }); +}); + +// ── permission gating ───────────────────────────────────────────────────────── + +describe('permission gating', () => { + it('viewer receives 403 from PUT /files/content', async () => { + const res = await request(app) + .put(`/api/stacks/${STACK}/files/content`) + .query({ path: 'new.txt' }) + .set('Cookie', viewerCookie) + .send({ content: 'hello' }); + expect(res.status).toBe(403); + }); + + it('viewer receives 403 from POST /files/upload', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .set('Cookie', viewerCookie) + .attach('file', Buffer.from('data'), 'test.txt'); + expect(res.status).toBe(403); + }); + + it('viewer receives 403 from DELETE /files', async () => { + const res = await request(app) + .delete(`/api/stacks/${STACK}/files`) + .query({ path: 'compose.yaml' }) + .set('Cookie', viewerCookie); + expect(res.status).toBe(403); + }); + + it('viewer receives 403 from POST /files/folder', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/folder`) + .query({ path: 'somedir' }) + .set('Cookie', viewerCookie); + expect(res.status).toBe(403); + }); +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index c7c65c4c..8e3e1953 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -1,6 +1,7 @@ -import { Router, type Request, type Response } from 'express'; +import { Router, type Request, type Response, type NextFunction } from 'express'; import path from 'path'; import YAML from 'yaml'; +import multer from 'multer'; import { FileSystemService } from '../services/FileSystemService'; import { ComposeService } from '../services/ComposeService'; import DockerController from '../services/DockerController'; @@ -13,7 +14,7 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; import { requirePermission } from '../middleware/permissions'; import { requirePaid, requireAdmin } from '../middleware/tierGates'; import { NotificationService } from '../services/NotificationService'; -import { isValidStackName, isValidServiceName, isPathWithinBase } from '../utils/validation'; +import { isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; import { sendGitSourceError } from '../utils/gitSourceHttp'; @@ -100,6 +101,15 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis } } +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 25 * 1024 * 1024, files: 1 }, +}); + +function getRelPath(req: Request): string { + return typeof req.query.path === 'string' ? req.query.path : ''; +} + export const stacksRouter = Router(); stacksRouter.get('/', async (req: Request, res: Response) => { @@ -847,3 +857,185 @@ stacksRouter.get('/:stackName/backup', async (req: Request, res: Response) => { res.status(500).json({ error: message }); } }); + +// ── File explorer endpoints ── + +type FsErrorCode = 'INVALID_PATH' | 'SYMLINK_ESCAPE' | 'IS_DIRECTORY' | 'NOT_EMPTY' | 'NOT_FOUND' | 'TOO_LARGE'; + +function sendFsError( + res: Response, + err: unknown, + fallback: string, + opts: { notFoundMessage?: string } = {}, +): Response { + const e = err as NodeJS.ErrnoException & { code?: string }; + if (e.code === 'INVALID_PATH' || e.code === 'SYMLINK_ESCAPE') { + return res.status(400).json({ error: e.message, code: e.code as FsErrorCode }); + } + if (e.code === 'IS_DIRECTORY') { + return res.status(400).json({ error: e.message, code: e.code as FsErrorCode }); + } + if (e.code === 'NOT_EMPTY') { + return res.status(409).json({ error: e.message, code: e.code as 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); + return res.status(500).json({ error: fallback }); +} + +stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' }); + const relPath = getRelPath(req); + if (relPath !== '' && !isValidRelativeStackPath(relPath)) { + return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); + } + try { + const entries = await FileSystemService.getInstance(req.nodeId).listStackDirectory(stackName, relPath); + return res.json(entries); + } catch (err: unknown) { + return sendFsError(res, err, 'Failed to list directory'); + } +}); + +stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' }); + const relPath = getRelPath(req); + 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' }); + } + try { + const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath); + return res.json(result); + } catch (err: unknown) { + return sendFsError(res, err, 'Failed to read file'); + } +}); + +stacksRouter.get('/:stackName/files/download', async (req: Request, res: Response) => { + if (!requirePaid(req, res)) return; + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' }); + const relPath = getRelPath(req); + if (relPath !== '' && !isValidRelativeStackPath(relPath)) { + return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); + } + try { + const result = await FileSystemService.getInstance(req.nodeId).streamStackFile(stackName, relPath); + res.setHeader('Content-Type', result.mime); + res.setHeader('Content-Length', result.size); + const encodedFilename = encodeURIComponent(result.filename); + 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); + if (!res.headersSent) res.status(500).end(); + else res.destroy(); + }); + req.on('close', () => result.stream.destroy()); + result.stream.pipe(res); + return; + } catch (err: unknown) { + return sendFsError(res, err, 'Failed to download file'); + } +}); + +stacksRouter.post( + '/:stackName/files/upload', + (req: Request, res: Response, next: NextFunction) => { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' }); + if (!requirePaid(req, res)) return; + upload.single('file')(req, res, (err) => { + if (err && (err as multer.MulterError).code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ error: 'File exceeds 25 MB limit', code: 'TOO_LARGE' }); + } + if (err) return res.status(500).json({ error: 'Upload failed' }); + next(); + }); + }, + async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + if (!req.file) { + return res.status(400).json({ error: 'No file provided' }); + } + const relPath = getRelPath(req); + 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 === '..') { + return res.status(400).json({ error: 'Invalid filename' }); + } + const targetRelPath = relPath ? `${relPath}/${safeName}` : safeName; + try { + await FileSystemService.getInstance(req.nodeId).writeStackFileBuffer(stackName, targetRelPath, req.file.buffer); + return res.status(204).send(); + } catch (err: unknown) { + return sendFsError(res, err, 'Failed to upload file', { notFoundMessage: 'Target directory not found' }); + } + }, +); + +stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response) => { + if (!requirePaid(req, res)) return; + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' }); + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + const relPath = getRelPath(req); + if (relPath !== '' && !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' }); + } + try { + await FileSystemService.getInstance(req.nodeId).writeStackFile(stackName, relPath, content); + return res.status(204).send(); + } catch (err: unknown) { + return sendFsError(res, err, 'Failed to write file'); + } +}); + +stacksRouter.delete('/:stackName/files', async (req: Request, res: Response) => { + if (!requirePaid(req, res)) return; + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' }); + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + const relPath = getRelPath(req); + if (relPath === '') return res.status(400).json({ error: 'Path is required for delete' }); + if (!isValidRelativeStackPath(relPath)) { + return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); + } + const recursive = req.query.recursive === '1'; + try { + await FileSystemService.getInstance(req.nodeId).deleteStackPath(stackName, relPath, recursive); + return res.status(204).send(); + } catch (err: unknown) { + return sendFsError(res, err, 'Failed to delete path'); + } +}); + +stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response) => { + if (!requirePaid(req, res)) return; + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' }); + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + const relPath = getRelPath(req); + if (relPath === '') return res.status(400).json({ error: 'Path is required to create a folder' }); + if (!isValidRelativeStackPath(relPath)) { + return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); + } + try { + await FileSystemService.getInstance(req.nodeId).mkdirStackPath(stackName, relPath); + return res.status(204).send(); + } catch (err: unknown) { + return sendFsError(res, err, 'Failed to create folder'); + } +}); diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index f721dcc6..ba4d78f5 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -1,6 +1,17 @@ import path from 'path'; -import { promises as fsPromises } from 'fs'; +import { promises as fsPromises, createReadStream } from 'fs'; +import type { Readable } from 'stream'; import { NodeRegistry } from './NodeRegistry'; +import { isPathWithinBase } from '../utils/validation'; +import { isBinaryBuffer } from '../utils/binaryDetect'; + +export interface FileEntry { + name: string; + type: 'file' | 'directory' | 'symlink'; + size: number; + mtime: number; + isProtected: boolean; +} /** * Resolves the writable Sencho data directory (same one DatabaseService / @@ -14,6 +25,22 @@ function getBackupBaseDir(): string { import { isDebugEnabled } from '../utils/debug'; +const PROTECTED_STACK_FILES = new Set([ + 'compose.yaml', + 'compose.yml', + 'docker-compose.yaml', + 'docker-compose.yml', + '.env', +]); + +const MIME_MAP: Record = { + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + '.json': 'application/json', + '.sh': 'text/x-sh', + '.env': 'text/plain', +}; + /** * FileSystemService - local-only file I/O for compose stack management. * @@ -320,4 +347,218 @@ export class FileSystemService { return { exists: false, timestamp: null }; } } + + // --------------------------------------------------------------------------- + // Stack-scoped file explorer methods + // --------------------------------------------------------------------------- + + private guessMime(filePath: string): string { + if (path.basename(filePath) === '.env') return 'text/plain'; + const ext = path.extname(filePath).toLowerCase(); + return MIME_MAP[ext] ?? 'text/plain'; + } + + private async resolveSafeStackPath(stackName: string, relPath: string): Promise { + const stackDir = path.join(this.baseDir, stackName); + if (!isPathWithinBase(stackDir, this.baseDir)) { + throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' }); + } + const target = relPath === '' ? stackDir : path.resolve(stackDir, relPath); + + if (!isPathWithinBase(target, stackDir)) { + throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' }); + } + + let realTarget: string; + try { + realTarget = await fsPromises.realpath(target); + } catch (err: unknown) { + const fsErr = err as NodeJS.ErrnoException; + if (fsErr.code !== 'ENOENT') throw err; + + // Walk up to the deepest existing ancestor, then reattach the suffix. + let existing = target; + const suffix: string[] = []; + while (true) { + const parent = path.dirname(existing); + if (parent === existing) { + // Reached filesystem root without finding an existing path. + throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' }); + } + suffix.unshift(path.basename(existing)); + existing = parent; + try { + const realExisting = await fsPromises.realpath(existing); + if (!isPathWithinBase(realExisting, stackDir)) { + throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' }); + } + realTarget = path.join(realExisting, ...suffix); + break; + } catch (innerErr: unknown) { + const innerFsErr = innerErr as NodeJS.ErrnoException; + if (innerFsErr.code !== 'ENOENT') throw innerErr; + // Continue walking up. + } + } + } + + if (!isPathWithinBase(realTarget, stackDir)) { + throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' }); + } + + return realTarget; + } + + async listStackDirectory(stackName: string, relPath: string): Promise { + const safePath = await this.resolveSafeStackPath(stackName, relPath); + const dirents = await fsPromises.readdir(safePath, { withFileTypes: true }); + + const entries = await Promise.all( + dirents.map(async (dirent): Promise => { + const entryPath = path.join(safePath, dirent.name); + let size = 0; + let mtime = 0; + try { + const st = await fsPromises.stat(entryPath); + size = dirent.isDirectory() ? 0 : st.size; + mtime = st.mtimeMs; + } catch { + // stat can fail for broken symlinks; use defaults. + } + const type: FileEntry['type'] = dirent.isDirectory() + ? 'directory' + : dirent.isSymbolicLink() + ? 'symlink' + : 'file'; + return { + name: dirent.name, + type, + size, + mtime, + isProtected: PROTECTED_STACK_FILES.has(dirent.name), + }; + }) + ); + + return entries.sort((a, b) => { + if (a.type === 'directory' && b.type !== 'directory') return -1; + if (a.type !== 'directory' && b.type === 'directory') return 1; + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + } + + async readStackFile( + stackName: string, + relPath: string, + maxBytes: number = 2 * 1024 * 1024 + ): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string }> { + const safePath = await this.resolveSafeStackPath(stackName, relPath); + const stat = await fsPromises.stat(safePath); + const mime = this.guessMime(safePath); + + if (stat.isDirectory()) { + throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' }); + } + + if (stat.size > maxBytes) { + const fd = await fsPromises.open(safePath, 'r'); + const probe = Buffer.allocUnsafe(8192); + const { bytesRead } = await fd.read(probe, 0, 8192, 0); + await fd.close(); + const binary = isBinaryBuffer(probe.subarray(0, bytesRead)); + return { binary, oversized: true, size: stat.size, mime }; + } + + const buf = await fsPromises.readFile(safePath); + + if (isBinaryBuffer(buf)) { + return { binary: true, oversized: false, size: stat.size, mime }; + } + + return { binary: false, oversized: false, size: stat.size, mime, content: buf.toString('utf-8') }; + } + + async streamStackFile( + stackName: string, + relPath: string + ): Promise<{ stream: Readable; size: number; filename: string; mime: string }> { + const safePath = await this.resolveSafeStackPath(stackName, relPath); + const stat = await fsPromises.stat(safePath); + + if (stat.isDirectory()) { + throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' }); + } + + return { + stream: createReadStream(safePath), + size: stat.size, + filename: path.basename(safePath), + mime: this.guessMime(safePath), + }; + } + + async writeStackFile(stackName: string, relPath: string, content: string): Promise { + const safePath = await this.resolveSafeStackPath(stackName, relPath); + await fsPromises.mkdir(path.dirname(safePath), { recursive: true }); + await fsPromises.writeFile(safePath, content, 'utf-8'); + } + + async writeStackFileBuffer(stackName: string, relPath: string, buffer: Buffer): Promise { + const safePath = await this.resolveSafeStackPath(stackName, relPath); + await fsPromises.mkdir(path.dirname(safePath), { recursive: true }); + await fsPromises.writeFile(safePath, buffer); + } + + async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise { + const safePath = await this.resolveSafeStackPath(stackName, relPath); + + if (recursive) { + await fsPromises.rm(safePath, { recursive: true, force: true }); + return; + } + try { + await fsPromises.unlink(safePath); + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'EISDIR') { + try { + await fsPromises.rmdir(safePath); + } catch (inner: unknown) { + const ie = inner as NodeJS.ErrnoException; + if (ie.code === 'ENOTEMPTY' || ie.code === 'EEXIST') { + throw Object.assign(new Error('Directory is not empty'), { code: 'NOT_EMPTY' }); + } + throw inner; + } + } else { + throw err; + } + } + } + + async mkdirStackPath(stackName: string, relPath: string): Promise { + const safePath = await this.resolveSafeStackPath(stackName, relPath); + await fsPromises.mkdir(safePath, { recursive: true }); + } + + async statStackEntry(stackName: string, relPath: string): Promise { + const safePath = await this.resolveSafeStackPath(stackName, relPath); + // Use lstat so symlinks are reported as 'symlink' rather than resolved to target type. + const stat = await fsPromises.lstat(safePath); + const name = path.basename(safePath); + + const type: FileEntry['type'] = stat.isDirectory() + ? 'directory' + : stat.isSymbolicLink() + ? 'symlink' + : 'file'; + + return { + name, + type, + size: stat.isDirectory() ? 0 : stat.size, + mtime: stat.mtimeMs, + isProtected: PROTECTED_STACK_FILES.has(name), + }; + } } diff --git a/backend/src/utils/binaryDetect.ts b/backend/src/utils/binaryDetect.ts new file mode 100644 index 00000000..23bbc8e6 --- /dev/null +++ b/backend/src/utils/binaryDetect.ts @@ -0,0 +1,21 @@ +/** + * Heuristic binary detection based on byte-range sampling. + * Printable bytes: 0x09-0x0D (tab, LF, VT, FF, CR) and 0x20-0x7E. + * Any NUL byte is an immediate binary signal (text editors never produce them). + * If more than 30% of sampled bytes are non-printable the buffer is treated as binary. + */ +export function isBinaryBuffer(buf: Buffer, sampleBytes = 8192): boolean { + if (buf.length === 0) return false; + + const sample = buf.subarray(0, sampleBytes); + let nonPrintable = 0; + + for (let i = 0; i < sample.length; i++) { + const b = sample[i]; + if (b === 0x00) return true; + const isPrintable = (b >= 0x09 && b <= 0x0d) || (b >= 0x20 && b <= 0x7e); + if (!isPrintable) nonPrintable++; + } + + return nonPrintable / sample.length > 0.3; +} diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 22242734..1b08a26f 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -79,6 +79,21 @@ export function isValidDockerResourceId(id: string): boolean { export const isValidServiceName = (name: string): boolean => /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name); +/** + * Validates a relative path supplied by the client for stack file operations. + * An empty string is allowed (it means the stack root directory). + * Rejects anything that could escape the stack directory or cause OS-level issues. + */ +export function isValidRelativeStackPath(rel: string): boolean { + if (rel === '') return true; + if (rel.includes('\0')) return false; + if (rel.includes('\\')) return false; + if (/^[a-zA-Z]:/.test(rel) || rel.startsWith('/')) return false; + if (rel.includes('//')) return false; + const segments = rel.split('/'); + return !segments.some(seg => seg === '..' || seg === '.'); +} + /** * Asserts that a resolved file path stays within a given base directory. * Returns true if the path is safe, false if it escapes the base. diff --git a/docs/docs.json b/docs/docs.json index c636e2d4..7b64268a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -99,6 +99,7 @@ "features/sidebar", "features/stack-management", "features/editor", + "features/stack-file-explorer", "features/deploy-progress", "features/resources", "features/app-store", diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index fed4186d..b32033d4 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -9,6 +9,12 @@ Selecting a stack opens the editor view, a two-column layout. The left column sh Editor view showing command center on the left and Monaco editor on the right +## Files tab + +The **Files** tab gives you a browseable directory tree for everything inside the stack's folder. Community users can view text files in read-only mode. Skipper and above can upload, download, edit and save, create folders, and delete files. Protected files (`compose.yaml`, `.env`, and their variants) redirect to their dedicated editor tabs when clicked. + +For full details on permissions, viewing limits, upload caps, and troubleshooting, see the [Stack File Explorer](/features/stack-file-explorer) guide. + ## Compose file editor The right panel shows your `compose.yaml` with full syntax highlighting. By default the editor is **read-only** to prevent accidental changes. diff --git a/docs/features/stack-file-explorer.mdx b/docs/features/stack-file-explorer.mdx new file mode 100644 index 00000000..14fa2348 --- /dev/null +++ b/docs/features/stack-file-explorer.mdx @@ -0,0 +1,104 @@ +--- +title: Stack File Explorer +description: Browse, edit, upload, and manage files inside a stack's directory from the dashboard. +--- + +The file explorer gives you direct access to everything inside a stack's directory: configuration files, certificates, scripts, static assets, and any other files your containers depend on. It lives on the **Files** tab inside the stack editor. + + + The explorer is scoped to the stack's own directory. You cannot browse other stacks or navigate above the stack root. + + +## Opening the file explorer + +Select any stack in the sidebar, then click the **Files** tab in the editor. The left panel shows the directory tree; clicking a file opens it in the viewer on the right. + +## Tiers at a glance + + + + Browse the directory tree and view text files in read-only mode. + + + Full read/write access: upload, download, edit and save, create folders, and delete files or directories. Admiral tier includes the same access. + + + +## Browsing the directory tree + +The tree lists all files and folders in the stack directory. Folders are sorted before files, and items within each group are sorted alphabetically. + +Click a folder to expand or collapse it. Click a file to open it in the viewer panel on the right. + +### Protected files + +`compose.yaml`, `docker-compose.yaml`, `docker-compose.yml`, `.env`, and any `.env.*` variants are shown in the tree, but clicking them does not open the generic file viewer. Instead, Sencho navigates to the dedicated **Compose** or **Env** tab editor for that file. This ensures you always use the purpose-built editor with save-and-deploy controls for your main stack files. + +## Viewing files + +When you click a text file, its contents appear in the editor panel on the right. + +**Viewing limits:** + +| File type | Behaviour | +|-----------|-----------| +| Text file up to 2 MB | Displayed inline with syntax highlighting. | +| Text file over 2 MB | "File is too large to preview" message with a **Download** button (Skipper+). | +| Binary file | "Binary file detected" message with a **Download** button (Skipper+). | + +On Community, the Download button is hidden for files that exceed the preview limit. To access large or binary files from the Community tier, use the host terminal or `docker cp` from your server. + +## Editing and saving (Skipper+) + +Text files open in read-only mode by default. Click **Edit** in the toolbar to enable editing. The editor accepts plain text input. + +When you're done, click **Save** to write the file to disk. Unsaved changes are discarded when you navigate away or close the tab. + + + Editing a file does not restart any containers. If your stack reads the file at runtime (e.g. a config file mounted as a volume), restart the relevant service after saving for the change to take effect. + + +## Uploading files (Skipper+) + +Click **Upload** in the file explorer toolbar, then select one or more files from your local machine. Files are uploaded into the currently selected directory. + +**Limits:** + +- Maximum 25 MB per file. +- Files larger than 25 MB are rejected with an error. Split large archives or use `scp` / `rsync` for bulk transfers. + +If a file with the same name already exists in the target directory, it is overwritten. + +## Downloading files (Skipper+) + +Click the **Download** button in the file toolbar (for the currently open file) or right-click a file in the tree and choose **Download**. Files are streamed directly to your browser. The download limit is 100 MB per file. + + + For files larger than 100 MB, use `docker cp`, `scp`, or mount an export volume and copy from there. + + +## Creating folders (Skipper+) + +Click the **New Folder** button in the tree toolbar, or right-click an existing folder and choose **New Folder**. Enter a name and confirm. The new folder is created immediately and appears in the tree. + +Folder names follow the same rules as filenames on your host OS. Avoid leading dots unless you intend a hidden directory. + +## Deleting files and folders (Skipper+) + +Right-click a file or folder in the tree and choose **Delete**. A confirmation prompt appears before anything is removed. + +**Deleting a folder** removes the folder and all of its contents recursively. The confirmation dialog lists the path so you can verify before proceeding. + + + Deletions are permanent. Sencho does not keep a trash bin or undo history for file operations. + + +## Troubleshooting + +| Symptom | Cause | Resolution | +|---------|-------|------------| +| File shows as "Binary file detected" but it is a text file | The binary detection heuristic found null bytes or a high proportion of non-printable characters, which can happen with certain encodings or line endings | Download the file, edit it locally or via an SSH session, then re-upload. Alternatively, open a host terminal and edit with `nano` or `vim`. | +| Upload fails with a 413 error | The file exceeds the 25 MB per-file limit | Compress or split the file before uploading, or transfer it via `scp` / `rsync` directly to the stack directory on the host. | +| Cannot delete a folder | The UI requires confirmation before a recursive delete | Confirm the deletion prompt. If the error persists, check whether a running container has an open file handle inside that directory, which can block the delete on some OS configurations. Stop the relevant service and retry. | +| File saved but container still reads old content | The container caches the file at startup or the mount is read-only inside the container | Restart the service after saving so the container picks up the new file. | +| Download button not visible | You are on the Community tier, and the file exceeds the preview limit or is binary | Upgrade to Skipper to enable downloads, or access the file via the host terminal or `docker cp`. | diff --git a/e2e/deploy-log-panel.spec.ts b/e2e/deploy-log-panel.spec.ts index d9d799aa..931257b4 100644 --- a/e2e/deploy-log-panel.spec.ts +++ b/e2e/deploy-log-panel.spec.ts @@ -9,7 +9,7 @@ * on a cold cache. */ import { test, expect, type Page } from '@playwright/test'; -import { loginAs } from './helpers'; +import { loginAs, waitForStacksLoaded } from './helpers'; const HAPPY_STACK = 'e2e-deploy-log-test'; const FAIL_STACK = 'e2e-deploy-log-fail-test'; @@ -25,11 +25,6 @@ const FAIL_COMPOSE = `services: image: nginnnnx:notexist `; -async function waitForStacksLoaded(page: Page): Promise { - await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 }); - await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 }); -} - async function createStackViaApi(page: Page, name: string, composeContent: string): Promise { await page.evaluate( async ({ stackName, content }: { stackName: string; content: string }) => { diff --git a/e2e/helpers.ts b/e2e/helpers.ts index e889f340..7a6a79b1 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -48,6 +48,16 @@ export async function isDashboard(page: Page): Promise { return page.locator(DASHBOARD_INDICATOR).isVisible().catch(() => false); } +/** + * Wait for the stacks sidebar to finish loading. Waits for the Create Stack + * button and the data-stacks-loaded sentinel set by the CommandList after its + * async refreshStacks() call resolves. + */ +export async function waitForStacksLoaded(page: Page): Promise { + await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 }); +} + /** * Navigate to the app root, complete first-run setup if needed, then log in. * After this call the dashboard is guaranteed to be visible. diff --git a/e2e/stack-files.spec.ts b/e2e/stack-files.spec.ts new file mode 100644 index 00000000..64c632b7 --- /dev/null +++ b/e2e/stack-files.spec.ts @@ -0,0 +1,351 @@ +/** + * File explorer E2E tests. + * + * Two scenarios are covered: + * + * 1. Community flow (read-only): the license endpoint is intercepted so the + * frontend believes the instance is community tier. Only viewing files is + * allowed; the upgrade pill is visible in the left pane and the Save button + * is absent from the editor toolbar. + * + * 2. Skipper+ flow (full CRUD): the real license state (paid) is used. + * Upload, edit-and-save, delete, and download are exercised end-to-end. + * These tests skip gracefully when the instance is community tier (CI). + * + * Fixture files (config/app.conf and assets/logo.png) are seeded once via + * direct filesystem writes in a beforeAll hook so seeding works on any tier. + * The entire test stack is torn down in afterAll. + */ +import * as fs from 'node:fs/promises'; +import * as nodePath from 'node:path'; +import { test, expect, type Page, type BrowserContext } from '@playwright/test'; +import { loginAs, waitForStacksLoaded } from './helpers'; + +// Matches the COMPOSE_DIR passed to the backend in CI (start-app default) and +// in local dev. The test runner and the backend share the same host so both +// see the same path. +const COMPOSE_DIR = process.env.COMPOSE_DIR ?? '/tmp/compose'; + +const TEST_STACK = 'e2e-files-test'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Dismiss any "requires a paid license" or upgrade gate overlays that may + * appear when operating in community mode. These are informational overlays + * rendered by CapabilityGate for features like Auto-Heal Policies. + */ +async function dismissUpgradeOverlays(page: Page): Promise { + const dismissBtn = page.getByRole('button', { name: /dismiss/i }); + if (await dismissBtn.isVisible().catch(() => false)) { + await dismissBtn.click(); + await page.waitForTimeout(200); + } +} + +/** + * Click the test stack in the sidebar, then click the "files" button in the + * anatomy panel header to enter the Files tab. This works regardless of the + * current license tier because the Files panel itself is always rendered + * (isPaid only gates edit/upload/delete within the panel). + */ +async function openFilesTab(page: Page): Promise { + await waitForStacksLoaded(page); + + // Ensure the stack appears in the sidebar (reload if necessary after seeding) + const stackInSidebar = page.getByText(TEST_STACK, { exact: true }).first(); + if (!await stackInSidebar.isVisible().catch(() => false)) { + await page.reload(); + await loginAs(page); + await waitForStacksLoaded(page); + } + + // Click the stack in the sidebar + await page.getByText(TEST_STACK, { exact: true }).first().click(); + + // Dismiss any upgrade/capability-gate overlays that block navigation + await dismissUpgradeOverlays(page); + + // The anatomy panel header has a stable testid on the "files" button. + const filesBtn = page.getByTestId('anatomy-files-btn'); + await expect(filesBtn).toBeVisible({ timeout: 8_000 }); + await filesBtn.click(); + + // Wait for the file tree to populate: span.font-mono appears once the + // tree data has loaded for both paid and community tiers. + await expect(page.locator('span.font-mono').first()).toBeVisible({ timeout: 10_000 }); +} + +/** + * Seed the test stack with fixture files. + * + * Stack creation uses the API (community-allowed) so the backend registry + * stays in sync. Fixture files are written directly via Node fs so seeding + * works on any tier without hitting the paid upload/mkdir endpoints. + */ +async function seedTestStack(page: Page): Promise { + // Create the stack via API (community-OK; ignore 409 if already exists). + await page.evaluate(async (name: string) => { + const res = await fetch('/api/stacks', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stackName: name }), + }); + if (!res.ok && res.status !== 409) throw new Error(`stack create: ${res.status}`); + }, TEST_STACK); + + // Write fixture files directly on the host filesystem. + const stackDir = nodePath.join(COMPOSE_DIR, TEST_STACK); + await fs.mkdir(nodePath.join(stackDir, 'config'), { recursive: true }); + await fs.mkdir(nodePath.join(stackDir, 'assets'), { recursive: true }); + await fs.writeFile(nodePath.join(stackDir, 'config', 'app.conf'), 'key=value\n'); + const pngB64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=='; + await fs.writeFile(nodePath.join(stackDir, 'assets', 'logo.png'), Buffer.from(pngB64, 'base64')); +} + +/** Delete the test stack via the authenticated browser session. */ +async function teardownTestStack(page: Page): Promise { + await page.evaluate(async (name: string) => { + await fetch(`/api/stacks/${encodeURIComponent(name)}`, { + method: 'DELETE', + credentials: 'include', + }).catch(() => {}); + }, TEST_STACK); +} + +/** + * Open a new page, login, seed the test stack, then close the page. + * Used as a beforeAll body so each describe suite shares one seed call. + */ +async function seedSuite(browser: import('@playwright/test').Browser): Promise { + const page = await browser.newPage(); + try { + await loginAs(page); + await seedTestStack(page); + } finally { + await page.close(); + } +} + +/** + * Open a new page, login, tear down the test stack, then close the page. + * Logs a warning on failure so a teardown error never masks test failures. + */ +async function teardownSuite(browser: import('@playwright/test').Browser, label: string): Promise { + const page = await browser.newPage(); + try { + await loginAs(page); + await teardownTestStack(page); + } catch (e) { + console.warn(`teardown failed (${label}):`, e); + } finally { + await page.close(); + } +} + +const COMMUNITY_LICENSE_BODY = JSON.stringify({ + tier: 'community', + status: 'community', + variant: null, + customerName: null, + productName: null, + maskedKey: null, + validUntil: null, + trialDaysRemaining: null, + instanceId: 'test-instance', + portalUrl: null, + isLifetime: false, +}); + +/** Stub the /api/license endpoint so the frontend treats the session as community tier. */ +async function mockCommunityLicense(context: BrowserContext): Promise { + await context.route('/api/license', (route) => { + route.fulfill({ status: 200, contentType: 'application/json', body: COMMUNITY_LICENSE_BODY }); + }); +} + +// --------------------------------------------------------------------------- +// Community flow (read-only) +// --------------------------------------------------------------------------- + +test.describe('File explorer - community (read-only)', () => { + // Increase timeout: seeding + navigation add overhead + test.setTimeout(60_000); + + test.beforeAll(async ({ browser }) => { await seedSuite(browser); }); + test.afterAll(async ({ browser }) => { await teardownSuite(browser, 'community'); }); + + test.beforeEach(async ({ page, context }) => { + // Login without the license stub first so cookies are established. + await loginAs(page); + + // Install the community-tier stub and reload so the frontend picks it up. + await mockCommunityLicense(context); + await page.reload(); + await loginAs(page); + + await openFilesTab(page); + }); + + test.afterEach(async ({ context }) => { + // Remove the stub so subsequent requests use the real license state. + await context.unroute('/api/license'); + }); + + test('upgrade pill is visible in the left pane', async ({ page }) => { + await expect( + page.getByRole('button', { name: /upgrade to unlock/i }) + ).toBeVisible({ timeout: 5_000 }); + }); + + test('can expand config/ and click config/app.conf - Save button is absent', async ({ page }) => { + // The config/ directory should be visible in the tree + const configNode = page.locator('span.font-mono').filter({ hasText: /^config$/ }).first(); + await expect(configNode).toBeVisible({ timeout: 8_000 }); + + // Click to expand the directory + await configNode.click(); + + // app.conf should now appear under config/ + const appConfNode = page.locator('span.font-mono').filter({ hasText: /^app\.conf$/ }).first(); + await expect(appConfNode).toBeVisible({ timeout: 8_000 }); + await appConfNode.click(); + + // The editor header should show "Read-only" badge (isPaid is false) + await expect(page.getByText('Read-only')).toBeVisible({ timeout: 10_000 }); + + // The Save button must NOT be present in community mode + await expect(page.getByRole('button', { name: /^save$/i })).not.toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Skipper+ flow (full CRUD) +// --------------------------------------------------------------------------- + +test.describe('File explorer - skipper+ (full CRUD)', () => { + test.setTimeout(60_000); + + test.beforeAll(async ({ browser }) => { await seedSuite(browser); }); + test.afterAll(async ({ browser }) => { await teardownSuite(browser, 'skipper'); }); + + test.beforeEach(async ({ page }) => { + await loginAs(page); + // Skip when the instance is community tier: upload/edit/delete/download + // all require Skipper+ and would 403. Same pattern as auto-heal-policies. + const tier = await page.evaluate(async () => { + const res = await fetch('/api/license', { credentials: 'include' }); + const json = await res.json() as { tier?: string }; + return json.tier; + }); + test.skip(tier !== 'paid', 'Skipper+ tier required; instance is community.'); + await openFilesTab(page); + }); + + test('upload a text file and verify it appears in the tree', async ({ page }) => { + // Guard: paid users see the upload dropzone, not the upgrade pill. + await expect( + page.locator('[role="button"]').filter({ hasText: /upload file/i }).first() + ).toBeVisible({ timeout: 5_000 }); + + // Set a file on the hidden input + const input = page.locator('input[type="file"][aria-label="Upload file"]'); + await input.setInputFiles({ + name: 'e2e-upload-test.txt', + mimeType: 'text/plain', + buffer: Buffer.from('hello e2e\n'), + }); + + // Success toast + await expect(page.getByText(/uploaded/i).first()).toBeVisible({ timeout: 10_000 }); + + // File appears in the tree at root level + await expect( + page.locator('span.font-mono').filter({ hasText: /^e2e-upload-test\.txt$/ }).first() + ).toBeVisible({ timeout: 8_000 }); + }); + + test('edit config/app.conf and save - success toast appears', async ({ page }) => { + // Expand config/ and open app.conf + const configNode = page.locator('span.font-mono').filter({ hasText: /^config$/ }).first(); + await expect(configNode).toBeVisible({ timeout: 8_000 }); + await configNode.click(); + + const appConfNode = page.locator('span.font-mono').filter({ hasText: /^app\.conf$/ }).first(); + await expect(appConfNode).toBeVisible({ timeout: 8_000 }); + await appConfNode.click(); + + // Wait for Monaco to load + await page.waitForLoadState('networkidle', { timeout: 10_000 }); + + // Save button must be present and initially disabled (no changes yet) + const saveBtn = page.getByRole('button', { name: /^save$/i }); + await expect(saveBtn).toBeVisible({ timeout: 8_000 }); + await expect(saveBtn).toBeDisabled(); + + // Edit the file content via Monaco + const editorTextarea = page.locator('.monaco-editor textarea').first(); + await editorTextarea.click({ force: true }); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.type('key=value\ne2e-edited=true\n'); + + // Save button should now be enabled + await expect(saveBtn).toBeEnabled({ timeout: 5_000 }); + await saveBtn.click(); + + // Success toast + await expect(page.getByText(/saved/i).first()).toBeVisible({ timeout: 8_000 }); + }); + + test('delete an uploaded file - it disappears from the tree', async ({ page }) => { + // Upload a disposable file first + const input = page.locator('input[type="file"][aria-label="Upload file"]'); + await input.setInputFiles({ + name: 'e2e-to-delete.txt', + mimeType: 'text/plain', + buffer: Buffer.from('delete me\n'), + }); + await expect(page.getByText(/uploaded/i).first()).toBeVisible({ timeout: 10_000 }); + + // Click the file to select it + const fileNode = page.locator('span.font-mono').filter({ hasText: /^e2e-to-delete\.txt$/ }).first(); + await expect(fileNode).toBeVisible({ timeout: 8_000 }); + await fileNode.click(); + + // The action bar Delete button has a stable data-testid for reliable targeting. + const actionBarDeleteBtn = page.getByTestId('file-action-delete'); + await expect(actionBarDeleteBtn).toBeVisible({ timeout: 5_000 }); + await actionBarDeleteBtn.click(); + + // Wait for the DeleteFileConfirm dialog to open, then confirm deletion. + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 8_000 }); + await page.getByTestId('delete-confirm-btn').click(); + + // Use the tree-specific class (text-sm) to avoid a strict-mode violation: + // the FileViewer header renders the same filename in a different span until + // handleDeleted() clears selectedPath. + await expect( + page.locator('span.font-mono.text-sm').filter({ hasText: /^e2e-to-delete\.txt$/ }) + ).not.toBeAttached({ timeout: 8_000 }); + }); + + test('download assets/logo.png - response is 200 with attachment header', async ({ page, request }) => { + // Replay the browser's session cookies in a raw request to the download endpoint. + const cookies = await page.context().cookies(); + const cookieHeader = cookies.map((c) => `${c.name}=${c.value}`).join('; '); + + const res = await request.get( + `/api/stacks/${encodeURIComponent(TEST_STACK)}/files/download` + + `?path=${encodeURIComponent('assets/logo.png')}`, + { headers: { cookie: cookieHeader } }, + ); + + expect(res.status()).toBe(200); + const disposition = res.headers()['content-disposition'] ?? ''; + expect(disposition).toMatch(/attachment/i); + }); +}); diff --git a/e2e/stacks.spec.ts b/e2e/stacks.spec.ts index 90908553..fa0db5c9 100644 --- a/e2e/stacks.spec.ts +++ b/e2e/stacks.spec.ts @@ -2,18 +2,10 @@ * Stack management E2E tests - happy path CRUD. */ import { test, expect } from '@playwright/test'; -import { loginAs } from './helpers'; +import { loginAs, waitForStacksLoaded } from './helpers'; const TEST_STACK = 'e2e-test-stack'; -/** Wait for the stacks sidebar to finish loading (skeletons replaced with actual stack list). */ -async function waitForStacksLoaded(page: import('@playwright/test').Page) { - await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 }); - // The CommandList has data-stacks-loaded="true" once the async refreshStacks() completes. - // Without this, tests can race against the loading state and miss newly created stacks. - await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 }); -} - /** Delete the test stack via the browser's authenticated fetch (so cookies are included). */ async function deleteTestStackViaApi(page: import('@playwright/test').Page) { await page.evaluate(async (name) => { diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 823f365d..9bb13ed6 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -18,7 +18,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs'; import { springs } from '@/lib/motion'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy, FolderOpen } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { type Label as StackLabel, type LabelColor } from './label-types'; import { UserProfileDropdown } from './UserProfileDropdown'; @@ -73,6 +73,7 @@ import { usePinnedStacks } from '@/hooks/usePinnedStacks'; import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse'; import type { StackRowStatus } from '@/components/sidebar/StackRow'; import type { StackMenuCtx } from '@/components/sidebar/sidebar-types'; +import { StackFileExplorer } from '@/components/files/StackFileExplorer'; interface ContainerInfo { Id: string; @@ -237,7 +238,7 @@ export default function EditorLayout() { // the delta is always computed against the most recent known value, avoiding // the stale-closure bug that occurs when reading containerStats directly. const rawBytesRef = useRef>({}); - const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose'); + const [activeTab, setActiveTab] = useState<'compose' | 'env' | 'files'>('compose'); const [logsMode, setLogsMode] = useState<'structured' | 'raw'>(() => { if (typeof window === 'undefined') return 'structured'; return (localStorage.getItem('sencho.stackView.logsMode') as 'structured' | 'raw' | null) ?? 'structured'; @@ -1107,6 +1108,7 @@ export default function EditorLayout() { setIsFileLoading(true); setIsEditing(false); // Reset to view mode when loading a new file setEditingCompose(false); // Default back to anatomy on stack switch + setActiveTab('compose'); try { const res = await apiFetch(`/stacks/${filename}`); const text = await res.text(); @@ -1231,6 +1233,7 @@ export default function EditorLayout() { }; const saveFile = async () => { + if (activeTab === 'files') return; if (!selectedFile) return; const currentContent = activeTab === 'compose' ? (content || '') : (envContent || ''); const endpoint = activeTab === 'compose' ? `/stacks/${selectedFile}` : `/stacks/${selectedFile}/env?file=${encodeURIComponent(selectedEnvFile)}`; @@ -1291,6 +1294,7 @@ export default function EditorLayout() { }; const discardChanges = () => { + if (activeTab === 'files') return; if (activeTab === 'compose') { setContent(originalContent); } else { @@ -2713,7 +2717,7 @@ export default function EditorLayout() {
- setActiveTab(value as 'compose' | 'env')}> + setActiveTab(value as 'compose' | 'env' | 'files')}> @@ -2722,6 +2726,12 @@ export default function EditorLayout() { .env + + + + Files + + @@ -2742,7 +2752,7 @@ export default function EditorLayout() { )}
- {can('stack:edit', 'stack', stackName) && ( + {activeTab !== 'files' && can('stack:edit', 'stack', stackName) && ( <>
- {activeTab === 'env' && ( -
- - Variables defined here are automatically available for substitution in your compose.yaml (e.g., ${'{}'}VAR). To pass them directly into your container, you must add env_file: - .env to your service definition. - -
- )} -
- {!isFileLoading && ( - { monacoEditorRef.current = editor; }} - onChange={(value) => { - if (!isEditing) return; // Prevent changes in view mode - if (activeTab === 'compose') { - setContent(value || ''); - } else { - setEnvContent(value || ''); - } - }} - options={{ - minimap: { enabled: false }, - fontFamily: "'Geist Mono', monospace", - fontSize: 14, - padding: { top: 10 }, - scrollBeyondLastLine: false, - readOnly: !isEditing || !can('stack:edit', 'stack', stackName), - }} - /> - )} - {isFileLoading && ( -
- Loading... + {activeTab === 'files' ? ( + setActiveTab('compose')} + onNavigateToEnv={() => setActiveTab('env')} + /> + ) : ( + <> + {activeTab === 'env' && ( +
+ + Variables defined here are automatically available for substitution in your compose.yaml (e.g., ${'{}'}VAR). To pass them directly into your container, you must add env_file: - .env to your service definition. + +
+ )} +
+ {!isFileLoading && ( + { monacoEditorRef.current = editor; }} + onChange={(value) => { + if (!isEditing) return; // Prevent changes in view mode + if (activeTab === 'compose') { + setContent(value || ''); + } else { + setEnvContent(value || ''); + } + }} + options={{ + minimap: { enabled: false }, + fontFamily: "'Geist Mono', monospace", + fontSize: 14, + padding: { top: 10 }, + scrollBeyondLastLine: false, + readOnly: !isEditing || !can('stack:edit', 'stack', stackName), + }} + /> + )} + {isFileLoading && ( +
+ Loading... +
+ )}
- )} -
+ + )}
) : ( @@ -2854,6 +2876,7 @@ export default function EditorLayout() { selectedEnvFile={selectedEnvFile} gitSourcePending={Boolean(gitSourcePendingMap[stackName])} onEditCompose={() => setEditingCompose(true)} + onOpenFiles={() => { setEditingCompose(true); setActiveTab('files'); }} onOpenGitSource={() => setGitSourceOpen(true)} onApplyUpdate={() => { void updateStack(); }} canEdit={can('stack:edit', 'stack', stackName)} diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index c030ced2..65546e83 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { parse as parseYaml } from 'yaml'; -import { GitBranch, Pencil, ExternalLink, Rocket } from 'lucide-react'; +import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react'; import { Button } from './ui/button'; import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/utils'; @@ -14,6 +14,7 @@ interface StackAnatomyPanelProps { onEditCompose: () => void; onOpenGitSource: () => void; onApplyUpdate: () => void; + onOpenFiles?: () => void; canEdit: boolean; } @@ -229,6 +230,7 @@ export default function StackAnatomyPanel({ onEditCompose, onOpenGitSource, onApplyUpdate, + onOpenFiles, canEdit, }: StackAnatomyPanelProps) { const anatomy = useMemo(() => parseAnatomy(content), [content]); @@ -327,16 +329,29 @@ export default function StackAnatomyPanel({
anatomy - {canEdit && ( - - )} +
+ {onOpenFiles && ( + + )} + {canEdit && ( + + )} +
{!anatomy ? ( diff --git a/frontend/src/components/files/DeleteFileConfirm.tsx b/frontend/src/components/files/DeleteFileConfirm.tsx new file mode 100644 index 00000000..cbd827c6 --- /dev/null +++ b/frontend/src/components/files/DeleteFileConfirm.tsx @@ -0,0 +1,145 @@ +import { useState, useEffect } from 'react'; +import { AlertTriangle, Loader2, Trash2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { toast } from '@/components/ui/toast-store'; +import { deleteStackPath } from '@/lib/stackFilesApi'; +import type { FileEntry } from '@/lib/stackFilesApi'; + +interface DeleteFileConfirmProps { + open: boolean; + onOpenChange: (open: boolean) => void; + stackName: string; + relPath: string; + entry: FileEntry | null; + onDeleted: () => void; +} + +export function DeleteFileConfirm({ + open, + onOpenChange, + stackName, + relPath, + entry, + onDeleted, +}: DeleteFileConfirmProps) { + const [deleting, setDeleting] = useState(false); + const [confirmInput, setConfirmInput] = useState(''); + const [notEmpty, setNotEmpty] = useState(false); + + const isProtected = entry?.isProtected ?? false; + const entryName = entry?.name ?? ''; + + useEffect(() => { + if (!open) { + setConfirmInput(''); + setNotEmpty(false); + } + }, [open]); + + const handleClose = (next: boolean) => { + if (deleting) return; + onOpenChange(next); + }; + + const executeDelete = async (recursive: boolean) => { + setDeleting(true); + try { + await deleteStackPath(stackName, relPath, recursive || undefined); + onDeleted(); + onOpenChange(false); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Delete failed.'; + if (!recursive && msg.toUpperCase().includes('NOT_EMPTY')) { + setNotEmpty(true); + } else { + toast.error(msg); + } + } finally { + setDeleting(false); + } + }; + + const handleDelete = () => void executeDelete(notEmpty); + + const protectedOk = !isProtected || confirmInput === entryName; + + const deleteLabel = notEmpty ? 'Delete all' : 'Delete'; + + return ( + + + + + + Delete {entryName ? `"${entryName}"` : 'item'}? + + + {notEmpty ? ( + + + This folder is not empty. Delete everything inside? + + ) : ( + 'This action cannot be undone.' + )} + + + + {isProtected && ( +
+
+ +

This is a critical stack file. Type the filename to confirm.

+
+
+ + setConfirmInput(e.target.value)} + placeholder={entryName} + disabled={deleting} + autoFocus + /> +
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/src/components/files/FileTree.tsx b/frontend/src/components/files/FileTree.tsx new file mode 100644 index 00000000..9f4505c2 --- /dev/null +++ b/frontend/src/components/files/FileTree.tsx @@ -0,0 +1,202 @@ +import { useState, useEffect, useRef, Fragment } from 'react'; +import type { ReactNode } from 'react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Skeleton } from '@/components/ui/skeleton'; +import { toast } from '@/components/ui/toast-store'; +import { listStackDirectory } from '@/lib/stackFilesApi'; +import type { FileEntry } from '@/lib/stackFilesApi'; +import { FileTreeNode } from './FileTreeNode'; + +interface FileTreeProps { + stackName: string; + refreshKey?: number; + selectedPath: string; + onSelectFile: (relPath: string, entry: FileEntry) => void; + onNavigateToCompose?: () => void; + onNavigateToEnv?: () => void; +} + +const COMPOSE_NAMES = new Set(['compose.yaml', 'compose.yml']); +const ENV_NAMES = new Set(['.env']); +const MAX_ENTRIES = 500; + +export function FileTree({ + stackName, + refreshKey, + selectedPath, + onSelectFile, + onNavigateToCompose, + onNavigateToEnv, +}: FileTreeProps) { + const [rootEntries, setRootEntries] = useState(null); + const [rootLoading, setRootLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedDirs, setExpandedDirs] = useState>(new Set()); + const [dirContents, setDirContents] = useState>(new Map()); + const [loadingDirs, setLoadingDirs] = useState>(new Set()); + const stackNameRef = useRef(stackName); + + useEffect(() => { + stackNameRef.current = stackName; + let cancelled = false; + + listStackDirectory(stackName, '') + .then((entries) => { + if (!cancelled) { + setRootEntries(entries); + setRootLoading(false); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + const msg = err instanceof Error ? err.message : 'Failed to load files.'; + setError(msg); + setRootLoading(false); + toast.error('Failed to load files.'); + } + }); + + return () => { + cancelled = true; + }; + }, [stackName, refreshKey]); + + function handleDirClick(dirRelPath: string) { + if (expandedDirs.has(dirRelPath)) { + setExpandedDirs((prev) => { + const next = new Set(prev); + next.delete(dirRelPath); + return next; + }); + return; + } + + if (dirContents.has(dirRelPath)) { + setExpandedDirs((prev) => new Set(prev).add(dirRelPath)); + return; + } + + setLoadingDirs((prev) => new Set(prev).add(dirRelPath)); + + const capturedStackName = stackName; + listStackDirectory(capturedStackName, dirRelPath) + .then((entries) => { + if (stackNameRef.current !== capturedStackName) return; + setDirContents((prev) => { + const next = new Map(prev); + next.set(dirRelPath, entries); + return next; + }); + setExpandedDirs((prev) => new Set(prev).add(dirRelPath)); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : 'Failed to load directory.'; + toast.error(msg); + }) + .finally(() => { + setLoadingDirs((prev) => { + const next = new Set(prev); + next.delete(dirRelPath); + return next; + }); + }); + } + + function handleFileClick(relPath: string, entry: FileEntry) { + if (COMPOSE_NAMES.has(entry.name)) { + if (onNavigateToCompose) onNavigateToCompose(); + else toast.info('Open the Compose tab to edit this file.'); + return; + } + if (ENV_NAMES.has(entry.name)) { + if (onNavigateToEnv) onNavigateToEnv(); + else toast.info('Open the Env tab to edit this file.'); + return; + } + onSelectFile(relPath, entry); + } + + function renderEntries(entries: FileEntry[], parentRelPath: string, depth: number): ReactNode { + const capped = entries.length > MAX_ENTRIES; + const visible = capped ? entries.slice(0, MAX_ENTRIES) : entries; + + return ( + <> + {visible.map((entry) => { + const entryRelPath = parentRelPath ? `${parentRelPath}/${entry.name}` : entry.name; + const isDir = entry.type === 'directory'; + const isExpanded = expandedDirs.has(entryRelPath); + const isLoading = loadingDirs.has(entryRelPath); + const children = dirContents.get(entryRelPath); + + return ( + + { + if (isDir) { + handleDirClick(entryRelPath); + } else { + handleFileClick(entryRelPath, entry); + } + }} + /> + {isDir && isExpanded && children !== undefined && ( + children.length === 0 + ? ( +
+ Empty folder +
+ ) + : renderEntries(children, entryRelPath, depth + 1) + )} +
+ ); + })} + {capped && ( +
+ Showing {MAX_ENTRIES} of {entries.length} - refine in shell +
+ )} + + ); + } + + if (rootLoading) { + return ( +
+ + + +
+ ); + } + + if (error !== null) { + return ( +
+ {error} +
+ ); + } + + if (rootEntries === null || rootEntries.length === 0) { + return ( +
+ Empty folder +
+ ); + } + + return ( + +
+ {renderEntries(rootEntries, '', 0)} +
+
+ ); +} diff --git a/frontend/src/components/files/FileTreeNode.tsx b/frontend/src/components/files/FileTreeNode.tsx new file mode 100644 index 00000000..c2785f65 --- /dev/null +++ b/frontend/src/components/files/FileTreeNode.tsx @@ -0,0 +1,60 @@ +import { ChevronRight, ChevronDown, Folder, File, Link, Loader2 } from 'lucide-react'; +import type { FileEntry } from '@/lib/stackFilesApi'; +import { cn } from '@/lib/utils'; + +interface FileTreeNodeProps { + entry: FileEntry; + depth: number; + isSelected: boolean; + isExpanded?: boolean; + isLoading?: boolean; + onClick: () => void; +} + +export function FileTreeNode({ + entry, + depth, + isSelected, + isExpanded, + isLoading, + onClick, +}: FileTreeNodeProps) { + const isDir = entry.type === 'directory'; + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') onClick(); + }} + aria-expanded={isDir ? isExpanded : undefined} + className={cn( + 'flex items-center gap-1.5 py-0.5 cursor-pointer select-none rounded-sm', + isSelected + ? 'bg-accent text-accent-foreground' + : 'hover:bg-accent/50 text-foreground' + )} + style={{ paddingLeft: depth * 16 + 8 }} + > + {isDir && ( + isLoading + ? + : isExpanded + ? + : + )} + {isDir + ? + : entry.type === 'symlink' + ? + : + } + {entry.name} + {entry.isProtected && ( + + )} +
+ ); +} diff --git a/frontend/src/components/files/FileUploadDropzone.tsx b/frontend/src/components/files/FileUploadDropzone.tsx new file mode 100644 index 00000000..bcdf1136 --- /dev/null +++ b/frontend/src/components/files/FileUploadDropzone.tsx @@ -0,0 +1,85 @@ +import { useRef } from 'react'; +import { UploadCloud, Compass } from 'lucide-react'; +import { toast } from '@/components/ui/toast-store'; +import { useLicense } from '@/context/LicenseContext'; +import { uploadStackFile } from '@/lib/stackFilesApi'; + +const MAX_BYTES = 25 * 1024 * 1024; // 25 MB + +interface FileUploadDropzoneProps { + stackName: string; + currentDir: string; + onUploaded: () => void; +} + +export function FileUploadDropzone({ + stackName, + currentDir, + onUploaded, +}: FileUploadDropzoneProps) { + const { isPaid } = useLicense(); + const inputRef = useRef(null); + + if (!isPaid) { + return ( + + ); + } + + const handleFile = async (file: File) => { + if (file.size > MAX_BYTES) { + toast.error('File exceeds 25 MB.'); + return; + } + const loadingId = toast.loading(`Uploading ${file.name}...`); + try { + await uploadStackFile(stackName, currentDir, file); + toast.success('Uploaded.'); + onUploaded(); + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : 'Upload failed.'); + } finally { + toast.dismiss(loadingId); + } + }; + + const handleChange = (ev: React.ChangeEvent) => { + const file = ev.target.files?.[0]; + if (file) void handleFile(file); + ev.target.value = ''; + }; + + return ( + <> + +
inputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + inputRef.current?.click(); + } + }} + > + + Upload file +
+ + ); +} diff --git a/frontend/src/components/files/FileViewer.tsx b/frontend/src/components/files/FileViewer.tsx new file mode 100644 index 00000000..c3c4461b --- /dev/null +++ b/frontend/src/components/files/FileViewer.tsx @@ -0,0 +1,292 @@ +import { useState, useEffect, useMemo } from 'react'; +import Editor from '@monaco-editor/react'; +import { AlertCircle, FileIcon, Download, Lock, Loader2, Save } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { toast } from '@/components/ui/toast-store'; +import { useLicense } from '@/context/LicenseContext'; +import { readStackFile, writeStackFile, downloadStackFile } from '@/lib/stackFilesApi'; +import { extensionToLanguage } from '@/lib/monacoLanguages'; +import { formatBytes } from '@/lib/utils'; + +interface FileViewerProps { + stackName: string; + selectedPath: string | null; + canEdit: boolean; + isDarkMode: boolean; + onSaved?: () => void; +} + +function getFilename(path: string): string { + return path.split('/').pop() ?? path; +} + +interface SpecialFilePanelProps { + filename: string; + size: number; + label: string; + stackName: string; + relPath: string; + canDownload: boolean; +} + +function SpecialFilePanel({ + filename, + size, + label, + stackName, + relPath, + canDownload, +}: SpecialFilePanelProps) { + const [downloading, setDownloading] = useState(false); + + const handleDownload = async () => { + if (!canDownload) return; + setDownloading(true); + try { + const res = await downloadStackFile(stackName, relPath); + if (!res.ok) { + toast.error('Download failed.'); + return; + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 100); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Download failed.'); + } finally { + setDownloading(false); + } + }; + + return ( +
+ +
+

{filename}

+

{label} · {formatBytes(size)}

+
+ {canDownload ? ( + + ) : ( +
+ +
+ )} +
+ ); +} + +export function FileViewer({ + stackName, + selectedPath, + canEdit, + isDarkMode, + onSaved, +}: FileViewerProps) { + const { isPaid } = useLicense(); + + const [content, setContent] = useState(''); + const [originalContent, setOriginalContent] = useState(''); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [isBinary, setIsBinary] = useState(false); + const [isOversized, setIsOversized] = useState(false); + const [size, setSize] = useState(0); + + const readOnly = !canEdit || !isPaid; + + const editorOptions = useMemo( + () => ({ + readOnly, + minimap: { enabled: false }, + fontFamily: "'Geist Mono', monospace", + fontSize: 13, + padding: { top: 8 }, + scrollBeyondLastLine: false, + }), + [readOnly], + ); + + useEffect(() => { + if (!selectedPath) { + setContent(''); + setOriginalContent(''); + setIsBinary(false); + setIsOversized(false); + setError(null); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + setIsBinary(false); + setIsOversized(false); + + readStackFile(stackName, selectedPath) + .then((result) => { + if (cancelled) return; + setSize(result.size); + if (result.binary) { + setIsBinary(true); + } else if (result.oversized) { + setIsOversized(true); + } else { + const text = result.content ?? ''; + setContent(text); + setOriginalContent(text); + } + }) + .catch((e: unknown) => { + if (cancelled) return; + setError(e instanceof Error ? e.message : 'Failed to load file.'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [stackName, selectedPath]); + + const handleSave = async () => { + if (!selectedPath) return; + setSaving(true); + const loadingId = toast.loading('Saving...'); + try { + await writeStackFile(stackName, selectedPath, content); + setOriginalContent(content); + toast.success('Saved.'); + onSaved?.(); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Save failed.'); + } finally { + toast.dismiss(loadingId); + setSaving(false); + } + }; + + if (!selectedPath) { + return ( +
+ Select a file to view it +
+ ); + } + + if (loading) { + return ( +
+ + +
+ ); + } + + if (error) { + return ( +
+ +

{error}

+
+ ); + } + + const filename = getFilename(selectedPath); + const language = extensionToLanguage(filename); + + if (isBinary) { + return ( + + ); + } + + if (isOversized) { + return ( + + ); + } + + const hasChanges = content !== originalContent; + + return ( +
+
+ {filename} +
+ {readOnly && ( + + Read-only + + )} + {!readOnly && ( + + )} +
+
+ +
+ { + if (!readOnly) setContent(val ?? ''); + }} + theme={isDarkMode ? 'vs-dark' : 'light'} + options={editorOptions} + /> +
+
+ ); +} diff --git a/frontend/src/components/files/NewFolderDialog.tsx b/frontend/src/components/files/NewFolderDialog.tsx new file mode 100644 index 00000000..4202c708 --- /dev/null +++ b/frontend/src/components/files/NewFolderDialog.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { FolderPlus, Loader2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { toast } from '@/components/ui/toast-store'; +import { mkdirStackPath } from '@/lib/stackFilesApi'; + +interface NewFolderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + stackName: string; + currentDir: string; + onCreated: () => void; +} + +function isValidFolderName(name: string): boolean { + if (!name || name === '.' || name === '..') return false; + return /^[^/\\]+$/.test(name); +} + +export function NewFolderDialog({ + open, + onOpenChange, + stackName, + currentDir, + onCreated, +}: NewFolderDialogProps) { + const [name, setName] = useState(''); + const [creating, setCreating] = useState(false); + const [validationError, setValidationError] = useState(null); + + const handleClose = (next: boolean) => { + if (creating) return; + onOpenChange(next); + if (!next) { + setName(''); + setValidationError(null); + } + }; + + const handleCreate = async () => { + const trimmed = name.trim(); + if (!isValidFolderName(trimmed)) { + setValidationError('Folder name must not be empty, and must not contain / or \\.'); + return; + } + setValidationError(null); + setCreating(true); + const relPath = currentDir ? `${currentDir}/${trimmed}` : trimmed; + try { + await mkdirStackPath(stackName, relPath); + toast.success('Folder created.'); + onCreated(); + onOpenChange(false); + setName(''); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to create folder.'); + } finally { + setCreating(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') void handleCreate(); + }; + + return ( + + + + + + New Folder + + + Enter a name for the new folder. + + + +
+ + { + setName(e.target.value); + setValidationError(null); + }} + onKeyDown={handleKeyDown} + placeholder="my-folder" + disabled={creating} + autoFocus + /> + {validationError && ( +

{validationError}

+ )} +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/files/StackFileExplorer.tsx b/frontend/src/components/files/StackFileExplorer.tsx new file mode 100644 index 00000000..d8b52a4e --- /dev/null +++ b/frontend/src/components/files/StackFileExplorer.tsx @@ -0,0 +1,182 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Trash2, FolderPlus, Download, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui/toast-store'; +import { useLicense } from '@/context/LicenseContext'; +import { downloadStackFile } from '@/lib/stackFilesApi'; +import { FileTree } from './FileTree'; +import { FileViewer } from './FileViewer'; +import { FileUploadDropzone } from './FileUploadDropzone'; +import { NewFolderDialog } from './NewFolderDialog'; +import { DeleteFileConfirm } from './DeleteFileConfirm'; +import type { FileEntry } from '@/lib/stackFilesApi'; + +interface StackFileExplorerProps { + stackName: string; + canEdit: boolean; + isDarkMode: boolean; + onNavigateToCompose?: () => void; + onNavigateToEnv?: () => void; +} + +export function StackFileExplorer({ + stackName, + canEdit, + isDarkMode, + onNavigateToCompose, + onNavigateToEnv, +}: StackFileExplorerProps) { + const { isPaid } = useLicense(); + const [selectedPath, setSelectedPath] = useState(null); + const [selectedEntry, setSelectedEntry] = useState(null); + const [currentDir, setCurrentDir] = useState(''); + const [refreshKey, setRefreshKey] = useState(0); + const [deleteOpen, setDeleteOpen] = useState(false); + const [newFolderOpen, setNewFolderOpen] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + + useEffect(() => { + setSelectedPath(null); + setSelectedEntry(null); + setCurrentDir(''); + }, [stackName]); + + const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); + + const handleSelectFile = useCallback((relPath: string, entry: FileEntry) => { + setSelectedPath(relPath); + setSelectedEntry(entry); + const parts = relPath.split('/'); + parts.pop(); + setCurrentDir(parts.join('/')); + }, []); + + const handleDeleted = useCallback(() => { + setSelectedPath(null); + setSelectedEntry(null); + refresh(); + }, [refresh]); + + const handleDownload = async () => { + if (!selectedPath) return; + setIsDownloading(true); + try { + const res = await downloadStackFile(stackName, selectedPath); + if (!res.ok) { + toast.error('Download failed.'); + return; + } + const blob = await res.blob(); + const filename = selectedPath.split('/').pop() ?? selectedPath; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 100); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Download failed.'); + } finally { + setIsDownloading(false); + } + }; + + return ( +
+ {/* Left pane: tree + upload + new folder */} +
+
+
+ +
+ {isPaid && ( + + )} +
+
+ +
+
+ + {/* Right pane: action bar + viewer */} +
+ {selectedPath !== null && isPaid && ( +
+ + +
+ )} +
+ +
+
+ + + + +
+ ); +} diff --git a/frontend/src/components/files/__tests__/FileTree.test.tsx b/frontend/src/components/files/__tests__/FileTree.test.tsx new file mode 100644 index 00000000..a14444b3 --- /dev/null +++ b/frontend/src/components/files/__tests__/FileTree.test.tsx @@ -0,0 +1,170 @@ +/** + * Coverage for FileTree. + * + * Locks the expand/collapse behavior: root directory loaded on mount, + * subdirectory fetched on first expand, collapsed on second click, and + * re-expanded from cache (no second fetch) on third click. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { FileEntry } from '@/lib/stackFilesApi'; + +vi.mock('@/lib/stackFilesApi', () => ({ + listStackDirectory: vi.fn(), +})); + +vi.mock('@/components/ui/toast-store', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }, +})); + +// ScrollArea just renders children so the tree nodes are accessible in jsdom. +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@/components/ui/skeleton', () => ({ + Skeleton: () =>
, +})); + +import { listStackDirectory } from '@/lib/stackFilesApi'; +import { FileTree } from '../FileTree'; + +const mockListDir = listStackDirectory as unknown as ReturnType; + +function makeFile(name: string): FileEntry { + return { name, type: 'file', size: 100, mtime: 0, isProtected: false }; +} + +function makeDir(name: string): FileEntry { + return { name, type: 'directory', size: 0, mtime: 0, isProtected: false }; +} + +const ROOT_ENTRIES: FileEntry[] = [makeDir('src'), makeFile('README.md')]; +const SRC_ENTRIES: FileEntry[] = [makeFile('index.ts'), makeFile('app.ts')]; + +function fakeOk(entries: FileEntry[]): Promise { + return Promise.resolve(entries); +} + +const defaultProps = { + stackName: 'my-stack', + selectedPath: '', + onSelectFile: vi.fn(), +}; + +beforeEach(() => { + mockListDir.mockReset(); + defaultProps.onSelectFile = vi.fn(); +}); + +afterEach(() => vi.clearAllMocks()); + +describe('FileTree', () => { + it('fetches root entries on mount and renders them', async () => { + mockListDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + + render(); + + await waitFor(() => expect(mockListDir).toHaveBeenCalledWith('my-stack', '')); + expect(await screen.findByText('src')).toBeInTheDocument(); + expect(screen.getByText('README.md')).toBeInTheDocument(); + }); + + it('fetches subdirectory on first expand and shows children', async () => { + mockListDir + .mockReturnValueOnce(fakeOk(ROOT_ENTRIES)) + .mockReturnValueOnce(fakeOk(SRC_ENTRIES)); + + const user = userEvent.setup(); + render(); + + await screen.findByText('src'); + + // One call so far: root fetch. + expect(mockListDir).toHaveBeenCalledTimes(1); + + await user.click(screen.getByText('src')); + + await waitFor(() => expect(mockListDir).toHaveBeenCalledTimes(2)); + expect(mockListDir).toHaveBeenNthCalledWith(2, 'my-stack', 'src'); + + expect(await screen.findByText('index.ts')).toBeInTheDocument(); + expect(screen.getByText('app.ts')).toBeInTheDocument(); + }); + + it('collapses on second click (no additional fetch)', async () => { + mockListDir + .mockReturnValueOnce(fakeOk(ROOT_ENTRIES)) + .mockReturnValueOnce(fakeOk(SRC_ENTRIES)); + + const user = userEvent.setup(); + render(); + + await screen.findByText('src'); + + // Expand. + await user.click(screen.getByText('src')); + await screen.findByText('index.ts'); + + const callsAfterExpand = mockListDir.mock.calls.length; + + // Collapse. + await user.click(screen.getByText('src')); + await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument()); + + // No extra fetch should have happened. + expect(mockListDir).toHaveBeenCalledTimes(callsAfterExpand); + }); + + it('re-expands from cache on third click (no second fetch for that dir)', async () => { + mockListDir + .mockReturnValueOnce(fakeOk(ROOT_ENTRIES)) + .mockReturnValueOnce(fakeOk(SRC_ENTRIES)); + + const user = userEvent.setup(); + render(); + + await screen.findByText('src'); + + // First click: expand (fetches subdirectory). + await user.click(screen.getByText('src')); + await screen.findByText('index.ts'); + + // Second click: collapse. + await user.click(screen.getByText('src')); + await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument()); + + const callsAfterCollapse = mockListDir.mock.calls.length; + + // Third click: re-expand from cache. + await user.click(screen.getByText('src')); + await screen.findByText('index.ts'); + + // Fetch count must not have increased. + expect(mockListDir).toHaveBeenCalledTimes(callsAfterCollapse); + }); + + it('shows error message when root fetch fails', async () => { + mockListDir.mockRejectedValue(new Error('Network error')); + + render(); + + expect(await screen.findByText('Network error')).toBeInTheDocument(); + }); + + it('shows empty state when root returns no entries', async () => { + mockListDir.mockReturnValue(fakeOk([])); + + render(); + + expect(await screen.findByText(/empty folder/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/files/__tests__/FileViewer.test.tsx b/frontend/src/components/files/__tests__/FileViewer.test.tsx new file mode 100644 index 00000000..2d7b1e36 --- /dev/null +++ b/frontend/src/components/files/__tests__/FileViewer.test.tsx @@ -0,0 +1,180 @@ +/** + * Coverage for FileViewer. + * + * Locks the three content-render modes: Monaco editor for text files, + * binary panel for binary files, and oversized panel for files that + * exceed the preview limit. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import type { FileContentResult } from '@/lib/stackFilesApi'; + +vi.mock('@monaco-editor/react', () => ({ + default: () =>
, +})); + +vi.mock('@/lib/stackFilesApi', () => ({ + readStackFile: vi.fn(), + writeStackFile: vi.fn(), + downloadStackFile: vi.fn(), +})); + +vi.mock('@/components/ui/toast-store', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(() => 'loading-id'), + dismiss: vi.fn(), + }, +})); + +vi.mock('@/components/ui/skeleton', () => ({ + Skeleton: () =>
, +})); + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + disabled, + onClick, + }: { + children: React.ReactNode; + disabled?: boolean; + onClick?: () => void; + }) => ( + + ), +})); + +vi.mock('@/lib/utils', () => ({ + formatBytes: (n: number) => `${n}B`, + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})); + +vi.mock('@/lib/monacoLanguages', () => ({ + extensionToLanguage: () => 'plaintext', +})); + +const licenseState = { isPaid: true }; +vi.mock('@/context/LicenseContext', () => ({ + useLicense: () => licenseState, +})); + +import { readStackFile } from '@/lib/stackFilesApi'; +import { FileViewer } from '../FileViewer'; + +const mockReadFile = readStackFile as unknown as ReturnType; + +function textResult(content = 'hello world'): FileContentResult { + return { content, binary: false, oversized: false, size: content.length, mime: 'text/plain' }; +} + +function binaryResult(): FileContentResult { + return { binary: true, oversized: false, size: 1024, mime: 'application/octet-stream' }; +} + +function oversizedResult(): FileContentResult { + return { binary: false, oversized: true, size: 5_000_000, mime: 'text/plain' }; +} + +const defaultProps = { + stackName: 'my-stack', + canEdit: true, + isDarkMode: false, +}; + +beforeEach(() => { + mockReadFile.mockReset(); + licenseState.isPaid = true; +}); + +afterEach(() => vi.clearAllMocks()); + +describe('FileViewer', () => { + it('shows "Select a file" placeholder when selectedPath is null', () => { + render(); + expect(screen.getByText(/select a file/i)).toBeInTheDocument(); + expect(mockReadFile).not.toHaveBeenCalled(); + }); + + it('renders Monaco editor for a regular text file', async () => { + mockReadFile.mockResolvedValue(textResult()); + + render(); + + await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument()); + expect(screen.queryByText(/binary file/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/too large/i)).not.toBeInTheDocument(); + }); + + it('calls readStackFile with the correct stack name and path', async () => { + mockReadFile.mockResolvedValue(textResult()); + + render(); + + await waitFor(() => expect(mockReadFile).toHaveBeenCalledWith('my-stack', 'src/index.ts')); + }); + + it('renders binary panel (not Monaco) for a binary file', async () => { + mockReadFile.mockResolvedValue(binaryResult()); + + render(); + + expect(await screen.findByText(/binary file/i)).toBeInTheDocument(); + expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument(); + }); + + it('renders oversized panel (not Monaco) when file is too large to preview', async () => { + mockReadFile.mockResolvedValue(oversizedResult()); + + render(); + + expect(await screen.findByText(/too large to preview/i)).toBeInTheDocument(); + expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument(); + }); + + it('renders error message when readStackFile rejects', async () => { + mockReadFile.mockRejectedValue(new Error('Not found')); + + render(); + + expect(await screen.findByText('Not found')).toBeInTheDocument(); + expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument(); + }); + + it('shows Download button when user has a paid tier', async () => { + mockReadFile.mockResolvedValue(binaryResult()); + + render(); + + await screen.findByText(/binary file/i); + const downloadBtn = screen.getByRole('button', { name: /download/i }); + expect(downloadBtn).not.toBeDisabled(); + }); + + it('shows disabled Download button for community tier', async () => { + licenseState.isPaid = false; + mockReadFile.mockResolvedValue(binaryResult()); + + render(); + + await screen.findByText(/binary file/i); + const downloadBtn = screen.getByRole('button', { name: /download/i }); + expect(downloadBtn).toBeDisabled(); + }); + + it('re-fetches when selectedPath changes', async () => { + mockReadFile.mockResolvedValue(textResult()); + + const { rerender } = render(); + await waitFor(() => expect(mockReadFile).toHaveBeenCalledTimes(1)); + + rerender(); + await waitFor(() => expect(mockReadFile).toHaveBeenCalledTimes(2)); + expect(mockReadFile).toHaveBeenNthCalledWith(2, 'my-stack', 'b.txt'); + }); +}); diff --git a/frontend/src/lib/monacoLanguages.ts b/frontend/src/lib/monacoLanguages.ts new file mode 100644 index 00000000..6e869e4c --- /dev/null +++ b/frontend/src/lib/monacoLanguages.ts @@ -0,0 +1,56 @@ +export function extensionToLanguage(filename: string): string { + if (filename === 'Dockerfile') return 'dockerfile'; + + // Dotfiles with no secondary dot (e.g. ".gitignore") have no meaningful + // extension, but ".env*" files should be treated as ini. + const basename = filename.includes('/') ? filename.slice(filename.lastIndexOf('/') + 1) : filename; + + if (basename === '.env' || /^\.env(\.|$)/.test(basename)) return 'ini'; + + const dotIndex = basename.lastIndexOf('.'); + + // No extension, or the only dot is the leading dot of a dotfile. + if (dotIndex <= 0) return 'plaintext'; + + const ext = basename.slice(dotIndex).toLowerCase(); + + switch (ext) { + case '.yaml': + case '.yml': + return 'yaml'; + case '.json': + case '.jsonc': + return 'json'; + case '.ts': + case '.tsx': + return 'typescript'; + case '.js': + case '.jsx': + case '.mjs': + case '.cjs': + return 'javascript'; + case '.sh': + case '.bash': + case '.zsh': + return 'shell'; + case '.py': + return 'python'; + case '.toml': + return 'ini'; + case '.ini': + case '.conf': + case '.cfg': + return 'ini'; + case '.xml': + case '.html': + case '.htm': + return 'html'; + case '.css': + return 'css'; + case '.md': + case '.mdx': + return 'markdown'; + default: + return 'plaintext'; + } +} diff --git a/frontend/src/lib/stackFilesApi.ts b/frontend/src/lib/stackFilesApi.ts new file mode 100644 index 00000000..b3e3dcf1 --- /dev/null +++ b/frontend/src/lib/stackFilesApi.ts @@ -0,0 +1,134 @@ +import { apiFetch } from './api'; + +export interface FileEntry { + name: string; + type: 'file' | 'directory' | 'symlink'; + size: number; + mtime: number; + isProtected: boolean; +} + +export interface FileContentResult { + content?: string; + binary: boolean; + oversized: boolean; + size: number; + mime: string; +} + +export async function parseApiError(res: Response): Promise { + try { + const data = await res.json(); + return (data as { error?: string }).error ?? `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } +} + +function stackFilesUrl(stackName: string, suffix: string): string { + return `/stacks/${encodeURIComponent(stackName)}/files${suffix}`; +} + +export async function listStackDirectory( + stackName: string, + relPath: string +): Promise { + const res = await apiFetch(stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}`)); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +} + +export async function readStackFile( + stackName: string, + relPath: string +): Promise { + const res = await apiFetch(stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`)); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +} + +export async function downloadStackFile( + stackName: string, + relPath: string +): Promise { + return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(relPath)}`)); +} + +export async function uploadStackFile( + stackName: string, + targetDir: string, + file: File, + options?: { localOnly?: boolean } +): Promise { + const fd = new FormData(); + fd.append('file', file, file.name); + + const activeNodeId = options?.localOnly ? null : localStorage.getItem('sencho-active-node'); + const headers: Record = {}; + if (activeNodeId) { + headers['x-node-id'] = activeNodeId; + } + + // Use fetch directly: apiFetch always sets Content-Type: application/json, + // which breaks multipart boundary negotiation. The 401 side-effects are + // replicated manually below. + const res = await fetch( + `/api${stackFilesUrl(stackName, `/upload?path=${encodeURIComponent(targetDir)}`)}`, + { method: 'POST', credentials: 'include', headers, body: fd } + ); + + if (res.status === 401) { + if (!res.headers.get('x-sencho-proxy')) { + window.dispatchEvent(new Event('sencho-unauthorized')); + } + throw new Error('Unauthorized'); + } + + if (!res.ok) { + if (res.status === 404) { + try { + const clone = res.clone(); + const errData = await clone.json(); + if (errData.error?.includes('not found') && errData.error?.includes('Node')) { + window.dispatchEvent(new Event('node-not-found')); + } + } catch { /* ignore */ } + } + throw new Error(await parseApiError(res)); + } +} + +export async function writeStackFile( + stackName: string, + relPath: string, + content: string +): Promise { + const res = await apiFetch( + stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`), + { method: 'PUT', body: JSON.stringify({ content }) } + ); + if (!res.ok) throw new Error(await parseApiError(res)); +} + +export async function deleteStackPath( + stackName: string, + relPath: string, + recursive?: boolean +): Promise { + const qs = recursive + ? `path=${encodeURIComponent(relPath)}&recursive=1` + : `path=${encodeURIComponent(relPath)}`; + const res = await apiFetch(stackFilesUrl(stackName, `?${qs}`), { method: 'DELETE' }); + if (!res.ok) throw new Error(await parseApiError(res)); +} + +export async function mkdirStackPath( + stackName: string, + relPath: string +): Promise { + const res = await apiFetch( + stackFilesUrl(stackName, `/folder?path=${encodeURIComponent(relPath)}`), + { method: 'POST', body: JSON.stringify({}) } + ); + if (!res.ok) throw new Error(await parseApiError(res)); +}