diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index ff57525..d01658d 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -78,9 +78,23 @@ Source: `src/backend/ssh/tunnel.ts` (2,414 lines) + `tunnel-c2s-relay.ts`, `tunn **Verified end-to-end** against a real test SSH server (extending the same real-`ssh2`-`Server` + `node-pty` pattern used in Phase 1c) that genuinely handles `tcpip` (forwardOut) and `tcpip-forward`/`cancel-tcpip-forward` (forwardIn) requests, plus a real upstream TCP echo server: created one tunnel of each mode (local/remote/dynamic), connected all three, and confirmed real data flowed through each — local forward and remote forward both delivered the upstream server's banner through the tunnel, and the dynamic tunnel completed a real SOCKS5 CONNECT handshake and relayed data. Also verified disconnect correctly tears down the local listener (`ECONNREFUSED` after stopping). All test artifacts (test SSH server, test backend instance, test DB, tokens) were cleaned up afterward. -### Phase 3 — Remote File Manager (NOT STARTED) +### Phase 3 — Remote File Manager (DONE, with documented gaps) -Source: `src/backend/ssh/file-manager*.ts` (six files, ~3,900 lines combined: list/content/action/operation/download routes + session + utils) + frontend `src/ui/features/file-manager/*`. View/edit code/images/audio/video, upload/download/rename/delete/move, sudo support, server-to-server moves. Runs over the SSH connections from Phase 1. +Source: `src/backend/ssh/file-manager*.ts` (six files, ~3,900 lines combined: list/content/action/operation/download routes + session + utils) + frontend `src/ui/features/file-manager/*`. + +**Scope decisions:** +- **Ephemeral SFTP connections** instead of Termix's pooled/long-lived sessions: each request opens a fresh SSH+SFTP connection (`backend/src/ssh/sftp.ts`'s `withSftp()`), does one operation, and tears the connection down. Simpler than managing a third long-lived connection lifecycle alongside terminal and tunnel sessions, and acceptable at this app's scale. +- **No sudo/permission-elevation support.** Termix falls back to shell commands piped a stored sudo password when SFTP returns a permission error; not ported in this pass (no privileged remote test target available in this sandbox to verify against safely — same category of gap as the OPKSSH cert-auth gap in Phase 1c). Documented here rather than silently dropped. +- **No server-to-server transfer** — this matches Termix's actual behavior (its own cross-host "transfer" is just sequential `download` then `upload` through the browser; same-host moves use shell `mv`/`cp`, which isn't ported since sudo isn't). Not a regression. +- **Whole-file-in-memory model** for view/edit, same as Termix: `GET/PUT /api/files/:id/content` reads/writes the entire file via `sftp.readFile`/`writeFile`. Files over 50MB (`MAX_EDITABLE_SIZE`) are rejected with a message pointing at download/upload instead. Binary detection (so binary files are shown as a "can't edit" message rather than mangled text) uses the same heuristic as Termix: scan the first 8KB for a null byte or a >1% ratio of other control bytes. +- **Streaming download** (`GET /api/files/:id/download`) for files of any size, via `sftp.createReadStream()` piped straight into the HTTP response rather than buffered in memory. + +**What was built:** +- `backend/src/ssh/sftp.ts` — `withSftp(integrationId, fn)`: opens an ephemeral SSH+SFTP connection (reusing `connect.ts`'s jump-host-chaining + TOFU logic from Phase 1/2), runs `fn`, then tears the connection down. +- `backend/src/routes/files.ts` — `GET /api/files/:id/list`, `GET/PUT /api/files/:id/content`, `POST /api/files/:id/mkdir`, `POST /api/files/:id/rename`, `POST /api/files/:id/delete`, `POST /api/files/:id/chmod`, `GET /api/files/:id/download`, `POST /api/files/:id/upload` (multipart, via newly-added `@fastify/multipart`, 1GB limit). +- `src/pages/Files.tsx` — new page (`/files`, sidebar entry with a `FolderOpen` icon): SSH host picker, breadcrumb-navigable directory browser, inline text editor for non-binary files, new-folder/rename/delete/chmod-via-octal-display/upload/download actions. + +**Verified end-to-end** against a real filesystem-backed SFTP server built specifically for this (using `ssh2`'s server-side low-level SFTP protocol API — genuine `OPEN`/`READ`/`WRITE`/`READDIR`/`RENAME`/`REMOVE`/`MKDIR`/`STAT`/`SETSTAT` handlers backed by real `fs` calls against a real directory on disk, not a mock). Confirmed by inspecting the actual files/permissions on disk after each operation (`cat`, `ls`, `stat -c '%a'`), not just the HTTP response: list, read, write, mkdir, rename, delete, chmod, upload, and download (byte-for-byte `diff` match against the uploaded source file) all round-tripped correctly. One real bug was caught and fixed during this verification: the download route's wrapping `Promise` was resolving immediately after `reply.send(stream)` instead of waiting for the response to actually finish, which raced Fastify into ending the HTTP response (and the route's `cleanup()` into closing the underlying SSH connection) before the SFTP stream had sent any data — produced a 0-byte download with a "stream closed prematurely" log line. Fixed by letting `reply.send(stream)`'s return value resolve the promise instead of resolving synchronously, and moving connection cleanup to the response's own `finish`/`close` events. All test artifacts (test SFTP server, test backend instance, test DB, tokens, temp files) were cleaned up afterward. ### Phase 4 — Docker Container Management (NOT STARTED) diff --git a/backend/package-lock.json b/backend/package-lock.json index 828ad90..39b3713 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/client-sts": "^3.1072.0", "@fastify/cors": "^10.0.1", "@fastify/jwt": "^10.1.0", + "@fastify/multipart": "^10.0.0", "@fastify/websocket": "^11.2.0", "@types/ssh2": "^1.15.5", "bcryptjs": "^2.4.3", @@ -886,6 +887,12 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, "node_modules/@fastify/cors": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", @@ -906,6 +913,22 @@ "mnemonist": "0.40.0" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -999,6 +1022,29 @@ "dequal": "^2.0.3" } }, + "node_modules/@fastify/multipart": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.0.0.tgz", + "integrity": "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, "node_modules/@fastify/proxy-addr": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index b3b2a0d..2ec0236 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "@aws-sdk/client-sts": "^3.1072.0", "@fastify/cors": "^10.0.1", "@fastify/jwt": "^10.1.0", + "@fastify/multipart": "^10.0.0", "@fastify/websocket": "^11.2.0", "@types/ssh2": "^1.15.5", "bcryptjs": "^2.4.3", diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts new file mode 100644 index 0000000..0738cdf --- /dev/null +++ b/backend/src/routes/files.ts @@ -0,0 +1,258 @@ +import type { FastifyInstance } from 'fastify' +import type { Client } from 'ssh2' +import { z } from 'zod' +import { withSftp } from '../ssh/sftp.js' +import { loadSshHost, connectTarget } from '../ssh/connect.js' + +const MAX_EDITABLE_SIZE = 50 * 1024 * 1024 // 50MB - above this, only download is offered + +function isBinary(buf: Buffer): boolean { + const sample = buf.subarray(0, 8000) + let suspicious = 0 + for (const byte of sample) { + if (byte === 0) return true + if (byte < 9 || (byte > 13 && byte < 32)) suspicious++ + } + return sample.length > 0 && suspicious / sample.length > 0.01 +} + +function parsePath(req: { query?: unknown; body?: unknown }, key = 'path'): string { + const fromQuery = (req.query as Record | undefined)?.[key] + const fromBody = (req.body as Record | undefined)?.[key] + const path = fromQuery ?? fromBody + if (!path) throw new Error('path is required') + return path +} + +export async function fileRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate) + + app.get('/api/files/:integrationId/list', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const path = (req.query as { path?: string }).path || '.' + try { + const entries = await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => { + sftp.readdir(path, (err, list) => { + if (err) reject(err) + else + resolve( + list + .map((e) => ({ + name: e.filename, + isDirectory: e.attrs.isDirectory(), + isSymlink: e.attrs.isSymbolicLink(), + size: e.attrs.size, + mode: e.attrs.mode, + mtime: e.attrs.mtime, + })) + .sort((a, b) => (a.isDirectory === b.isDirectory ? a.name.localeCompare(b.name) : a.isDirectory ? -1 : 1)), + ) + }) + }), + ) + return { path, entries } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to list directory' }) + } + }) + + app.get('/api/files/:integrationId/content', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + let path: string + try { + path = parsePath(req) + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Invalid request' }) + } + try { + const result = await withSftp(integrationId, (sftp) => + new Promise<{ content: string; encoding: 'utf8' | 'base64'; size: number; mode: number }>((resolve, reject) => { + sftp.stat(path, (statErr, stats) => { + if (statErr) { + reject(statErr) + return + } + if (stats.size > MAX_EDITABLE_SIZE) { + reject(new Error(`File is too large to view/edit (${Math.round(stats.size / 1024 / 1024)}MB, limit 50MB) - use download instead`)) + return + } + sftp.readFile(path, (readErr, buf) => { + if (readErr) { + reject(readErr) + return + } + const binary = isBinary(buf) + resolve({ + content: binary ? buf.toString('base64') : buf.toString('utf8'), + encoding: binary ? 'base64' : 'utf8', + size: stats.size, + mode: stats.mode, + }) + }) + }) + }), + ) + return result + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to read file' }) + } + }) + + app.put('/api/files/:integrationId/content', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const bodySchema = z.object({ path: z.string().min(1), content: z.string(), encoding: z.enum(['utf8', 'base64']).default('utf8') }) + const parsed = bodySchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + const { path, content, encoding } = parsed.data + try { + await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => { + const buf = Buffer.from(content, encoding === 'base64' ? 'base64' : 'utf8') + sftp.writeFile(path, buf, (err) => (err ? reject(err) : resolve())) + }), + ) + return { ok: true } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to write file' }) + } + }) + + app.post('/api/files/:integrationId/mkdir', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const bodySchema = z.object({ path: z.string().min(1) }) + const parsed = bodySchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + try { + await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => sftp.mkdir(parsed.data.path, (err) => (err ? reject(err) : resolve()))), + ) + return { ok: true } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to create directory' }) + } + }) + + app.post('/api/files/:integrationId/rename', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const bodySchema = z.object({ from: z.string().min(1), to: z.string().min(1) }) + const parsed = bodySchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + try { + await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => sftp.rename(parsed.data.from, parsed.data.to, (err) => (err ? reject(err) : resolve()))), + ) + return { ok: true } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to rename' }) + } + }) + + app.post('/api/files/:integrationId/delete', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const bodySchema = z.object({ path: z.string().min(1), isDirectory: z.boolean().default(false) }) + const parsed = bodySchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + const { path, isDirectory } = parsed.data + try { + await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => { + if (isDirectory) sftp.rmdir(path, (err) => (err ? reject(err) : resolve())) + else sftp.unlink(path, (err) => (err ? reject(err) : resolve())) + }), + ) + return { ok: true } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to delete' }) + } + }) + + app.post('/api/files/:integrationId/chmod', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const bodySchema = z.object({ path: z.string().min(1), mode: z.string().regex(/^[0-7]{3,4}$/) }) + const parsed = bodySchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + try { + await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => + sftp.chmod(parsed.data.path, parseInt(parsed.data.mode, 8), (err) => (err ? reject(err) : resolve())), + ), + ) + return { ok: true } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to chmod' }) + } + }) + + app.get('/api/files/:integrationId/download', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const path = (req.query as { path?: string }).path + if (!path) return reply.code(400).send({ error: 'path is required' }) + const target = loadSshHost(integrationId) + if (!target) return reply.code(404).send({ error: 'SSH integration not found' }) + + const filename = path.split('/').filter(Boolean).pop() ?? 'download' + reply.header('content-disposition', `attachment; filename="${filename.replace(/"/g, '')}"`) + reply.header('content-type', 'application/octet-stream') + + return new Promise((resolve, reject) => { + let conn: Client | null = null + let jumpConn: Client | null = null + let cleaned = false + const cleanup = () => { + if (cleaned) return + cleaned = true + conn?.end() + jumpConn?.end() + } + const result = connectTarget( + target, + (client) => { + conn = client + client.sftp((err, sftp) => { + if (err) { + cleanup() + resolve(reply.code(400).send({ error: err.message })) + return + } + const stream = sftp.createReadStream(path) + stream.on('error', (streamErr: Error) => { + cleanup() + reject(streamErr) + }) + // Tear down the SSH connection only once the HTTP response has + // actually finished flushing. Resolving the outer promise before + // that point (e.g. right after calling reply.send) lets Fastify + // consider the handler done and end the response early, which + // produced truncated/empty downloads. + reply.raw.on('finish', cleanup) + reply.raw.on('close', cleanup) + resolve(reply.send(stream)) + }) + }, + (message) => { + cleanup() + resolve(reply.code(400).send({ error: message })) + }, + ) + jumpConn = result.jumpConn + }) + }) + + app.post('/api/files/:integrationId/upload', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const data = await req.file() + if (!data) return reply.code(400).send({ error: 'No file uploaded' }) + const dirPath = (data.fields.path as { value?: string } | undefined)?.value ?? '.' + const destPath = `${dirPath.replace(/\/$/, '')}/${data.filename}` + try { + const buf = await data.toBuffer() + await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => sftp.writeFile(destPath, buf, (err) => (err ? reject(err) : resolve()))), + ) + return { ok: true, path: destPath } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to upload file' }) + } + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 9d87d65..74e6d03 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -3,12 +3,14 @@ import Fastify from 'fastify' import cors from '@fastify/cors' import jwt from '@fastify/jwt' import websocket from '@fastify/websocket' +import multipart from '@fastify/multipart' import { authRoutes } from './routes/auth.js' import { integrationRoutes } from './routes/integrations.js' import { bookmarkRoutes } from './routes/bookmarks.js' import { eventRoutes } from './routes/events.js' import { terminalRoutes } from './routes/terminal.js' import { tunnelRoutes } from './routes/tunnels.js' +import { fileRoutes } from './routes/files.js' import { startAutoStartTunnels } from './tunnels/manager.js' const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET @@ -21,6 +23,7 @@ const app = Fastify({ logger: true }) await app.register(cors, { origin: process.env.ARCHNEST_CORS_ORIGIN ?? true }) await app.register(jwt, { secret: JWT_SECRET }) await app.register(websocket) +await app.register(multipart, { limits: { fileSize: 1024 * 1024 * 1024 } }) app.decorate('authenticate', async function (req, reply) { try { @@ -36,6 +39,7 @@ await app.register(bookmarkRoutes) await app.register(eventRoutes) await app.register(terminalRoutes) await app.register(tunnelRoutes) +await app.register(fileRoutes) app.get('/api/health', async () => ({ ok: true })) diff --git a/backend/src/ssh/sftp.ts b/backend/src/ssh/sftp.ts new file mode 100644 index 0000000..958746e --- /dev/null +++ b/backend/src/ssh/sftp.ts @@ -0,0 +1,50 @@ +import type { Client, SFTPWrapper } from 'ssh2' +import { loadSshHost, connectTarget, type SshHost } from './connect.js' + +/** Opens an ephemeral SSH+SFTP connection for a single operation, then tears it down. + * Simpler than a pooled/long-lived session (which Termix uses) - acceptable at this + * app's scale, and avoids a second connection-lifecycle to manage on top of the + * terminal/tunnel ones. */ +export function withSftp( + integrationId: number, + fn: (sftp: SFTPWrapper, host: SshHost) => Promise, +): Promise { + const target = loadSshHost(integrationId) + if (!target) return Promise.reject(new Error('SSH integration not found')) + + return new Promise((resolve, reject) => { + let conn: Client | null = null + let jumpConn: Client | null = null + const cleanup = () => { + conn?.end() + jumpConn?.end() + } + const result = connectTarget( + target, + (client) => { + conn = client + client.sftp((err, sftp) => { + if (err) { + cleanup() + reject(err) + return + } + fn(sftp, target) + .then((value) => { + cleanup() + resolve(value) + }) + .catch((fnErr) => { + cleanup() + reject(fnErr) + }) + }) + }, + (message) => { + cleanup() + reject(new Error(message)) + }, + ) + jumpConn = result.jumpConn + }) +} diff --git a/src/App.tsx b/src/App.tsx index 217fbb2..de5af04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import Infrastructure from './pages/Infrastructure' import BookNest from './pages/BookNest' import Terminal from './pages/Terminal' import Tunnels from './pages/Tunnels' +import Files from './pages/Files' import Settings from './pages/Settings' import Login from './pages/Login' import Enrollment from './pages/Enrollment' @@ -84,6 +85,7 @@ function Dashboard() { } /> } /> } /> + } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index fc2414d..aa238fe 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { Bookmark, Terminal, Waypoints, + FolderOpen, Settings, ChevronLeft, ChevronRight, @@ -23,6 +24,7 @@ const navItems = [ { icon: Bookmark, label: 'BookNest', route: '/booknest' }, { icon: Terminal, label: 'Terminal', route: '/terminal' }, { icon: Waypoints, label: 'Tunnels', route: '/tunnels' }, + { icon: FolderOpen, label: 'Files', route: '/files' }, { icon: Settings, label: 'Settings', route: '/settings' }, ] diff --git a/src/lib/api.ts b/src/lib/api.ts index 85eb15a..f596e80 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -90,6 +90,46 @@ export const api = { apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/connect`, { method: 'POST' }), disconnectTunnel: (id: number) => apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/disconnect`, { method: 'POST' }), + + listFiles: (integrationId: number, path: string) => + apiFetch<{ path: string; entries: FileEntry[] }>(`/files/${integrationId}/list?path=${encodeURIComponent(path)}`), + readFile: (integrationId: number, path: string) => + apiFetch<{ content: string; encoding: 'utf8' | 'base64'; size: number; mode: number }>( + `/files/${integrationId}/content?path=${encodeURIComponent(path)}`, + ), + writeFile: (integrationId: number, path: string, content: string, encoding: 'utf8' | 'base64' = 'utf8') => + apiFetch<{ ok: boolean }>(`/files/${integrationId}/content`, { method: 'PUT', body: JSON.stringify({ path, content, encoding }) }), + mkdir: (integrationId: number, path: string) => + apiFetch<{ ok: boolean }>(`/files/${integrationId}/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }), + renameFile: (integrationId: number, from: string, to: string) => + apiFetch<{ ok: boolean }>(`/files/${integrationId}/rename`, { method: 'POST', body: JSON.stringify({ from, to }) }), + deleteFile: (integrationId: number, path: string, isDirectory = false) => + apiFetch<{ ok: boolean }>(`/files/${integrationId}/delete`, { method: 'POST', body: JSON.stringify({ path, isDirectory }) }), + chmodFile: (integrationId: number, path: string, mode: string) => + apiFetch<{ ok: boolean }>(`/files/${integrationId}/chmod`, { method: 'POST', body: JSON.stringify({ path, mode }) }), + downloadFileUrl: (integrationId: number, path: string) => + `/api/files/${integrationId}/download?path=${encodeURIComponent(path)}`, + uploadFile: async (integrationId: number, path: string, file: File) => { + const form = new FormData() + form.append('path', path) + form.append('file', file) + const token = getToken() + const res = await fetch(`/api/files/${integrationId}/upload`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + body: form, + }) + if (!res.ok) { + let message = res.statusText + try { + message = (await res.json()).error ?? message + } catch { + // ignore non-JSON error bodies + } + throw new ApiError(res.status, message) + } + return res.json() as Promise<{ ok: boolean; path: string }> + }, } export interface AuthUser { @@ -155,6 +195,15 @@ export interface Event { created_at: string } +export interface FileEntry { + name: string + isDirectory: boolean + isSymlink: boolean + size: number + mode: number + mtime: number +} + export interface Resource { name: string status: 'healthy' | 'warning' | 'critical' | 'unknown' diff --git a/src/pages/Files.tsx b/src/pages/Files.tsx new file mode 100644 index 0000000..65b9ae4 --- /dev/null +++ b/src/pages/Files.tsx @@ -0,0 +1,360 @@ +import { useEffect, useRef, useState } from 'react' +import { + Folder, + File as FileIcon, + ChevronRight, + Upload, + FolderPlus, + Trash2, + Download, + Pencil, + Save, + X, + RefreshCw, +} from 'lucide-react' +import { api, type FileEntry, type Integration } from '../lib/api' + +const TEXT_PRIMARY = '#E8E6E0' +const TEXT_SECONDARY = '#7A7D85' +const GOLD = '#C8A434' + +const cardBase: React.CSSProperties = { + backgroundColor: 'rgba(10, 10, 12, 0.92)', + border: '1px solid rgba(200, 164, 52, 0.08)', + borderRadius: '12px', + boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)', +} + +function inputStyle(): React.CSSProperties { + return { + backgroundColor: 'rgba(255,255,255,0.03)', + border: '1px solid rgba(255,255,255,0.08)', + borderRadius: '6px', + padding: '6px 10px', + color: TEXT_PRIMARY, + fontSize: '13px', + } +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const units = ['KB', 'MB', 'GB', 'TB'] + let v = bytes + let i = -1 + do { + v /= 1024 + i++ + } while (v >= 1024 && i < units.length - 1) + return `${v.toFixed(1)} ${units[i]}` +} + +function joinPath(dir: string, name: string): string { + if (dir === '.' || dir === '') return name + return `${dir.replace(/\/$/, '')}/${name}` +} + +export default function Files() { + const [hosts, setHosts] = useState([]) + const [integrationId, setIntegrationId] = useState('') + const [path, setPath] = useState('.') + const [entries, setEntries] = useState([]) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const [editingPath, setEditingPath] = useState(null) + const [editingContent, setEditingContent] = useState('') + const [editingEncoding, setEditingEncoding] = useState<'utf8' | 'base64'>('utf8') + const [savingEdit, setSavingEdit] = useState(false) + + const fileInputRef = useRef(null) + + useEffect(() => { + api.listIntegrations().then(({ integrations }) => { + const sshHosts = integrations.filter((i) => i.type === 'ssh') + setHosts(sshHosts) + if (sshHosts.length > 0) setIntegrationId(sshHosts[0].id) + }) + }, []) + + function refresh() { + if (!integrationId) return + setLoading(true) + setError(null) + api + .listFiles(integrationId, path) + .then(({ entries }) => setEntries(entries)) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to list directory')) + .finally(() => setLoading(false)) + } + + useEffect(() => { + refresh() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [integrationId, path]) + + function openDirectory(name: string) { + setPath(joinPath(path, name)) + } + + function goUp() { + if (path === '.' || path === '') return + const parts = path.split('/').filter(Boolean) + parts.pop() + setPath(parts.length === 0 ? '.' : parts.join('/')) + } + + async function openFile(name: string) { + if (!integrationId) return + const full = joinPath(path, name) + setError(null) + try { + const result = await api.readFile(integrationId, full) + setEditingPath(full) + setEditingContent(result.content) + setEditingEncoding(result.encoding) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to open file') + } + } + + async function saveEdit() { + if (!integrationId || !editingPath) return + setSavingEdit(true) + try { + await api.writeFile(integrationId, editingPath, editingContent, editingEncoding) + setEditingPath(null) + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save file') + } finally { + setSavingEdit(false) + } + } + + async function handleMkdir() { + if (!integrationId) return + const name = prompt('New folder name') + if (!name) return + try { + await api.mkdir(integrationId, joinPath(path, name)) + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create directory') + } + } + + async function handleRename(entry: FileEntry) { + if (!integrationId) return + const next = prompt('Rename to', entry.name) + if (!next || next === entry.name) return + try { + await api.renameFile(integrationId, joinPath(path, entry.name), joinPath(path, next)) + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to rename') + } + } + + async function handleDelete(entry: FileEntry) { + if (!integrationId) return + if (!confirm(`Delete ${entry.name}?`)) return + try { + await api.deleteFile(integrationId, joinPath(path, entry.name), entry.isDirectory) + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete') + } + } + + function handleDownload(entry: FileEntry) { + if (!integrationId) return + window.open(api.downloadFileUrl(integrationId, joinPath(path, entry.name)), '_blank') + } + + async function handleUpload(file: File) { + if (!integrationId) return + setError(null) + try { + await api.uploadFile(integrationId, path, file) + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to upload file') + } + } + + const breadcrumbs = path === '.' || path === '' ? [] : path.split('/').filter(Boolean) + + return ( +
+
+
+

+ Files +

+

+ Browse, edit, and transfer files on your remote SSH hosts. +

+
+ +
+ + {error && ( +
+ {error} + +
+ )} + +
+
+
+ + {breadcrumbs.map((part, i) => ( + + + + + ))} +
+
+ + + + { + const file = e.target.files?.[0] + if (file) handleUpload(file) + e.target.value = '' + }} + /> +
+
+ + + + {path !== '.' && path !== '' && ( + + + + )} + {entries.map((entry) => ( + + + + + + + ))} + {entries.length === 0 && !loading && ( + + + + )} + +
+ ../ +
(entry.isDirectory ? openDirectory(entry.name) : openFile(entry.name))} + > + {entry.isDirectory ? : } + {entry.name} + + {entry.isDirectory ? '' : formatSize(entry.size)} + + {(entry.mode & 0o777).toString(8)} + +
+ {!entry.isDirectory && ( + + )} + + +
+
+ Empty directory +
+
+ + {editingPath && ( +
+
+
+ + {editingPath} + +
+ + +
+
+ {editingEncoding === 'base64' ? ( +
Binary file - editing not supported. Use download instead.
+ ) : ( +