diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 10cad8375..260911232 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -196,6 +196,17 @@ setupWebSocket(app); app.route("/api/services/terminal", serviceTerminalRoutes); } +/* ---------- Service files (both modes) ---------- */ +// +// Read-only browsing of a service's own filesystem. Same runtime selection and +// same admin gate as the terminal above — see service-files.routes.ts. +{ + const { serviceFilesRoutes } = await import( + "./modules/service-files/service-files.routes" + ); + app.route("/api/services/files", serviceFilesRoutes); +} + /* ---------- Cloud-only routes (gated by CLOUD_MODE) ---------- */ if (env.CLOUD_MODE) { const { cloudSaasRoutes } = await import("./modules/cloud/cloud-saas.routes"); diff --git a/apps/api/src/modules/service-files/service-files.controller.ts b/apps/api/src/modules/service-files/service-files.controller.ts new file mode 100644 index 000000000..aceb0d86d --- /dev/null +++ b/apps/api/src/modules/service-files/service-files.controller.ts @@ -0,0 +1,414 @@ +/** + * Read-only file browsing inside a deployed service. + * + * SECURITY: this is the SAME REACH as the service terminal — a container's + * filesystem holds its `.env`, its keys, its database credentials. So it is + * gated at the same admin tier, with the same 404-shaped denial (never confirm + * a service exists to someone not allowed to see it). + * + * The resolver below deliberately DUPLICATES the terminal's rather than + * extracting a shared one. The terminal's `resolveServiceForOrg` is the + * authorization boundary for an interactive shell; parameterising it to serve a + * second feature means editing that boundary. The two are kept in sync by + * calling the identical `checkPermission(...)` — if they ever diverge, this + * comment is the place that says they shouldn't. + * + * Runtime gate: `serviceShell`. Docker and Cloud implement it; BARE DOES NOT, + * and that exclusion is load-bearing rather than incidental — bare's + * `inContainerExecutor()` ignores its containerId and hands back the HOST + * executor (bare.ts:154, "a bare deployment is a host process"). Gating on + * anything weaker would turn this tab into a host filesystem browser. + */ + +import type { Context } from "hono"; +import { repos } from "@repo/db"; +import type { RuntimeAdapter } from "@repo/adapters"; +import { safeErrorMessage } from "@repo/core"; +import { getRequestContext } from "../../lib/request-context"; +import { checkPermission } from "../../lib/permission"; +import { + containerIdForService, + liveContainerIdWithRuntime, + resolveServiceRuntimeForRead, +} from "../services/service-container"; +import { + MAX_DOWNLOAD_BYTES, + MAX_ENTRIES, + MAX_PREVIEW_BYTES, + buildListCommand, + buildReadCommand, + joinContainerPath, + looksBinary, + newProbeNonce, + normalizeContainerPath, + parseListOutput, + parseReadOutput, + type ListFailure, + type ReadFailure, +} from "./service-files.service"; + +/** + * Wall-clock budget for one probe, passed through to the executor — which + * honours it (`opts.timeout`, guarded by both an in-container watchdog and a JS + * timer). Well under the runtime's 120s default, because this backs an + * interactive tab: a wedged container must surface as an error long before the + * dashboard's own request timeout, not hold the tab spinning. + */ +const PROBE_TIMEOUT_MS = 15_000; + +/** + * In-flight probes per user. The service terminal caps concurrent sessions per + * user; nothing carried that over, and each probe buffers the base64 payload + * plus the decoded bytes in API heap (~2.3x the file). On a 3GB-capped API a + * handful of concurrent 10MB downloads is a real OOM path, and this box has + * been OOM-killed before. + */ +const MAX_CONCURRENT_PROBES_PER_USER = 4; +const inFlight = new Map(); + +function acquireSlot(userId: string): boolean { + const current = inFlight.get(userId) ?? 0; + if (current >= MAX_CONCURRENT_PROBES_PER_USER) return false; + inFlight.set(userId, current + 1); + return true; +} + +function releaseSlot(userId: string): void { + const current = inFlight.get(userId) ?? 0; + if (current <= 1) inFlight.delete(userId); + else inFlight.set(userId, current - 1); +} + +type Resolved = { containerId: string; runtime: RuntimeAdapter }; +type ResolveFailure = { status: 403 | 404 | 409 | 500 | 501; message: string }; + +async function resolveServiceForFiles( + serviceId: string, + organizationId: string, + userId: string, +): Promise<{ ok: true; value: Resolved } | { ok: false; error: ResolveFailure }> { + const notFound = { + ok: false as const, + error: { status: 404 as const, message: "Service not found" }, + }; + + const service = await repos.service.findById(serviceId); + if (!service) return notFound; + + // Admin tier — see the header note. 404-shape on deny, never 403: a 403 would + // confirm the service exists to someone with no right to know that. + const allowed = await checkPermission(userId, organizationId, { + resourceType: "project", + resourceId: service.projectId, + action: "admin", + }); + if (!allowed) return notFound; + + const project = await repos.project.findById(service.projectId); + if (!project || (project.organizationId != null && project.organizationId !== organizationId)) { + return notFound; + } + + if (!project.activeDeploymentId) { + return { ok: false, error: { status: 409, message: "Project has no active deployment yet" } }; + } + const dep = await repos.deployment.findById(project.activeDeploymentId); + if (!dep) { + return { ok: false, error: { status: 409, message: "Active deployment not found" } }; + } + + // RUNTIME ONLY — the read-path resolver, for the reason its own doc gives: + // building the full platform drags the OpenResty detect + Lua self-heal, and + // the provision lock they run under, into what is a polled read endpoint. + // That is the exact defect recorded in service.service.ts ("what made status + // hang and then report unknown"), and the Files tab polls no less than it. + const runtime = await resolveServiceRuntimeForRead(project, dep); + if (!runtime) { + console.error("[service-files] runtime resolution returned null for", service.id); + return { ok: false, error: { status: 500, message: "Could not reach this service's host" } }; + } + + // From here the runtime is OURS to release: every early return has to hand it + // back, or a rejected request leaks the same SSH bridge a served one would. + const abandon = () => void Promise.resolve(runtime.dispose?.()).catch(() => {}); + + if (!runtime.supports("serviceShell") || !runtime.inContainerExecutor) { + abandon(); + return { + ok: false, + error: { status: 501, message: `File browsing not supported on ${runtime.name} runtime` }, + }; + } + + // Verify the recorded container against the host: a redeploy replaces it, and + // probing a dead id fails with docker's opaque "no such container". + let containerId: string | null; + try { + containerId = await liveContainerIdWithRuntime(runtime, { + service: { id: service.id, name: service.name }, + projectId: project.id, + slug: project.slug, + tracked: await containerIdForService(dep, service), + }); + } catch (err) { + abandon(); + console.error("[service-files] live container lookup failed:", safeErrorMessage(err)); + return { + ok: false, + error: { status: 500, message: `Could not reach the host: ${safeErrorMessage(err)}` }, + }; + } + if (!containerId) { + abandon(); + return { + ok: false, + error: { status: 409, message: "Service container not found — it may still be deploying." }, + }; + } + + return { ok: true, value: { containerId, runtime } }; +} + +/** `execInContainer` REJECTS when the container isn't running. That is the + * single most likely failure in normal use — a stopped service — so it gets a + * real status and a plain-language message, not a 500. */ +function execFailure(err: unknown): { status: 409 | 500 | 504; message: string } { + const message = safeErrorMessage(err); + if (/timed out/i.test(message)) { + return { status: 504, message: "The container took too long to answer." }; + } + if (/not running/i.test(message)) { + return { status: 409, message: "Service container is not running." }; + } + // 500, NOT 502. Cloudflare treats a 502 from the origin as a gateway failure + // and REPLACES the body with its own "Bad gateway" page, so every message + // below is invisible to any operator behind a CDN — they just see a blank + // error. Verified against this install: a 404 body passes through, a 502 + // body does not. + console.error("[service-files] probe failed:", message); + return { status: 500, message: `Could not read from the container: ${message}` }; +} + +// See execFailure on why these are 500 rather than 502. +const LIST_STATUS: Record = { + not_found: 404, + not_a_directory: 400, + permission_denied: 403, + truncated: 500, + malformed: 500, +}; + +const LIST_MESSAGE: Record = { + not_found: "No such directory in this container", + not_a_directory: "That path is a file, not a directory", + permission_denied: "Permission denied inside the container", + truncated: "The listing came back incomplete — try again", + malformed: "Could not read the directory listing", +}; + +const READ_STATUS: Record = { + not_found: 404, + is_a_directory: 400, + not_regular: 400, + permission_denied: 403, + too_large: 413, + no_base64: 501, + incomplete: 500, + malformed: 500, +}; + +const READ_MESSAGE: Record = { + not_found: "No such file in this container", + is_a_directory: "That path is a directory, not a file", + not_regular: "That path isn't a regular file (device, socket or pipe)", + permission_denied: "Permission denied inside the container", + too_large: "File is too large to open here", + no_base64: "This container has no `base64`, so its files can't be read here", + incomplete: "The file came back incomplete — try again", + malformed: "Could not read the file", +}; + +interface Probe { + path: string; + nonce: string; + run: (command: string) => Promise<{ ok: true; stdout: string } | { ok: false; status: number; message: string }>; +} + +/** Authorize, resolve, normalize the path, and hand back a one-shot probe that + * ALWAYS releases both the runtime and the concurrency slot. */ +async function prepare(c: Context): Promise<{ ok: true; probe: Probe } | { ok: false; response: Response }> { + const ctx = getRequestContext(c); + const serviceId = c.req.param("serviceId"); + if (!serviceId) { + return { ok: false, response: c.json({ error: "serviceId required" }, 400) }; + } + + const path = normalizeContainerPath(c.req.query("path")); + if (path === null) { + return { ok: false, response: c.json({ error: "Invalid path" }, 400) }; + } + + if (!acquireSlot(ctx.userId)) { + return { + ok: false, + response: c.json({ error: "Too many file requests in flight — wait for one to finish." }, 429), + }; + } + + let resolved: Awaited>; + try { + resolved = await resolveServiceForFiles(serviceId, ctx.organizationId, ctx.userId); + } catch (err) { + releaseSlot(ctx.userId); + return { + ok: false, + response: c.json({ error: `Could not open this service: ${safeErrorMessage(err)}` }, 500), + }; + } + if (!resolved.ok) { + releaseSlot(ctx.userId); + return { ok: false, response: c.json({ error: resolved.error.message }, resolved.error.status) }; + } + + const { runtime, containerId } = resolved.value; + // Fire-and-forget, matching service.service.ts:1071 — teardown still runs, it + // just never extends the response the user is waiting on. + const release = () => { + void Promise.resolve(runtime.dispose?.()).catch(() => {}); + releaseSlot(ctx.userId); + }; + + return { + ok: true, + probe: { + path, + nonce: newProbeNonce(), + run: async (command) => { + try { + const executor = await runtime.inContainerExecutor!(containerId); + const stdout = await executor.exec(command, { timeout: PROBE_TIMEOUT_MS }); + return { ok: true, stdout }; + } catch (err) { + return { ok: false, ...execFailure(err) }; + } finally { + release(); + } + }, + }, + }; +} + +// ─── GET /api/services/files/:serviceId/list?path= ─────────────────────────── + +export async function listDirectory(c: Context) { + const prep = await prepare(c); + if (!prep.ok) return prep.response; + const { path, nonce, run } = prep.probe; + + const ran = await run(buildListCommand(path, nonce, MAX_ENTRIES)); + if (!ran.ok) return c.json({ error: ran.message }, ran.status as 409); + + const result = parseListOutput(ran.stdout, nonce); + if (!result.ok) { + console.error( + `[service-files] list parse failed reason=${result.reason} bytes=${ran.stdout.length}`, + JSON.stringify(ran.stdout.slice(0, 300)), + ); + return c.json({ error: LIST_MESSAGE[result.reason] }, LIST_STATUS[result.reason]); + } + + return c.json({ + success: true, + path, + // Surfaced, never silent: a capped listing that looked complete would tell + // the operator a directory holds 500 files when it holds 40,000. + truncated: result.truncated, + limit: MAX_ENTRIES, + entries: result.entries.map((e) => ({ + ...e, + // The client must never build paths itself — normalization lives in one + // place, and a name like `..` must not become a client-side traversal. + path: joinContainerPath(path, e.name), + })), + }); +} + +// ─── GET /api/services/files/:serviceId/read?path= ─────────────────────────── + +export async function readFile(c: Context) { + const prep = await prepare(c); + if (!prep.ok) return prep.response; + const { path, nonce, run } = prep.probe; + + const ran = await run(buildReadCommand(path, MAX_PREVIEW_BYTES, nonce)); + if (!ran.ok) return c.json({ error: ran.message }, ran.status as 409); + + const result = parseReadOutput(ran.stdout, MAX_PREVIEW_BYTES, nonce); + if (!result.ok) { + return c.json( + { + error: READ_MESSAGE[result.reason], + reason: result.reason, + size: result.size ?? null, + limit: MAX_PREVIEW_BYTES, + }, + READ_STATUS[result.reason], + ); + } + + // Binary content is reported, never rendered — the client offers a download + // instead of painting control bytes into the DOM. + if (looksBinary(result.content)) { + return c.json({ + success: true, + path, + binary: true, + size: result.content.byteLength, + content: null, + }); + } + + return c.json({ + success: true, + path, + binary: false, + size: result.content.byteLength, + content: result.content.toString("utf8"), + }); +} + +// ─── GET /api/services/files/:serviceId/download?path= ─────────────────────── + +export async function downloadFile(c: Context) { + const prep = await prepare(c); + if (!prep.ok) return prep.response; + const { path, nonce, run } = prep.probe; + + const ran = await run(buildReadCommand(path, MAX_DOWNLOAD_BYTES, nonce)); + if (!ran.ok) return c.json({ error: ran.message }, ran.status as 409); + + const result = parseReadOutput(ran.stdout, MAX_DOWNLOAD_BYTES, nonce); + if (!result.ok) { + return c.json( + { + error: READ_MESSAGE[result.reason], + reason: result.reason, + size: result.size ?? null, + limit: MAX_DOWNLOAD_BYTES, + }, + READ_STATUS[result.reason], + ); + } + + const filename = path.split("/").pop() || "download"; + // Copy into a plain-ArrayBuffer view: Buffer may sit on a SharedArrayBuffer, + // which Hono's body type (rightly) refuses. + const bytes = new Uint8Array(result.content); + return c.body(bytes, 200, { + "Content-Type": "application/octet-stream", + // Percent-encoded: a container filename may contain characters that would + // otherwise terminate the header value. + "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`, + "Content-Length": String(bytes.byteLength), + }); +} diff --git a/apps/api/src/modules/service-files/service-files.routes.ts b/apps/api/src/modules/service-files/service-files.routes.ts new file mode 100644 index 000000000..c7d57337c --- /dev/null +++ b/apps/api/src/modules/service-files/service-files.routes.ts @@ -0,0 +1,31 @@ +import { Hono } from "hono"; +import { secureRouter } from "../../lib/secure-router"; +import { listDirectory, readFile, downloadFile } from "./service-files.controller"; + +/** + * Read-only file browsing inside a deployed service. + * + * Sibling of service-terminal.routes.ts and, like it, NOT `localOnly`: it works + * on self-hosted (docker exec) AND cloud (Oblien workspace exec). The runtime + * is chosen inside the controller from the deployment's meta. + * + * GET /api/services/files/:serviceId/list?path= directory listing + * GET /api/services/files/:serviceId/read?path= text preview (capped) + * GET /api/services/files/:serviceId/download?path= raw bytes (capped) + * + * All three carry `terminal:write` rather than a read tag. That is deliberate: + * the tag names the CAPABILITY TIER, not the HTTP verb, and reading a + * container's filesystem is exactly as sensitive as opening a shell in it — + * same `.env`, same credentials. Gating these below the terminal would let + * someone denied a shell read everything the shell would have shown them. + * The controller re-asserts project `admin` per request on top of this. + */ +export const serviceFilesRoutes = new Hono(); +const r = secureRouter(serviceFilesRoutes, { + module: "service-files", + basePath: "/api/services/files", +}); + +r.get("/:serviceId/list", { tag: "terminal:write" }, listDirectory); +r.get("/:serviceId/read", { tag: "terminal:write" }, readFile); +r.get("/:serviceId/download", { tag: "terminal:write" }, downloadFile); diff --git a/apps/api/src/modules/service-files/service-files.service.test.ts b/apps/api/src/modules/service-files/service-files.service.test.ts new file mode 100644 index 000000000..7e955dd68 --- /dev/null +++ b/apps/api/src/modules/service-files/service-files.service.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect } from "vitest"; +import { + shellQuote, + newProbeNonce, + normalizeContainerPath, + joinContainerPath, + buildListCommand, + parseListOutput, + buildReadCommand, + parseReadOutput, + looksBinary, + MAX_ENTRIES, + MAX_PREVIEW_BYTES, + MAX_DOWNLOAD_BYTES, +} from "./service-files.service"; + +const N = "deadbeefcafe0123456789ab"; +const rec = (kind: string, link: string, size: string, name: string) => + `${N}\tE\t${kind}\t${link}\t${size}\t${name}`; +const end = `${N}\tEND`; + +/** + * Every command this module builds is handed to `sh -c` INSIDE the target + * container. The path is attacker-controlled, so quoting is a security + * boundary, not a formatting detail — these are the tests that hold it. + */ +describe("shellQuote", () => { + it("wraps a plain value in single quotes", () => { + expect(shellQuote("/var/www/html")).toBe("'/var/www/html'"); + }); + + it("neutralises command separators and substitution", () => { + for (const attack of [ + "/tmp; rm -rf /", + "/tmp && cat /etc/shadow", + "/tmp | nc evil 1234", + "/tmp$(whoami)", + "/tmp`whoami`", + "/tmp\nrm -rf /", + "/tmp${IFS}x", + ]) { + const quoted = shellQuote(attack); + expect(quoted.startsWith("'")).toBe(true); + expect(quoted.endsWith("'")).toBe(true); + expect(quoted.slice(1, -1)).not.toContain("'"); + } + }); + + it("escapes embedded single quotes so the string cannot be broken out of", () => { + expect(shellQuote("/tmp/it's")).toBe("'/tmp/it'\\''s'"); + expect(shellQuote("';id;'")).toBe("''\\'';id;'\\'''"); + }); + + it("round-trips through a real shell for every hostile input", async () => { + // Quoting bugs are invisible in string assertions and fatal in production, + // so assert against the actual sh. + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const run = promisify(execFile); + for (const value of ["/tmp/it's", "a b", "x$(id)", "y`id`", "z;id", "d'q\"m", "n\nl"]) { + const { stdout } = await run("sh", ["-c", `printf %s ${shellQuote(value)}`]); + expect(stdout).toBe(value); + } + }); +}); + +describe("newProbeNonce", () => { + it("is unpredictable and shell-safe", () => { + const a = newProbeNonce(); + expect(a).toMatch(/^[0-9a-f]{24}$/); + // A filename cannot contain what it cannot guess — that is the whole basis + // of the forgery defence, so a repeated value would silently undo it. + expect(new Set(Array.from({ length: 50 }, newProbeNonce)).size).toBe(50); + }); +}); + +describe("normalizeContainerPath", () => { + it("keeps an absolute path canonical", () => { + expect(normalizeContainerPath("/var/www/html")).toBe("/var/www/html"); + expect(normalizeContainerPath("/var//www///html/")).toBe("/var/www/html"); + expect(normalizeContainerPath("/")).toBe("/"); + }); + + it("defaults an empty or missing path to the root", () => { + expect(normalizeContainerPath("")).toBe("/"); + expect(normalizeContainerPath(undefined)).toBe("/"); + }); + + it("resolves . and .. segments", () => { + expect(normalizeContainerPath("/var/www/../log")).toBe("/var/log"); + expect(normalizeContainerPath("/var/./www")).toBe("/var/www"); + }); + + it("clamps traversal above the root instead of escaping it", () => { + expect(normalizeContainerPath("/../../etc")).toBe("/etc"); + expect(normalizeContainerPath("/..")).toBe("/"); + }); + + it("forces a relative path to be absolute", () => { + expect(normalizeContainerPath("var/www")).toBe("/var/www"); + }); + + it("rejects a NUL byte outright", () => { + expect(normalizeContainerPath("/tmp/\0/etc/passwd")).toBeNull(); + }); +}); + +describe("joinContainerPath", () => { + it("joins a directory and an entry name", () => { + expect(joinContainerPath("/var/www", "index.php")).toBe("/var/www/index.php"); + expect(joinContainerPath("/", "etc")).toBe("/etc"); + }); + + it("does not let an entry name climb out of its directory", () => { + expect(joinContainerPath("/var/www", "../../etc/passwd")).toBe("/etc/passwd"); + }); +}); + +describe("buildListCommand", () => { + it("embeds the path quoted, never raw", () => { + const path = "/tmp; rm -rf /"; + const cmd = buildListCommand(path, N); + expect(cmd).toContain(shellQuote(path)); + expect(cmd.split(shellQuote(path)).join("")).not.toContain("rm -rf"); + }); + + it("always exits zero so a missing directory is data, not a thrown exec", () => { + // `exec` REJECTS on a non-zero exit with stdout as the message, so leaning + // on exit codes would turn every "not found" into a 500. + expect(buildListCommand("/x", N)).toContain("exit 0"); + }); + + it("caps the entry count so a huge directory can't cost thousands of forks", () => { + expect(buildListCommand("/x", N, 500)).toContain("-gt 500"); + }); + + it("resolves symlink targets so a symlinked directory stays navigable", () => { + // `-d` dereferences; that is what makes `storage` / `current` openable. + expect(buildListCommand("/x", N)).toContain(`if [ -d "$e" ]; then k=d`); + }); +}); + +describe("parseListOutput", () => { + it("parses a normal listing", () => { + const out = [ + rec("d", "0", "0", "app"), + rec("d", "0", "0", "config"), + rec("f", "0", "1042", ".env"), + rec("f", "0", "53", "composer.json"), + end, + ].join("\n"); + expect(parseListOutput(out, N)).toEqual({ + ok: true, + truncated: false, + entries: [ + { name: "app", type: "dir", symlink: false, size: 0 }, + { name: "config", type: "dir", symlink: false, size: 0 }, + { name: ".env", type: "file", symlink: false, size: 1042 }, + { name: "composer.json", type: "file", symlink: false, size: 53 }, + ], + }); + }); + + it("sorts directories first, then case-insensitively by name", () => { + const out = [rec("f", "0", "1", "zebra"), rec("f", "0", "1", "Apple"), rec("d", "0", "0", "zoo"), end].join("\n"); + const r = parseListOutput(out, N); + expect(r.ok && r.entries.map((e) => e.name)).toEqual(["zoo", "Apple", "zebra"]); + }); + + it("keeps names containing spaces intact", () => { + const r = parseListOutput([rec("f", "0", "12", "my file name.txt"), end].join("\n"), N); + expect(r.ok && r.entries[0]!.name).toBe("my file name.txt"); + }); + + it("reports a symlinked directory as a navigable directory", () => { + const r = parseListOutput([rec("d", "1", "0", "storage"), end].join("\n"), N); + expect(r.ok && r.entries[0]).toEqual({ + name: "storage", + type: "dir", + symlink: true, + size: 0, + }); + }); + + it("returns an empty list for an empty directory", () => { + expect(parseListOutput(end, N)).toEqual({ ok: true, entries: [], truncated: false }); + }); + + it("flags a truncated listing so the UI can say so", () => { + const out = [rec("f", "0", "1", "a"), `${N}\tTRUNC`, end].join("\n"); + const r = parseListOutput(out, N); + expect(r.ok && r.truncated).toBe(true); + }); + + it("maps each error marker to a distinct reason", () => { + expect(parseListOutput(`${N}\tERR\tnotfound`, N)).toEqual({ ok: false, reason: "not_found" }); + expect(parseListOutput(`${N}\tERR\tnotdir`, N)).toEqual({ ok: false, reason: "not_a_directory" }); + expect(parseListOutput(`${N}\tERR\tdenied`, N)).toEqual({ ok: false, reason: "permission_denied" }); + }); + + it("treats a missing end sentinel as a truncated read, not an empty directory", () => { + expect(parseListOutput(rec("f", "0", "1", "a.txt"), N)).toEqual({ ok: false, reason: "truncated" }); + }); + + it("ignores noise lines that lack the nonce", () => { + const out = ["sh: warning: something", rec("f", "0", "2", "real.txt"), end].join("\n"); + const r = parseListOutput(out, N); + expect(r.ok && r.entries.map((e) => e.name)).toEqual(["real.txt"]); + }); + + /* ── Marker forgery: newlines are legal in unix filenames ─────────────── */ + + it("cannot be tricked into reporting an error by a filename containing a newline", () => { + // A file literally named "evil\n\tERR\tdenied". Without the + // nonce scope this made the WHOLE directory render as permission-denied. + const out = [rec("f", "0", "1", "evil\nERR\tdenied"), rec("f", "0", "1", "real.txt"), end].join("\n"); + const r = parseListOutput(out, N); + expect(r.ok).toBe(true); + expect(r.ok && r.entries.some((e) => e.name === "real.txt")).toBe(true); + }); + + it("cannot be tricked into ending the listing early by a crafted filename", () => { + // Forging END would truncate the listing while still reporting success — + // hiding files from an operator who has every right to see them. + const out = [rec("f", "0", "1", "evil\nEND"), rec("f", "0", "1", "hidden-by-forgery.txt"), end].join("\n"); + const r = parseListOutput(out, N); + expect(r.ok && r.entries.some((e) => e.name === "hidden-by-forgery.txt")).toBe(true); + }); + + it("ignores markers carrying a DIFFERENT nonce", () => { + const out = [`0000000000000000000000ff\tERR\tdenied`, rec("f", "0", "1", "real.txt"), end].join("\n"); + const r = parseListOutput(out, N); + expect(r.ok && r.entries.map((e) => e.name)).toEqual(["real.txt"]); + }); +}); + +describe("buildReadCommand", () => { + it("quotes the path and caps the byte count", () => { + const cmd = buildReadCommand("/tmp/a b.txt", 1024, N); + expect(cmd).toContain(shellQuote("/tmp/a b.txt")); + expect(cmd).toContain("1024"); + expect(cmd).toContain("exit 0"); + }); + + it("refuses anything that is not a regular file", () => { + // Without `-f`, a FIFO or /dev/zero passes every other guard and + // `wc -c` never returns — wedging the request past both size caps. + expect(buildReadCommand("/x", 10, N)).toContain(`if [ ! -f "$f" ]`); + }); + + it("terminates the payload so a cut-short read is detectable", () => { + expect(buildReadCommand("/x", 10, N)).toContain("END"); + }); +}); + +describe("parseReadOutput", () => { + const readOut = (raw: Buffer, opts?: { size?: number; wrap?: boolean }) => { + const b64 = raw.toString("base64"); + const payload = opts?.wrap ? b64.replace(/(.{76})/g, "$1\n") : b64; + return [`${N}\tSIZE\t${opts?.size ?? raw.length}`, `${N}\tDATA`, payload, `${N}\tEND`].join("\n"); + }; + + it("decodes base64 payloads exactly, including binary bytes", () => { + const raw = Buffer.from([0x00, 0x01, 0xff, 0xfe, 0x41]); + const r = parseReadOutput(readOut(raw), MAX_PREVIEW_BYTES, N); + expect(r.ok && r.content.equals(raw)).toBe(true); + }); + + it("tolerates the line wrapping both coreutils and busybox base64 emit", () => { + const raw = Buffer.from("x".repeat(200)); + const r = parseReadOutput(readOut(raw, { wrap: true }), MAX_PREVIEW_BYTES, N); + expect(r.ok && r.content.equals(raw)).toBe(true); + }); + + it("REFUSES a payload with no terminator instead of serving a partial file", () => { + // docker's exec resolves on stream close and only throws on a NON-ZERO exit + // code — null is falsy — so an early close does not throw. This is the only + // thing standing between the user and a half-read .env that looks whole. + const raw = Buffer.from("APP_KEY=secret\nDB_PASSWORD=hunter2\n"); + const truncated = readOut(raw).split("\n").slice(0, -1).join("\n"); + expect(parseReadOutput(truncated, MAX_PREVIEW_BYTES, N)).toMatchObject({ ok: false, reason: "incomplete" }); + }); + + it("REFUSES when the decoded length disagrees with the size the container reported", () => { + const raw = Buffer.from("0123456789"); + expect(parseReadOutput(readOut(raw, { size: 99 }), MAX_PREVIEW_BYTES, N)).toMatchObject({ + ok: false, + reason: "incomplete", + size: 99, + }); + }); + + it("reports the real size so the UI can refuse instead of truncating", () => { + const out = [`${N}\tSIZE\t99999999`, `${N}\tERR\ttoolarge`].join("\n"); + expect(parseReadOutput(out, MAX_PREVIEW_BYTES, N)).toEqual({ + ok: false, + reason: "too_large", + size: 99999999, + }); + }); + + it("maps the remaining error markers", () => { + for (const [marker, reason] of [ + ["notfound", "not_found"], + ["isdir", "is_a_directory"], + ["notregular", "not_regular"], + ["denied", "permission_denied"], + ["nobase64", "no_base64"], + ] as const) { + expect(parseReadOutput(`${N}\tERR\t${marker}`, MAX_PREVIEW_BYTES, N)).toMatchObject({ + ok: false, + reason, + }); + } + }); + + it("rejects a payload that exceeds the cap even if the container lied about size", () => { + const raw = Buffer.alloc(64, 0x41); + expect(parseReadOutput(readOut(raw), 32, N)).toMatchObject({ ok: false, reason: "too_large" }); + }); + + it("fails closed on garbage rather than returning half a file", () => { + expect(parseReadOutput("total nonsense", MAX_PREVIEW_BYTES, N)).toMatchObject({ ok: false }); + }); + + it("ignores markers carrying a different nonce", () => { + const foreign = readOut(Buffer.from("x")).replaceAll(N, "0000000000000000000000ff"); + expect(parseReadOutput(foreign, MAX_PREVIEW_BYTES, N)).toMatchObject({ ok: false, reason: "malformed" }); + }); +}); + +describe("looksBinary", () => { + it("treats a NUL byte as binary", () => { + expect(looksBinary(Buffer.from([0x41, 0x00, 0x42]))).toBe(true); + }); + + it("treats ordinary text as text", () => { + expect(looksBinary(Buffer.from("APP_ENV=production\nDB_HOST=db\n"))).toBe(false); + }); + + it("treats UTF-8 text as text", () => { + expect(looksBinary(Buffer.from("olá — ção 日本語\n", "utf8"))).toBe(false); + }); + + it("treats an empty file as text", () => { + expect(looksBinary(Buffer.alloc(0))).toBe(false); + }); +}); + +describe("caps", () => { + it("keeps the preview cap well under the download cap", () => { + expect(MAX_PREVIEW_BYTES).toBeLessThan(MAX_DOWNLOAD_BYTES); + }); + + it("keeps the download cap small enough to survive base64 in memory", () => { + expect(MAX_DOWNLOAD_BYTES).toBeLessThanOrEqual(16 * 1024 * 1024); + }); + + it("bounds the per-listing fork count", () => { + expect(MAX_ENTRIES).toBeGreaterThan(0); + expect(MAX_ENTRIES).toBeLessThanOrEqual(1000); + }); +}); diff --git a/apps/api/src/modules/service-files/service-files.service.ts b/apps/api/src/modules/service-files/service-files.service.ts new file mode 100644 index 000000000..db68ea0d9 --- /dev/null +++ b/apps/api/src/modules/service-files/service-files.service.ts @@ -0,0 +1,379 @@ +/** + * Read-only file browsing INSIDE a deployed service. + * + * Sibling of modules/service-terminal/: same reach, same gate. Where the + * terminal opens a PTY, this module runs bounded, non-interactive probes over + * `runtime.inContainerExecutor()` — which docker and cloud both implement as + * `sh -c ` inside the container. That shared contract is why one + * command string works on both. + * + * Everything here is PURE: build a command, parse its output. No I/O, no db, + * no runtime — which is what makes the quoting and parsing directly testable, + * and those are the two places this feature can go wrong. + * + * Four invariants the rest of the module leans on: + * + * 1. THE COMMAND ALWAYS EXITS 0. `inContainerExecutor.exec` REJECTS on any + * non-zero exit, using stdout (or stderr) as the error message. If "file + * not found" rode the exit code it would arrive as a thrown Error and + * surface as a 500 with a shell string in it. Instead every failure is + * printed as a marker on stdout and parsed into a real reason here. + * + * 2. EVERY MARKER IS NONCE-SCOPED. Newlines are legal in unix filenames, and + * the probe prints names verbatim — so a name containing a newline splits + * into extra lines. Without a nonce a file could be NAMED so that its own + * listing line forged `ERR denied` (whole directory reads as forbidden) or + * `END` (listing silently truncated while still reporting success). The + * caller mints an unpredictable per-request nonce; a filename cannot + * contain it, so continuation lines are inert and get dropped. + * + * 3. EVERY PROBE IS TERMINATED. Both listing AND reading end with an `END` + * marker, and neither result is accepted without it. docker's exec resolves + * on stream `end` OR `close` and only throws when ExitCode is non-zero — + * `null` is falsy — so a stream that closes early does NOT throw. Node's + * base64 decoder is lenient about truncated input, so without the + * terminator a half-read `.env` would be served looking complete. + * + * 4. ONLY STDOUT CARRIES SIGNAL. Docker demuxes stderr onto its own sink; + * cloud funnels it into the same collector as stdout. Markers therefore go + * to stdout, and the payload is base64 — whose alphabet contains no tab and + * no newline — so on either runtime a stray stderr line can neither forge a + * marker nor corrupt a payload. + */ + +/** Preview cap. Base64 inflates 4/3 and the whole payload is buffered on both + * sides — this is a string channel, not a stream. A `.env` is ~1KB; 2MB is + * generous for the config-file use case this serves. */ +export const MAX_PREVIEW_BYTES = 2 * 1024 * 1024; + +/** Download cap. Same buffered path, so it stays modest on purpose — a real + * streaming download would need docker's getArchive, which cloud has no + * equivalent for, and forking the two runtimes apart is not worth it. */ +export const MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024; + +/** + * Entries returned per directory. This is a COST BOUND, not a display choice: + * the probe forks one `wc -c` per regular file to get its size, so the cap is + * what stops `/usr/lib` or a big `node_modules` from costing thousands of forks + * of container CPU per page view. It also bounds the JSON payload and the + * number of rows the client renders. Measured ~660ms for 274 entries. + */ +export const MAX_ENTRIES = 500; + +export type EntryType = "file" | "dir"; + +export interface DirEntry { + name: string; + /** RESOLVED type — a symlink reports what it points AT, so a symlinked + * directory is navigable. Broken symlinks resolve to "file" and fail on + * read, which is the honest outcome. */ + type: EntryType; + /** True when the entry itself is a symlink, whatever it resolves to. */ + symlink: boolean; + /** Bytes for regular files; 0 for directories. */ + size: number; +} + +export type ListResult = + | { ok: true; entries: DirEntry[]; truncated: boolean } + | { ok: false; reason: ListFailure }; + +export type ListFailure = + | "not_found" + | "not_a_directory" + | "permission_denied" + | "truncated" + | "malformed"; + +export type ReadResult = + | { ok: true; content: Buffer } + | { ok: false; reason: ReadFailure; size?: number }; + +export type ReadFailure = + | "not_found" + | "is_a_directory" + | "not_regular" + | "permission_denied" + | "too_large" + | "no_base64" + | "incomplete" + | "malformed"; + +// ─── Quoting ──────────────────────────────────────────────────────────────── + +/** + * POSIX single-quote a value for `sh -c`. + * + * SECURITY BOUNDARY. The path is caller-controlled and lands in a shell string. + * Inside single quotes every character is literal and the only one that can end + * the quoting is `'` itself — POSIX offers no backslash escape there — so an + * embedded quote must close, emit an escaped quote, and reopen: `'` → `'\''`. + */ +export function shellQuote(value: string): string { + return `'${value.split("'").join(`'\\''`)}'`; +} + +/** An unpredictable marker scope for one probe — see invariant 2. */ +export function newProbeNonce(): string { + // Hex only: it has to survive a shell single-quoted string and a tab-split + // parser without escaping, and be impossible to guess from outside. + const bytes = new Uint8Array(12); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +// ─── Paths ────────────────────────────────────────────────────────────────── + +/** + * Canonicalise a path for display and for embedding in a probe. + * + * NOTE this is NOT a sandbox: browsing is gated at the same admin tier as the + * service terminal, which already grants the whole container. Normalising is + * about producing one canonical spelling (so breadcrumbs and caching agree) and + * refusing inputs the shell and the kernel would disagree about. + * + * Returns null for a path containing NUL — the syscall truncates there, so what + * `[ -r "$f" ]` checks and what `base64` opens could differ. + */ +export function normalizeContainerPath(input?: string | null): string | null { + const raw = input ?? ""; + if (raw.includes("\0")) return null; + + const out: string[] = []; + for (const segment of raw.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + // Clamp at the root rather than escaping it: "/.." is "/" on every unix. + out.pop(); + continue; + } + out.push(segment); + } + return `/${out.join("/")}`.replace(/\/{2,}/g, "/"); +} + +/** Resolve an entry name against its directory, canonically. */ +export function joinContainerPath(dir: string, name: string): string | null { + return normalizeContainerPath(`${dir}/${name}`); +} + +// ─── Listing ──────────────────────────────────────────────────────────────── + +/** + * One directory listing, POSIX-sh only so it runs on busybox (alpine), dash and + * bash alike — no `find -printf`, no `ls --full-time`, no `stat -c`, none of + * which exist on all three. + * + * The three globs cover normal names, dotfiles, and `..`-prefixed names while + * excluding `.` and `..` themselves. An unmatched glob stays literal in sh, so + * every candidate is re-checked with `[ -e ]` before it is emitted. + * + * Symlinks report their RESOLVED type (`[ -d ]` dereferences), so a symlinked + * directory — `storage`, `current`, and friends, which real Laravel and + * release-dir images are full of — is navigable rather than a dead end. + */ +export function buildListCommand(path: string, nonce: string, maxEntries = MAX_ENTRIES): string { + const d = shellQuote(path); + const n = shellQuote(nonce); + return [ + `d=${d}`, + `N=${n}`, + `if [ ! -e "$d" ]; then printf '%s\\tERR\\tnotfound\\n' "$N"; exit 0; fi`, + `if [ ! -d "$d" ]; then printf '%s\\tERR\\tnotdir\\n' "$N"; exit 0; fi`, + `cd "$d" 2>/dev/null || { printf '%s\\tERR\\tdenied\\n' "$N"; exit 0; }`, + `if [ ! -r "$d" ]; then printf '%s\\tERR\\tdenied\\n' "$N"; exit 0; fi`, + `c=0`, + `t=0`, + `for e in * .[!.]* ..?*; do`, + ` [ -e "$e" ] || [ -L "$e" ] || continue`, + ` c=$((c+1))`, + ` if [ "$c" -gt ${Math.floor(maxEntries)} ]; then t=1; break; fi`, + ` if [ -L "$e" ]; then l=1; else l=0; fi`, + // `-d` dereferences, so this is the RESOLVED type for symlinks too. + ` if [ -d "$e" ]; then k=d; else k=f; fi`, + ` s=0`, + ` if [ "$k" = f ] && [ -f "$e" ]; then s=$(wc -c < "$e" 2>/dev/null || echo 0); fi`, + ` printf '%s\\tE\\t%s\\t%s\\t%s\\t%s\\n' "$N" "$k" "$l" "$s" "$e"`, + `done`, + `if [ "$t" = 1 ]; then printf '%s\\tTRUNC\\n' "$N"; fi`, + `printf '%s\\tEND\\n' "$N"`, + `exit 0`, + ].join("\n"); +} + +const LIST_FAILURES: Record = { + notfound: "not_found", + notdir: "not_a_directory", + denied: "permission_denied", +}; + +export function parseListOutput(stdout: string, nonce: string): ListResult { + // Only lines the probe itself wrote carry the nonce — see invariant 2. A + // filename with an embedded newline produces continuation lines that fail + // this prefix test and are dropped, so it can forge nothing. + const marked = stdout + .split("\n") + .filter((l) => l.startsWith(`${nonce}\t`)) + .map((l) => l.slice(nonce.length + 1)); + + for (const line of marked) { + if (line.startsWith("ERR\t")) { + return { ok: false, reason: LIST_FAILURES[line.slice(4).trim()] ?? "malformed" }; + } + } + + // Invariant 3: no terminator means the read was cut short, which must never + // be presented as "this folder is empty". + if (!marked.some((l) => l.trim() === "END")) return { ok: false, reason: "truncated" }; + const truncated = marked.some((l) => l.trim() === "TRUNC"); + + const entries: DirEntry[] = []; + for (const line of marked) { + if (!line.startsWith("E\t")) continue; + const parts = line.slice(2).split("\t"); + if (parts.length < 4) continue; + const [kind, link, size, ...nameParts] = parts; + // Re-join so a name containing a tab survives; only the leading fields are + // structural. + const name = nameParts.join("\t"); + if (!name) continue; + entries.push({ + name, + type: kind === "d" ? "dir" : "file", + symlink: link === "1", + size: Number.parseInt(size ?? "0", 10) || 0, + }); + } + + // Directories first, then case-insensitive by name — the ordering every file + // browser uses, and stable regardless of the shell's glob order. + entries.sort((a, b) => { + if ((a.type === "dir") !== (b.type === "dir")) return a.type === "dir" ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }); + }); + + return { ok: true, entries, truncated }; +} + +// ─── Reading ──────────────────────────────────────────────────────────────── + +/** + * Read one file as base64 — binary-safe over a channel that hands us a utf8 + * string. + * + * The `-f` test is load-bearing, not defensive noise: without it a FIFO or a + * character device passes every other guard, and `wc -c < /dev/zero` never + * returns. Both size caps only fire on a payload that finishes arriving, so an + * infinite source would bypass them entirely and wedge the request. + * + * The in-container size guard is a courtesy that avoids shipping a huge + * payload; the authoritative cap is enforced on the parsed bytes, because a + * file can grow between the check and the read. + */ +export function buildReadCommand(path: string, maxBytes: number, nonce: string): string { + const f = shellQuote(path); + const n = shellQuote(nonce); + return [ + `f=${f}`, + `N=${n}`, + `if [ ! -e "$f" ]; then printf '%s\\tERR\\tnotfound\\n' "$N"; exit 0; fi`, + `if [ -d "$f" ]; then printf '%s\\tERR\\tisdir\\n' "$N"; exit 0; fi`, + // Regular files only — see the note above. + `if [ ! -f "$f" ]; then printf '%s\\tERR\\tnotregular\\n' "$N"; exit 0; fi`, + `if [ ! -r "$f" ]; then printf '%s\\tERR\\tdenied\\n' "$N"; exit 0; fi`, + `s=$(wc -c < "$f" 2>/dev/null || echo -1)`, + `printf '%s\\tSIZE\\t%s\\n' "$N" "$s"`, + `if [ "$s" -lt 0 ]; then printf '%s\\tERR\\tdenied\\n' "$N"; exit 0; fi`, + `if [ "$s" -gt ${Math.floor(maxBytes)} ]; then printf '%s\\tERR\\ttoolarge\\n' "$N"; exit 0; fi`, + `command -v base64 >/dev/null 2>&1 || { printf '%s\\tERR\\tnobase64\\n' "$N"; exit 0; }`, + `printf '%s\\tDATA\\n' "$N"`, + `base64 "$f" 2>/dev/null || { printf '\\n%s\\tERR\\tdenied\\n' "$N"; exit 0; }`, + `printf '\\n%s\\tEND\\n' "$N"`, + `exit 0`, + ].join("\n"); +} + +const READ_FAILURES: Record = { + notfound: "not_found", + isdir: "is_a_directory", + notregular: "not_regular", + denied: "permission_denied", + toolarge: "too_large", + nobase64: "no_base64", +}; + +export function parseReadOutput(stdout: string, maxBytes: number, nonce: string): ReadResult { + const lines = stdout.split("\n"); + const marker = (line: string) => + line.startsWith(`${nonce}\t`) ? line.slice(nonce.length + 1).trim() : null; + + let size: number | undefined; + let dataIndex = -1; + let endIndex = -1; + let failure: ReadFailure | undefined; + + lines.forEach((line, i) => { + const m = marker(line); + if (m === null) return; + if (m.startsWith("SIZE\t")) { + const parsed = Number.parseInt(m.slice(5).trim(), 10); + if (Number.isFinite(parsed) && parsed >= 0) size = parsed; + } else if (m.startsWith("ERR\t")) { + failure ??= READ_FAILURES[m.slice(4).trim()] ?? "malformed"; + } else if (m === "DATA") { + dataIndex = i; + } else if (m === "END") { + endIndex = i; + } + }); + + if (failure) return size === undefined ? { ok: false, reason: failure } : { ok: false, reason: failure, size }; + if (dataIndex === -1) return { ok: false, reason: "malformed" }; + // Invariant 3 — a payload with no terminator was cut short. Serving it would + // hand back a truncated file that looks complete. + if (endIndex === -1 || endIndex < dataIndex) return { ok: false, reason: "incomplete", size }; + + // Both coreutils and busybox wrap base64 output; strip all whitespace rather + // than assuming a column width. The base64 alphabet has no tab or newline, so + // nothing between the markers can impersonate one. + const payload = lines.slice(dataIndex + 1, endIndex).join("").replace(/\s+/g, ""); + const content = Buffer.from(payload, "base64"); + + // Authoritative cap. Never return a partial file: a truncated .env that looks + // complete is worse than a refusal. + if (content.byteLength > maxBytes) { + return { ok: false, reason: "too_large", size: size ?? content.byteLength }; + } + // Node's base64 decoder is lenient, so a payload that lost bytes in transit + // decodes happily. The container already told us how big the file was — + // disagreement means we did not receive all of it. + if (size !== undefined && content.byteLength !== size) { + return { ok: false, reason: "incomplete", size }; + } + + return { ok: true, content }; +} + +// ─── Content sniffing ─────────────────────────────────────────────────────── + +/** + * Is this payload binary? Used only to decide between rendering text and + * offering a download — never to gate access. + * + * A NUL byte is decisive. Beyond that, a high density of C0 control characters + * (excluding tab/newline/CR, which are ordinary in text) means it isn't + * something a text pane should render. Multi-byte UTF-8 is all >= 0x80 and is + * deliberately NOT counted, so translated content reads as text. + */ +export function looksBinary(buf: Buffer): boolean { + if (buf.byteLength === 0) return false; + + const sample = buf.subarray(0, 8192); + let control = 0; + for (const byte of sample) { + if (byte === 0) return true; + const isOrdinaryWhitespace = byte === 0x09 || byte === 0x0a || byte === 0x0d; + if (!isOrdinaryWhitespace && byte < 0x20) control++; + } + return control / sample.byteLength > 0.1; +} diff --git a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/services/ServiceDetailPanel.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/services/ServiceDetailPanel.tsx index f42552b95..3f9b6fc91 100644 --- a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/services/ServiceDetailPanel.tsx +++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/services/ServiceDetailPanel.tsx @@ -36,6 +36,7 @@ import { Settings, Trash2, DatabaseBackup, + FolderTree, PlayCircle, Plus, LayoutDashboard, @@ -48,6 +49,7 @@ import { backupsApi, getApiErrorMessage, type BackupPolicy } from "@/lib/api"; import { PolicyEditor } from "@/components/backup/PolicyEditor"; import { BackupRunCard } from "@/components/backup/BackupRunCard"; import { ServiceTerminal } from "@/components/terminal/ServiceTerminal"; +import { ServiceFiles } from "@/components/files/ServiceFiles"; import { useTheme } from "@/components/theme-provider"; import { Tabs, type TabDef } from "@/components/ui/Tabs"; import DropdownMenu from "@/components/ui/DropdownMenu"; @@ -58,10 +60,13 @@ import { endpoints } from "@/lib/api/endpoints"; import { useI18n, interpolate } from "@/components/i18n-provider"; import { useLocalhostForward } from "@/hooks/useLocalhostForward"; -type ServiceTab = "overview" | "terminal" | "logs" | "env" | "settings" | "backup"; +type ServiceTab = "overview" | "terminal" | "files" | "logs" | "env" | "settings" | "backup"; const SERVICE_TAB_DEFS: TabDef[] = [ { key: "overview", label: "Overview", icon: LayoutDashboard }, { key: "terminal", label: "Terminal", icon: Terminal }, + // Sits next to Terminal on purpose: same reach, same server-side gate — one + // is the shell, the other is the browser over the same filesystem. + { key: "files", label: "Files", icon: FolderTree }, { key: "logs", label: "Logs", icon: ScrollText }, { key: "env", label: "Environment", icon: Variable }, { key: "settings", label: "Settings", icon: Settings }, @@ -751,6 +756,11 @@ export function ServiceDetailPanel({ ))} + {/* ── Files ──────────────────────────────────────────────── */} + {/* Opens at the container root: the dashboard has no reliable read on a + service's WORKDIR, and guessing wrong strands the user in a 404. */} + {activeTab === "files" && } + {/* ── Logs ───────────────────────────────────────────────── */} {activeTab === "logs" && (
diff --git a/apps/dashboard/src/components/files/ServiceFiles.tsx b/apps/dashboard/src/components/files/ServiceFiles.tsx new file mode 100644 index 000000000..64e132789 --- /dev/null +++ b/apps/dashboard/src/components/files/ServiceFiles.tsx @@ -0,0 +1,301 @@ +"use client"; + +/** + * Read-only file browser for a single deployed service. + * + * directory list ↔ GET /api/services/files/:serviceId/list + * text preview ↔ GET /api/services/files/:serviceId/read + * download ↔ GET /api/services/files/:serviceId/download + * + * Sibling of and gated identically server-side — this is the + * same reach as a shell, presented as a browser. + * + * Two rules this component holds to: + * + * 1. NEVER BUILD A PATH. Navigation always uses the `path` the server returned + * for an entry (or a prefix of the current path for breadcrumbs), so + * normalization stays in one place and an entry literally named `..` can't + * become a client-side traversal. + * + * 2. NEVER SILENTLY TRUNCATE. A file over the cap renders as an explicit + * refusal with a download offer. A truncated `.env` that looks complete is + * worse than no `.env` at all. + * + * Layout is single-column on phones: picking a file swaps the list for the + * viewer (with a back affordance) rather than squeezing two panes side by side. + */ + +import { useCallback, useEffect, useState } from "react"; +import { + ArrowLeft, + ChevronRight, + Download, + File as FileIcon, + FileWarning, + Folder, + Link2, + Loader2, + RotateCw, +} from "lucide-react"; +import { + listServiceFiles, + readServiceFile, + downloadServiceFile, + type ServiceFileEntry, + type ServiceFileContent, +} from "@/lib/api/service-files"; +import { getApiErrorMessage } from "@/lib/api"; +import { formatBytes } from "@/lib/formatBytes"; +import { useI18n, interpolate } from "@/components/i18n-provider"; + +interface ServiceFilesProps { + serviceId: string; + /** Where to open. The service's working directory when known, else the root. */ + initialPath?: string; +} + +export function ServiceFiles({ serviceId, initialPath = "/" }: ServiceFilesProps) { + const { t } = useI18n(); + const copy = t.projectDetail.services.detail.files; + + const [path, setPath] = useState(initialPath); + const [entries, setEntries] = useState(null); + const [truncated, setTruncated] = useState(false); + const [listError, setListError] = useState(null); + const [listing, setListing] = useState(false); + + const [selected, setSelected] = useState(null); + const [content, setContent] = useState(null); + const [readError, setReadError] = useState(null); + const [reading, setReading] = useState(false); + const [downloading, setDownloading] = useState(false); + const [downloadError, setDownloadError] = useState(null); + + const loadDirectory = useCallback( + async (target: string) => { + setListing(true); + setListError(null); + try { + const res = await listServiceFiles(serviceId, target); + setEntries(res.entries); + setTruncated(res.truncated); + // Adopt the server's canonical spelling so breadcrumbs match what was + // actually listed rather than what was asked for. + setPath(res.path); + } catch (err) { + setEntries(null); + setListError(getApiErrorMessage(err, copy.loadFailed)); + } finally { + setListing(false); + } + }, + [serviceId, copy.loadFailed], + ); + + useEffect(() => { + void loadDirectory(initialPath); + }, [loadDirectory, initialPath]); + + const openEntry = useCallback( + async (entry: ServiceFileEntry) => { + if (entry.type === "dir") { + setSelected(null); + setContent(null); + setReadError(null); + void loadDirectory(entry.path); + return; + } + + setSelected(entry); + setContent(null); + setReadError(null); + setDownloadError(null); + setReading(true); + try { + setContent(await readServiceFile(serviceId, entry.path)); + } catch (err) { + setReadError(getApiErrorMessage(err, copy.readFailed)); + } finally { + setReading(false); + } + }, + [serviceId, loadDirectory, copy.readFailed], + ); + + // Breadcrumb segments, each carrying the absolute prefix it stands for. + const crumbs = path.split("/").filter(Boolean); + const crumbPath = (index: number) => `/${crumbs.slice(0, index + 1).join("/")}`; + + return ( +
+ {/* ── Breadcrumb ─────────────────────────────────────────── */} +
+ + {crumbs.map((segment, i) => ( + + + + + ))} + +
+ +
+ {/* ── Directory list ───────────────────────────────────── */} + {/* Hidden on phones once a file is open — see the layout note above. */} +
+ {listError ? ( +

{listError}

+ ) : entries === null ? ( +

+ + {copy.loading} +

+ ) : entries.length === 0 ? ( +

{copy.empty}

+ ) : ( +
    + {entries.map((entry) => ( +
  • + +
  • + ))} +
+ )} + {truncated && ( + /* A capped listing that looked complete would tell the operator a + directory holds 500 files when it holds 40,000. */ +

+ {interpolate(copy.truncated, { limit: String(entries?.length ?? 0) })} +

+ )} +
+ + {/* ── Viewer ───────────────────────────────────────────── */} +
+ {!selected ? ( +

{copy.selectPrompt}

+ ) : ( + <> +
+ + {selected.name} + +
+ + {downloadError && ( +

+ + {downloadError} +

+ )} + + {reading ? ( +

+ + {copy.loading} +

+ ) : readError ? ( + // Covers the capped case too: the server sends a real status and + // a plain-language message rather than a partial body. +

+ + {readError} +

+ ) : content?.binary ? ( +

+ + {interpolate(copy.binary, { size: formatBytes(content.size) })} +

+ ) : content ? ( +
+                  {content.content}
+                
+ ) : null} + + )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/i18n/locales/ar/projectDetail.json b/apps/dashboard/src/i18n/locales/ar/projectDetail.json index 277d0a29d..36935dfda 100644 --- a/apps/dashboard/src/i18n/locales/ar/projectDetail.json +++ b/apps/dashboard/src/i18n/locales/ar/projectDetail.json @@ -138,6 +138,7 @@ "tabs": { "overview": "نظرة عامة", "terminal": "الطرفية", + "files": "الملفات", "logs": "السجلات", "env": "البيئة", "settings": "الإعدادات", @@ -209,7 +210,20 @@ "backupRunFailed": "فشلت عملية النسخ الاحتياطي" }, "duplicateContainers": "توجد حاوية أخرى مطابقة لهذه الخدمة: {names}", - "editInSettings": "تعديل في الإعدادات" + "editInSettings": "تعديل في الإعدادات", + "files": { + "loading": "جارٍ التحميل…", + "empty": "هذا المجلد فارغ", + "selectPrompt": "اختر ملفًا لمعاينته", + "download": "تنزيل", + "binary": "ملف ثنائي ({size}) — نزّله لعرضه", + "loadFailed": "تعذّر فتح هذا المجلد", + "readFailed": "تعذّر فتح هذا الملف", + "refresh": "تحديث", + "back": "رجوع", + "downloadFailed": "فشل التنزيل", + "truncated": "يتم عرض أول {limit} عنصرًا — يحتوي هذا المجلد على المزيد." + } }, "settingsForm": { "name": "الاسم", diff --git a/apps/dashboard/src/i18n/locales/de/projectDetail.json b/apps/dashboard/src/i18n/locales/de/projectDetail.json index bc8385c24..f5953736a 100644 --- a/apps/dashboard/src/i18n/locales/de/projectDetail.json +++ b/apps/dashboard/src/i18n/locales/de/projectDetail.json @@ -138,6 +138,7 @@ "tabs": { "overview": "Übersicht", "terminal": "Terminal", + "files": "Dateien", "logs": "Protokolle", "env": "Umgebung", "settings": "Einstellungen", @@ -209,7 +210,20 @@ "backupRunFailed": "Backup-Ausführung fehlgeschlagen" }, "duplicateContainers": "Ein weiterer Container passt ebenfalls zu diesem Service: {names}", - "editInSettings": "In Einstellungen bearbeiten" + "editInSettings": "In Einstellungen bearbeiten", + "files": { + "loading": "Wird geladen…", + "empty": "Dieser Ordner ist leer", + "selectPrompt": "Wählen Sie eine Datei zur Vorschau", + "download": "Herunterladen", + "binary": "Binärdatei ({size}) — zum Ansehen herunterladen", + "loadFailed": "Ordner konnte nicht geöffnet werden", + "readFailed": "Datei konnte nicht geöffnet werden", + "refresh": "Aktualisieren", + "back": "Zurück", + "downloadFailed": "Download fehlgeschlagen", + "truncated": "Die ersten {limit} Einträge werden angezeigt — dieser Ordner enthält mehr." + } }, "settingsForm": { "name": "Name", diff --git a/apps/dashboard/src/i18n/locales/en/projectDetail.json b/apps/dashboard/src/i18n/locales/en/projectDetail.json index a962c18ca..d6aab344d 100644 --- a/apps/dashboard/src/i18n/locales/en/projectDetail.json +++ b/apps/dashboard/src/i18n/locales/en/projectDetail.json @@ -141,6 +141,7 @@ "tabs": { "overview": "Overview", "terminal": "Terminal", + "files": "Files", "logs": "Logs", "env": "Environment", "settings": "Settings", @@ -216,7 +217,20 @@ "backupRunFailed": "Backup run failed" }, "duplicateContainers": "Another container also matches this service: {names}", - "editInSettings": "Edit in settings" + "editInSettings": "Edit in settings", + "files": { + "loading": "Loading…", + "empty": "This folder is empty", + "selectPrompt": "Select a file to preview", + "download": "Download", + "binary": "Binary file ({size}) — download it to view", + "loadFailed": "Could not open this folder", + "readFailed": "Could not open this file", + "refresh": "Refresh", + "back": "Back", + "downloadFailed": "Download failed", + "truncated": "Showing the first {limit} entries — this folder has more." + } }, "settingsForm": { "name": "Name", diff --git a/apps/dashboard/src/i18n/locales/es/projectDetail.json b/apps/dashboard/src/i18n/locales/es/projectDetail.json index a794f6db7..a2b859e7a 100644 --- a/apps/dashboard/src/i18n/locales/es/projectDetail.json +++ b/apps/dashboard/src/i18n/locales/es/projectDetail.json @@ -138,6 +138,7 @@ "tabs": { "overview": "Resumen", "terminal": "Terminal", + "files": "Archivos", "logs": "Registros", "env": "Entorno", "settings": "Configuración", @@ -209,7 +210,20 @@ "backupRunFailed": "La ejecución de la copia de seguridad falló" }, "duplicateContainers": "Otro contenedor también coincide con este servicio: {names}", - "editInSettings": "Editar en ajustes" + "editInSettings": "Editar en ajustes", + "files": { + "loading": "Cargando…", + "empty": "Esta carpeta está vacía", + "selectPrompt": "Selecciona un archivo para previsualizar", + "download": "Descargar", + "binary": "Archivo binario ({size}): descárgalo para verlo", + "loadFailed": "No se pudo abrir esta carpeta", + "readFailed": "No se pudo abrir este archivo", + "refresh": "Actualizar", + "back": "Atrás", + "downloadFailed": "Error al descargar", + "truncated": "Mostrando las primeras {limit} entradas: esta carpeta tiene más." + } }, "settingsForm": { "name": "Nombre", diff --git a/apps/dashboard/src/i18n/locales/fr/projectDetail.json b/apps/dashboard/src/i18n/locales/fr/projectDetail.json index 7322d8668..0a40ae03d 100644 --- a/apps/dashboard/src/i18n/locales/fr/projectDetail.json +++ b/apps/dashboard/src/i18n/locales/fr/projectDetail.json @@ -141,6 +141,7 @@ "tabs": { "overview": "Aperçu", "terminal": "Terminal", + "files": "Fichiers", "logs": "Journaux", "env": "Environnement", "settings": "Paramètres", @@ -216,7 +217,20 @@ "currentIp": "IP actuelle", "currentIpHint": "Change au redémarrage — utilisez plutôt l'adresse interne ci-dessus.", "duplicateContainers": "Un autre conteneur correspond aussi à ce service : {names}", - "editInSettings": "Modifier dans les paramètres" + "editInSettings": "Modifier dans les paramètres", + "files": { + "loading": "Chargement…", + "empty": "Ce dossier est vide", + "selectPrompt": "Sélectionnez un fichier à prévisualiser", + "download": "Télécharger", + "binary": "Fichier binaire ({size}) — téléchargez-le pour le consulter", + "loadFailed": "Impossible d’ouvrir ce dossier", + "readFailed": "Impossible d’ouvrir ce fichier", + "refresh": "Actualiser", + "back": "Retour", + "downloadFailed": "Échec du téléchargement", + "truncated": "Affichage des {limit} premières entrées — ce dossier en contient plus." + } }, "settingsForm": { "name": "Nom", diff --git a/apps/dashboard/src/i18n/locales/ja/projectDetail.json b/apps/dashboard/src/i18n/locales/ja/projectDetail.json index 8af736fb6..f10ebabf5 100644 --- a/apps/dashboard/src/i18n/locales/ja/projectDetail.json +++ b/apps/dashboard/src/i18n/locales/ja/projectDetail.json @@ -138,6 +138,7 @@ "tabs": { "overview": "概要", "terminal": "ターミナル", + "files": "ファイル", "logs": "ログ", "env": "環境", "settings": "設定", @@ -209,7 +210,20 @@ "backupRunFailed": "バックアップの実行に失敗しました" }, "duplicateContainers": "このサービスに一致する別のコンテナがあります: {names}", - "editInSettings": "設定で編集" + "editInSettings": "設定で編集", + "files": { + "loading": "読み込み中…", + "empty": "このフォルダーは空です", + "selectPrompt": "プレビューするファイルを選択してください", + "download": "ダウンロード", + "binary": "バイナリファイル({size})— 表示するにはダウンロードしてください", + "loadFailed": "このフォルダーを開けませんでした", + "readFailed": "このファイルを開けませんでした", + "refresh": "更新", + "back": "戻る", + "downloadFailed": "ダウンロードに失敗しました", + "truncated": "最初の {limit} 件を表示しています。このフォルダーにはさらに項目があります。" + } }, "settingsForm": { "name": "名前", diff --git a/apps/dashboard/src/i18n/locales/pt/projectDetail.json b/apps/dashboard/src/i18n/locales/pt/projectDetail.json index a0bc6e10a..d5da14b12 100644 --- a/apps/dashboard/src/i18n/locales/pt/projectDetail.json +++ b/apps/dashboard/src/i18n/locales/pt/projectDetail.json @@ -138,6 +138,7 @@ "tabs": { "overview": "Visão geral", "terminal": "Terminal", + "files": "Ficheiros", "logs": "Logs", "env": "Ambiente", "settings": "Configurações", @@ -209,7 +210,20 @@ "backupRunFailed": "Falha na execução do backup" }, "duplicateContainers": "Outro contêiner também corresponde a este serviço: {names}", - "editInSettings": "Editar nas configurações" + "editInSettings": "Editar nas configurações", + "files": { + "loading": "A carregar…", + "empty": "Esta pasta está vazia", + "selectPrompt": "Selecione um ficheiro para pré-visualizar", + "download": "Transferir", + "binary": "Ficheiro binário ({size}) — transfira-o para ver", + "loadFailed": "Não foi possível abrir esta pasta", + "readFailed": "Não foi possível abrir este ficheiro", + "refresh": "Atualizar", + "back": "Voltar", + "downloadFailed": "A transferência falhou", + "truncated": "A mostrar as primeiras {limit} entradas — esta pasta tem mais." + } }, "settingsForm": { "name": "Nome", diff --git a/apps/dashboard/src/i18n/locales/tr/projectDetail.json b/apps/dashboard/src/i18n/locales/tr/projectDetail.json index e6e29a308..a721589dd 100644 --- a/apps/dashboard/src/i18n/locales/tr/projectDetail.json +++ b/apps/dashboard/src/i18n/locales/tr/projectDetail.json @@ -141,6 +141,7 @@ "tabs": { "overview": "Genel Bakış", "terminal": "Terminal", + "files": "Dosyalar", "logs": "Günlükler", "env": "Ortam", "settings": "Ayarlar", @@ -216,7 +217,20 @@ "backupRunFailed": "Yedekleme işlemi başarısız oldu" }, "duplicateContainers": "Bu servisle eşleşen başka bir konteyner var: {names}", - "editInSettings": "Ayarlarda düzenle" + "editInSettings": "Ayarlarda düzenle", + "files": { + "loading": "Yükleniyor…", + "empty": "Bu klasör boş", + "selectPrompt": "Önizlemek için bir dosya seçin", + "download": "İndir", + "binary": "İkili dosya ({size}) — görüntülemek için indirin", + "loadFailed": "Bu klasör açılamadı", + "readFailed": "Bu dosya açılamadı", + "refresh": "Yenile", + "back": "Geri", + "downloadFailed": "İndirme başarısız", + "truncated": "İlk {limit} öğe gösteriliyor — bu klasörde daha fazlası var." + } }, "settingsForm": { "name": "Ad", diff --git a/apps/dashboard/src/i18n/locales/zh/projectDetail.json b/apps/dashboard/src/i18n/locales/zh/projectDetail.json index 9fb73549e..dd556fb79 100644 --- a/apps/dashboard/src/i18n/locales/zh/projectDetail.json +++ b/apps/dashboard/src/i18n/locales/zh/projectDetail.json @@ -138,6 +138,7 @@ "tabs": { "overview": "概览", "terminal": "终端", + "files": "文件", "logs": "日志", "env": "环境", "settings": "设置", @@ -209,7 +210,20 @@ "backupRunFailed": "备份运行失败" }, "duplicateContainers": "另一个容器也匹配此服务:{names}", - "editInSettings": "在设置中编辑" + "editInSettings": "在设置中编辑", + "files": { + "loading": "加载中…", + "empty": "此文件夹为空", + "selectPrompt": "选择一个文件进行预览", + "download": "下载", + "binary": "二进制文件({size})— 请下载后查看", + "loadFailed": "无法打开此文件夹", + "readFailed": "无法打开此文件", + "refresh": "刷新", + "back": "返回", + "downloadFailed": "下载失败", + "truncated": "仅显示前 {limit} 项 — 此文件夹中还有更多。" + } }, "settingsForm": { "name": "名称", diff --git a/apps/dashboard/src/lib/api/endpoints.ts b/apps/dashboard/src/lib/api/endpoints.ts index d71981938..0f8cd9504 100644 --- a/apps/dashboard/src/lib/api/endpoints.ts +++ b/apps/dashboard/src/lib/api/endpoints.ts @@ -514,6 +514,18 @@ export const endpoints = { wsPath: (serviceId: string) => `services/terminal/ws/${serviceId}`, }, + /* ---------------------------------------------------------------- */ + /* Service files (read-only browsing inside a container) */ + /* ---------------------------------------------------------------- */ + serviceFiles: { + list: (serviceId: string, path: string) => + `services/files/${serviceId}/list?path=${encodeURIComponent(path)}`, + read: (serviceId: string, path: string) => + `services/files/${serviceId}/read?path=${encodeURIComponent(path)}`, + download: (serviceId: string, path: string) => + `services/files/${serviceId}/download?path=${encodeURIComponent(path)}`, + }, + /* ---------------------------------------------------------------- */ /* Backup destinations (per-user) */ /* ---------------------------------------------------------------- */ diff --git a/apps/dashboard/src/lib/api/service-files.ts b/apps/dashboard/src/lib/api/service-files.ts new file mode 100644 index 000000000..b7c6bca35 --- /dev/null +++ b/apps/dashboard/src/lib/api/service-files.ts @@ -0,0 +1,99 @@ +/** + * Service files API client — read-only browsing inside a deployed container. + * + * Sibling of service-terminal.ts and gated identically server-side (project + * admin): a container's filesystem holds its `.env`, so this is the same reach + * as a shell, not a lesser one. + * + * Paths are always the ones the SERVER returned (`entry.path`), never built by + * concatenating strings here — normalization lives in exactly one place so a + * name like `..` can't become a client-side traversal. + */ + +import { api, getApiBaseUrl } from "./client"; +import { endpoints } from "./endpoints"; + +export interface ServiceFileEntry { + name: string; + /** RESOLVED type — a symlink reports what it points AT, so a symlinked + * directory (`storage`, `current`, …) is navigable rather than a dead end. */ + type: "file" | "dir"; + /** True when the entry itself is a symlink, whatever it resolves to. */ + symlink: boolean; + /** Bytes for regular files; 0 for directories. */ + size: number; + /** Canonical absolute path inside the container, resolved server-side. */ + path: string; +} + +export interface ServiceFileListing { + success: true; + /** The canonical form of the directory actually listed. */ + path: string; + /** The directory holds more than `limit` entries and was cut short. Surfaced + * so the UI can say so — a capped listing that looked complete would report + * 500 files in a directory holding 40,000. */ + truncated: boolean; + limit: number; + entries: ServiceFileEntry[]; +} + +export type ServiceFileContent = + | { success: true; path: string; binary: false; size: number; content: string } + | { success: true; path: string; binary: true; size: number; content: null }; + +export function listServiceFiles( + serviceId: string, + path: string, +): Promise { + return api.get(endpoints.serviceFiles.list(serviceId, path)); +} + +export function readServiceFile( + serviceId: string, + path: string, +): Promise { + return api.get(endpoints.serviceFiles.read(serviceId, path)); +} + +/** + * Download a file to disk. + * + * Deliberately NOT a plain ``: the endpoint answers 413 (over the cap), + * 404 (deleted since listing) or 403 with a JSON body and no + * Content-Disposition, and a top-level navigation would replace the dashboard + * with a raw `{"error":…}` page. Fetching lets a failure stay an in-place + * message. Bounded by the server's 10MB cap, so buffering a blob is safe. + * + * Throws with the server's message on failure. + */ +export async function downloadServiceFile( + serviceId: string, + path: string, + filename: string, +): Promise { + const url = new URL(endpoints.serviceFiles.download(serviceId, path), getApiBaseUrl()).toString(); + const res = await fetch(url, { credentials: "include" }); + + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error( + (body as { error?: string } | null)?.error ?? `Download failed (${res.status})`, + ); + } + + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + try { + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + } finally { + // Revoke on the next tick — revoking synchronously can cancel the download + // in some browsers before it has read the blob. + setTimeout(() => URL.revokeObjectURL(objectUrl), 0); + } +}