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:
parent
eaa971bb5a
commit
7edf4548d9
10 changed files with 788 additions and 2 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
46
backend/package-lock.json
generated
46
backend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
258
backend/src/routes/files.ts
Normal 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' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
50
backend/src/ssh/sftp.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
360
src/pages/Files.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue