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.
|
**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)
|
### 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",
|
"@aws-sdk/client-sts": "^3.1072.0",
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
"@fastify/jwt": "^10.1.0",
|
"@fastify/jwt": "^10.1.0",
|
||||||
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|
@ -886,6 +887,12 @@
|
||||||
"fast-uri": "^3.0.0"
|
"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": {
|
"node_modules/@fastify/cors": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz",
|
||||||
|
|
@ -906,6 +913,22 @@
|
||||||
"mnemonist": "0.40.0"
|
"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": {
|
"node_modules/@fastify/error": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||||
|
|
@ -999,6 +1022,29 @@
|
||||||
"dequal": "^2.0.3"
|
"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": {
|
"node_modules/@fastify/proxy-addr": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
"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",
|
"@aws-sdk/client-sts": "^3.1072.0",
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
"@fastify/jwt": "^10.1.0",
|
"@fastify/jwt": "^10.1.0",
|
||||||
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
"bcryptjs": "^2.4.3",
|
"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 cors from '@fastify/cors'
|
||||||
import jwt from '@fastify/jwt'
|
import jwt from '@fastify/jwt'
|
||||||
import websocket from '@fastify/websocket'
|
import websocket from '@fastify/websocket'
|
||||||
|
import multipart from '@fastify/multipart'
|
||||||
import { authRoutes } from './routes/auth.js'
|
import { authRoutes } from './routes/auth.js'
|
||||||
import { integrationRoutes } from './routes/integrations.js'
|
import { integrationRoutes } from './routes/integrations.js'
|
||||||
import { bookmarkRoutes } from './routes/bookmarks.js'
|
import { bookmarkRoutes } from './routes/bookmarks.js'
|
||||||
import { eventRoutes } from './routes/events.js'
|
import { eventRoutes } from './routes/events.js'
|
||||||
import { terminalRoutes } from './routes/terminal.js'
|
import { terminalRoutes } from './routes/terminal.js'
|
||||||
import { tunnelRoutes } from './routes/tunnels.js'
|
import { tunnelRoutes } from './routes/tunnels.js'
|
||||||
|
import { fileRoutes } from './routes/files.js'
|
||||||
import { startAutoStartTunnels } from './tunnels/manager.js'
|
import { startAutoStartTunnels } from './tunnels/manager.js'
|
||||||
|
|
||||||
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
|
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(cors, { origin: process.env.ARCHNEST_CORS_ORIGIN ?? true })
|
||||||
await app.register(jwt, { secret: JWT_SECRET })
|
await app.register(jwt, { secret: JWT_SECRET })
|
||||||
await app.register(websocket)
|
await app.register(websocket)
|
||||||
|
await app.register(multipart, { limits: { fileSize: 1024 * 1024 * 1024 } })
|
||||||
|
|
||||||
app.decorate('authenticate', async function (req, reply) {
|
app.decorate('authenticate', async function (req, reply) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -36,6 +39,7 @@ await app.register(bookmarkRoutes)
|
||||||
await app.register(eventRoutes)
|
await app.register(eventRoutes)
|
||||||
await app.register(terminalRoutes)
|
await app.register(terminalRoutes)
|
||||||
await app.register(tunnelRoutes)
|
await app.register(tunnelRoutes)
|
||||||
|
await app.register(fileRoutes)
|
||||||
|
|
||||||
app.get('/api/health', async () => ({ ok: true }))
|
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 BookNest from './pages/BookNest'
|
||||||
import Terminal from './pages/Terminal'
|
import Terminal from './pages/Terminal'
|
||||||
import Tunnels from './pages/Tunnels'
|
import Tunnels from './pages/Tunnels'
|
||||||
|
import Files from './pages/Files'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Enrollment from './pages/Enrollment'
|
import Enrollment from './pages/Enrollment'
|
||||||
|
|
@ -84,6 +85,7 @@ function Dashboard() {
|
||||||
<Route path="/booknest" element={<BookNest />} />
|
<Route path="/booknest" element={<BookNest />} />
|
||||||
<Route path="/terminal" element={<Terminal />} />
|
<Route path="/terminal" element={<Terminal />} />
|
||||||
<Route path="/tunnels" element={<Tunnels />} />
|
<Route path="/tunnels" element={<Tunnels />} />
|
||||||
|
<Route path="/files" element={<Files />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Terminal,
|
Terminal,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
|
FolderOpen,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
|
@ -23,6 +24,7 @@ const navItems = [
|
||||||
{ icon: Bookmark, label: 'BookNest', route: '/booknest' },
|
{ icon: Bookmark, label: 'BookNest', route: '/booknest' },
|
||||||
{ icon: Terminal, label: 'Terminal', route: '/terminal' },
|
{ icon: Terminal, label: 'Terminal', route: '/terminal' },
|
||||||
{ icon: Waypoints, label: 'Tunnels', route: '/tunnels' },
|
{ icon: Waypoints, label: 'Tunnels', route: '/tunnels' },
|
||||||
|
{ icon: FolderOpen, label: 'Files', route: '/files' },
|
||||||
{ icon: Settings, label: 'Settings', route: '/settings' },
|
{ 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' }),
|
apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/connect`, { method: 'POST' }),
|
||||||
disconnectTunnel: (id: number) =>
|
disconnectTunnel: (id: number) =>
|
||||||
apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/disconnect`, { method: 'POST' }),
|
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 {
|
export interface AuthUser {
|
||||||
|
|
@ -155,6 +195,15 @@ export interface Event {
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string
|
||||||
|
isDirectory: boolean
|
||||||
|
isSymlink: boolean
|
||||||
|
size: number
|
||||||
|
mode: number
|
||||||
|
mtime: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface Resource {
|
export interface Resource {
|
||||||
name: string
|
name: string
|
||||||
status: 'healthy' | 'warning' | 'critical' | 'unknown'
|
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