From 045dd47b9ad1499f34bca4eca4ffa918cc01a9ec Mon Sep 17 00:00:00 2001 From: Diogo Duarte <31131606+DiogoDuart3@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:20:19 +0100 Subject: [PATCH] feat(services): read-only file browsing inside a deployed service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Files tab next to Terminal — breadcrumb, directory listing with sizes, text preview and download — plus the endpoints behind it: GET /api/services/files/:serviceId/{list,read,download}. Built on runtime.inContainerExecutor(), which docker and cloud both implement as sh -c inside the container, so one command string serves both and there is no docker-only path (getArchive) for cloud to lack. Gated at the service terminal's admin tier with the same 404-shaped denial: a container's filesystem holds its .env, so browsing it is exactly as sensitive as opening a shell in it. Gated on the serviceShell capability, which excludes bare — bare's inContainerExecutor ignores its containerId and returns the HOST executor, so a weaker gate would make this a host filesystem browser. The probe protocol holds four invariants, each with tests: 1. Commands always exit 0. exec rejects on a non-zero exit with stdout as the message, so leaning on exit codes would turn every 'file not found' into a 500. Failures are markers on stdout. 2. Markers are nonce-scoped. Newlines are legal in filenames and the probe prints names verbatim, so without a nonce a file could be NAMED to forge 'ERR denied' (whole directory reads as forbidden) or 'END' (listing truncated while still reporting success). Each probe mints an unpredictable nonce a filename cannot contain. 3. Every probe is terminated, and reads also verify the decoded length against the size the container reported. Node's base64 decoder is lenient about truncated input, so without both a half-read .env would be served looking complete. 4. Only stdout carries signal, and payloads are base64 — an alphabet with no tab and no newline — so a stray stderr line can neither forge a marker nor corrupt a payload on either runtime. Reads refuse anything that is not a regular file: without that a FIFO or character device passes every other guard and 'wc -c < /dev/zero' never returns, bypassing both size caps. Bounds: 2MB preview, 10MB download, 500 entries per directory (which also bounds the per-file fork count), and a per-user concurrency cap mirroring the terminal's. Over-cap results refuse rather than truncate, and a capped listing is surfaced in the UI. Verified against real containers on busybox, dash and bash: hostile filenames, symlinked and broken symlinks, empty directories, devices and FIFOs, and a 138KB binary round-tripped byte-exact. Container-read failures answer 500 rather than 502: Cloudflare treats a 502 from the origin as a gateway failure and replaces the body with its own error page, so the message would be invisible behind a CDN. Strings translated across all 9 locales so the i18n parity ratchet tightens rather than growing. --- apps/api/src/app.ts | 11 + .../service-files/service-files.controller.ts | 414 ++++++++++++++++++ .../service-files/service-files.routes.ts | 31 ++ .../service-files.service.test.ts | 363 +++++++++++++++ .../service-files/service-files.service.ts | 379 ++++++++++++++++ .../services/ServiceDetailPanel.tsx | 12 +- .../src/components/files/ServiceFiles.tsx | 301 +++++++++++++ .../src/i18n/locales/ar/projectDetail.json | 16 +- .../src/i18n/locales/de/projectDetail.json | 16 +- .../src/i18n/locales/en/projectDetail.json | 16 +- .../src/i18n/locales/es/projectDetail.json | 16 +- .../src/i18n/locales/fr/projectDetail.json | 16 +- .../src/i18n/locales/ja/projectDetail.json | 16 +- .../src/i18n/locales/pt/projectDetail.json | 16 +- .../src/i18n/locales/tr/projectDetail.json | 16 +- .../src/i18n/locales/zh/projectDetail.json | 16 +- apps/dashboard/src/lib/api/endpoints.ts | 12 + apps/dashboard/src/lib/api/service-files.ts | 99 +++++ 18 files changed, 1756 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/modules/service-files/service-files.controller.ts create mode 100644 apps/api/src/modules/service-files/service-files.routes.ts create mode 100644 apps/api/src/modules/service-files/service-files.service.test.ts create mode 100644 apps/api/src/modules/service-files/service-files.service.ts create mode 100644 apps/dashboard/src/components/files/ServiceFiles.tsx create mode 100644 apps/dashboard/src/lib/api/service-files.ts 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); + } +}