Phase 3: remote file manager (SFTP list/edit/upload/download/rename/delete/chmod)

Ephemeral per-request SFTP connections, whole-file-in-memory view/edit
with a 50MB cap and binary detection, streaming download for files of
any size, multipart upload. No sudo/permission-elevation or
server-to-server transfer in this pass (documented gaps, matching
Termix's own scope for the latter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
Claude 2026-06-19 11:56:04 +00:00
parent eaa971bb5a
commit 7edf4548d9
No known key found for this signature in database
10 changed files with 788 additions and 2 deletions

View file

@ -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)

View file

@ -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",

View file

@ -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",

258
backend/src/routes/files.ts Normal file
View file

@ -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<string, string> | undefined)?.[key]
const fromBody = (req.body as Record<string, string> | 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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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' })
}
})
}

View file

@ -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 }))

50
backend/src/ssh/sftp.ts Normal file
View file

@ -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<T>(
integrationId: number,
fn: (sftp: SFTPWrapper, host: SshHost) => Promise<T>,
): Promise<T> {
const target = loadSshHost(integrationId)
if (!target) return Promise.reject(new Error('SSH integration not found'))
return new Promise<T>((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
})
}

View file

@ -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() {
<Route path="/booknest" element={<BookNest />} />
<Route path="/terminal" element={<Terminal />} />
<Route path="/tunnels" element={<Tunnels />} />
<Route path="/files" element={<Files />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</section>

View file

@ -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' },
]

View file

@ -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'

360
src/pages/Files.tsx Normal file
View file

@ -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<Integration[]>([])
const [integrationId, setIntegrationId] = useState<number | ''>('')
const [path, setPath] = useState('.')
const [entries, setEntries] = useState<FileEntry[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [editingPath, setEditingPath] = useState<string | null>(null)
const [editingContent, setEditingContent] = useState('')
const [editingEncoding, setEditingEncoding] = useState<'utf8' | 'base64'>('utf8')
const [savingEdit, setSavingEdit] = useState(false)
const fileInputRef = useRef<HTMLInputElement | null>(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 (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold" style={{ color: TEXT_PRIMARY }}>
Files
</h1>
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>
Browse, edit, and transfer files on your remote SSH hosts.
</p>
</div>
<select
style={inputStyle()}
value={integrationId}
onChange={(e) => {
setIntegrationId(e.target.value ? Number(e.target.value) : '')
setPath('.')
}}
>
{hosts.length === 0 && <option value="">No SSH hosts configured</option>}
{hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</div>
{error && (
<div style={{ color: '#E74C3C', fontSize: '13px' }} className="flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} style={{ color: TEXT_SECONDARY }}>
<X size={14} />
</button>
</div>
)}
<div style={cardBase} className="p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-sm flex-wrap" style={{ color: TEXT_SECONDARY }}>
<button onClick={() => setPath('.')} style={{ color: TEXT_PRIMARY }}>
root
</button>
{breadcrumbs.map((part, i) => (
<span key={i} className="flex items-center gap-1">
<ChevronRight size={12} />
<button onClick={() => setPath(breadcrumbs.slice(0, i + 1).join('/'))} style={{ color: TEXT_PRIMARY }}>
{part}
</button>
</span>
))}
</div>
<div className="flex items-center gap-2">
<button onClick={refresh} className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs" style={{ border: '1px solid rgba(255,255,255,0.1)', color: TEXT_PRIMARY }}>
<RefreshCw size={12} /> Refresh
</button>
<button onClick={handleMkdir} className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs" style={{ border: '1px solid rgba(255,255,255,0.1)', color: TEXT_PRIMARY }}>
<FolderPlus size={12} /> New Folder
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
style={{ backgroundColor: GOLD, color: '#0A0A0C' }}
>
<Upload size={12} /> Upload
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleUpload(file)
e.target.value = ''
}}
/>
</div>
</div>
<table className="w-full text-sm">
<tbody>
{path !== '.' && path !== '' && (
<tr>
<td colSpan={4} className="py-1.5 cursor-pointer" style={{ color: TEXT_SECONDARY }} onClick={goUp}>
../
</td>
</tr>
)}
{entries.map((entry) => (
<tr key={entry.name} className="hover:bg-white/[0.02]">
<td
className="py-1.5 cursor-pointer flex items-center gap-2"
style={{ color: TEXT_PRIMARY }}
onClick={() => (entry.isDirectory ? openDirectory(entry.name) : openFile(entry.name))}
>
{entry.isDirectory ? <Folder size={14} color={GOLD} /> : <FileIcon size={14} color={TEXT_SECONDARY} />}
{entry.name}
</td>
<td className="py-1.5 text-right" style={{ color: TEXT_SECONDARY, width: '90px' }}>
{entry.isDirectory ? '' : formatSize(entry.size)}
</td>
<td className="py-1.5 text-right" style={{ color: TEXT_SECONDARY, width: '70px' }}>
{(entry.mode & 0o777).toString(8)}
</td>
<td className="py-1.5 text-right" style={{ width: '120px' }}>
<div className="flex items-center justify-end gap-2">
{!entry.isDirectory && (
<button onClick={() => handleDownload(entry)} title="Download" style={{ color: TEXT_SECONDARY }}>
<Download size={14} />
</button>
)}
<button onClick={() => handleRename(entry)} title="Rename" style={{ color: TEXT_SECONDARY }}>
<Pencil size={14} />
</button>
<button onClick={() => handleDelete(entry)} title="Delete" style={{ color: '#E74C3C' }}>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
{entries.length === 0 && !loading && (
<tr>
<td colSpan={4} className="py-4 text-center" style={{ color: TEXT_SECONDARY }}>
Empty directory
</td>
</tr>
)}
</tbody>
</table>
</div>
{editingPath && (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}>
<div style={cardBase} className="p-4 w-3/4 max-w-3xl h-3/4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium" style={{ color: TEXT_PRIMARY }}>
{editingPath}
</span>
<div className="flex items-center gap-2">
<button
onClick={saveEdit}
disabled={savingEdit || editingEncoding === 'base64'}
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
style={{ backgroundColor: GOLD, color: '#0A0A0C', opacity: editingEncoding === 'base64' ? 0.5 : 1 }}
>
<Save size={12} /> Save
</button>
<button onClick={() => setEditingPath(null)} style={{ color: TEXT_SECONDARY }}>
<X size={16} />
</button>
</div>
</div>
{editingEncoding === 'base64' ? (
<div style={{ color: TEXT_SECONDARY, fontSize: '13px' }}>Binary file - editing not supported. Use download instead.</div>
) : (
<textarea
value={editingContent}
onChange={(e) => setEditingContent(e.target.value)}
className="flex-1 resize-none"
style={{
...inputStyle(),
fontFamily: 'monospace',
width: '100%',
height: '100%',
}}
/>
)}
</div>
</div>
)}
</div>
)
}