mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
fix(files): read remote-case file previews and downloads over ssh
A remote case's workingDir is an absolute path on the remote host, but the file read routes resolved it with local `fs`: `validateSessionFilePath`'s realpathSync fails for a path that does not exist on the Codeman host, so every preview of an agent-written file answered "File not found" (#415). Add src/remote-files.ts as the single remote-read layer, built on the same buildSshConnectionArgs() the launch uses: - remoteProbePaths(): ONE round trip returning realpath + stat for the requested path AND the workspace root, so containment is checked against a remotely canonicalized root (a symlinked remotePath is ordinary). - remoteCreateReadStream(): streams the body (cat, or tail -c +N | head -c L for a Range) with nothing buffered in memory, and reaps the ssh child when the response ends so an aborted download cannot orphan it. - remoteReadFile(): bounded read for file-content. file-raw, file-content, file-preview and file-thumbnail now share one local/ remote target resolution. Guards keep their local strength: lexical pre-check, remote realpath, workspace containment, sensitive-path blocklist, and the size cap applied to the remote size before any bytes are read. An unreachable host answers 502 with the remote reason instead of a misleading 404. Nothing is ever copied to the Codeman host and there is NO local fallback (an sshfs mount of the same tree must not shadow the remote bytes). Deliberately unchanged: writes (edit=1 / PUT now answer 400 explicitly while the viewer hides its Edit affordance), office previews, thumbnails, file tree, picker, external attachment registration and tail-file stay local-only.
This commit is contained in:
@@ -88,11 +88,43 @@ export function validateSessionFilePath(
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const relativePath = relative(resolvedWorkingDir, resolvedPath);
|
||||
return confineToRoot(resolvedWorkingDir, resolvedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lexical half of {@link validateSessionFilePath}: same containment rule, but
|
||||
* WITHOUT touching the filesystem.
|
||||
*
|
||||
* Needed for remote-SSH cases (`src/remote-files.ts`), where `workingDir` is an
|
||||
* absolute path on the REMOTE host and any local `realpathSync` fails by
|
||||
* construction — which is how every file-raw/file-content request in a remote case
|
||||
* used to end up as a 404 before a single byte was read. The caller follows this
|
||||
* pre-check with a remote realpath + the same containment rule, so escapes are
|
||||
* refused exactly as they are locally; what changes is only WHICH filesystem
|
||||
* resolves the symlinks.
|
||||
*
|
||||
* A lexical check alone would follow nothing, so it must never be the last word for
|
||||
* a path that can contain a symlink — it is the cheap reject in front of the real
|
||||
* (local or remote) resolution, not a replacement for it.
|
||||
*/
|
||||
export function validateSessionFilePathLexical(
|
||||
sessionWorkingDir: string,
|
||||
filePath: string
|
||||
): { resolvedPath: string; relativePath: string } | null {
|
||||
return confineToRoot(resolve(sessionWorkingDir), resolve(sessionWorkingDir, filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared containment rule: `candidate` must sit inside `root` (both already
|
||||
* canonical for their filesystem). `relative()` is the whole test — a `..` or an
|
||||
* absolute result means the candidate escaped.
|
||||
*/
|
||||
function confineToRoot(root: string, candidate: string): { resolvedPath: string; relativePath: string } | null {
|
||||
const relativePath = relative(root, candidate);
|
||||
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
return { resolvedPath, relativePath };
|
||||
return { resolvedPath: candidate, relativePath };
|
||||
}
|
||||
|
||||
// Maximum hook data size (prevents oversized SSE broadcasts)
|
||||
|
||||
+348
-50
@@ -8,7 +8,8 @@
|
||||
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import { basename as pathBasename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
||||
import { createReadStream, realpathSync } from 'node:fs';
|
||||
import type { Readable } from 'node:stream';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
@@ -47,9 +48,17 @@ import {
|
||||
getAuthUser,
|
||||
parseBody,
|
||||
validateSessionFilePath,
|
||||
validateSessionFilePathLexical,
|
||||
} from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import type { SessionAttachmentHistoryItem, SessionRemote, SessionState } from '../../types/session.js';
|
||||
import {
|
||||
RemoteFileAccessError,
|
||||
remoteCreateReadStream,
|
||||
remoteProbePaths,
|
||||
remoteReadFile,
|
||||
type RemoteProbe,
|
||||
} from '../../remote-files.js';
|
||||
import { downloadTooLargeMessage, exceedsDownloadLimit } from '../../config/buffer-limits.js';
|
||||
import { parseByteRange } from '../http-range.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
@@ -106,7 +115,7 @@ function buildContentDisposition(disposition: 'inline' | 'attachment', fileName:
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||
function sendRawStream(reply: FastifyReply, content: Readable, cleanup?: () => void): void {
|
||||
const headers = reply.getHeaders();
|
||||
// hijack() answers on reply.raw, which keeps Fastify's own status handling out
|
||||
// of the picture — so a 206 set with reply.code() has to be carried across by
|
||||
@@ -122,6 +131,14 @@ function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||
}
|
||||
}
|
||||
|
||||
// A remote body is an `ssh` child process, not a file handle: it has to be reaped
|
||||
// when the client goes away (tab closed, video seek, a cancelled fetch), or the
|
||||
// ssh process outlives the request. Registered here because this is the one place
|
||||
// that owns the response's lifecycle.
|
||||
if (cleanup) {
|
||||
reply.raw.on('close', cleanup);
|
||||
}
|
||||
|
||||
content.on('error', (err) => {
|
||||
if (reply.raw.headersSent) {
|
||||
reply.raw.destroy(err);
|
||||
@@ -134,6 +151,22 @@ function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||
content.pipe(reply.raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a response body's bytes come from. Local files and remote (SSH) files share
|
||||
* the range math below; only the source differs.
|
||||
*/
|
||||
interface FileBodySource {
|
||||
content: Readable;
|
||||
cleanup?: () => void;
|
||||
}
|
||||
|
||||
/** Byte source for a LOCAL file (the historical, only path). */
|
||||
function localFileSource(resolvedPath: string) {
|
||||
return (range?: { start: number; end: number }): FileBodySource => ({
|
||||
content: createReadStream(resolvedPath, range ? { start: range.start, end: range.end } : undefined),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a file body, honoring a `Range` request header.
|
||||
*
|
||||
@@ -144,12 +177,16 @@ function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||
* does nothing and `currentTime = x` is silently reverted (measured against an
|
||||
* 18MB mp4 before this existed). It also stops each seek from re-reading the
|
||||
* whole file into memory.
|
||||
*
|
||||
* `open` supplies the bytes for the (optional) range, which is what lets a remote
|
||||
* case reuse this instead of re-implementing the 206/416 contract: same headers,
|
||||
* same status codes, whether the file sits on this host or behind an ssh pipe.
|
||||
*/
|
||||
function sendFileBody(
|
||||
reply: FastifyReply,
|
||||
resolvedPath: string,
|
||||
size: number,
|
||||
rangeHeader: string | string[] | undefined
|
||||
rangeHeader: string | string[] | undefined,
|
||||
open: (range?: { start: number; end: number }) => FileBodySource
|
||||
): void {
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
const range = parseByteRange(rangeHeader, size);
|
||||
@@ -167,12 +204,14 @@ function sendFileBody(
|
||||
reply.code(206);
|
||||
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`);
|
||||
reply.header('Content-Length', range.end - range.start + 1);
|
||||
sendRawStream(reply, createReadStream(resolvedPath, { start: range.start, end: range.end }));
|
||||
const { content, cleanup } = open({ start: range.start, end: range.end });
|
||||
sendRawStream(reply, content, cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.header('Content-Length', size);
|
||||
sendRawStream(reply, createReadStream(resolvedPath));
|
||||
const { content, cleanup } = open();
|
||||
sendRawStream(reply, content, cleanup);
|
||||
}
|
||||
|
||||
async function serveRawFile(
|
||||
@@ -201,7 +240,7 @@ async function serveRawFile(
|
||||
);
|
||||
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
|
||||
sendFileBody(reply, stat.size, rangeHeader, localFileSource(resolvedPath));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -212,14 +251,14 @@ async function serveRawFile(
|
||||
reply.header('Content-Type', 'text/plain; charset=utf-8');
|
||||
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
|
||||
sendFileBody(reply, stat.size, rangeHeader, localFileSource(resolvedPath));
|
||||
return;
|
||||
}
|
||||
|
||||
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
||||
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
|
||||
sendFileBody(reply, stat.size, rangeHeader, localFileSource(resolvedPath));
|
||||
}
|
||||
|
||||
function getAttachmentOr404(
|
||||
@@ -344,19 +383,196 @@ function getKnownSessionWorkingDir(
|
||||
reply: FastifyReply,
|
||||
req: FastifyRequest
|
||||
): string | undefined {
|
||||
// One implementation of the live-or-persisted lookup and its ownership rule; this
|
||||
// wrapper exists for the callers that only need the workspace PATH.
|
||||
return getKnownSessionFileScope(ctx, sessionId, reply, req)?.workingDir;
|
||||
}
|
||||
|
||||
// ===== Remote (SSH) file access =====
|
||||
//
|
||||
// A remote case's `workingDir` is an absolute path on ANOTHER host
|
||||
// (`Session.workingDir = RemoteCase.remotePath`). Every read route below used to call
|
||||
// `validateSessionFilePath`, whose LOCAL `realpathSync` cannot resolve a path that by
|
||||
// definition does not exist on this machine — so a remote preview failed as a 404
|
||||
// before the read, and the ssh-aware launch path next door had no counterpart on the
|
||||
// file side (#415). The helpers here give both kinds of case one entry point:
|
||||
//
|
||||
// local → realpath + workspace boundary + local `fs` (unchanged behavior)
|
||||
// remote → lexical pre-check, then realpath + boundary + stat ON THE REMOTE HOST
|
||||
//
|
||||
// The remote branch is not a weaker check: the symlink resolution that makes the
|
||||
// local boundary honest is performed remotely (`remoteProbePaths`), and the same
|
||||
// containment rule (`isPathWithinRoot`) is applied to its result, so a symlink inside
|
||||
// a remote workspace still cannot reach outside it. Everything is read-only — remote
|
||||
// WRITES (edit mode) stay unsupported on purpose, see docs/file-viewer-edit-plan.md §6.
|
||||
|
||||
/** Where a session's files live: this host, or a host reachable over ssh. */
|
||||
interface SessionFileScope {
|
||||
workingDir: string;
|
||||
remote?: SessionRemote;
|
||||
}
|
||||
|
||||
/**
|
||||
* The file-access scope of a session, live or persisted — the `workingDir`-only
|
||||
* variant of {@link getKnownSessionWorkingDir}, plus the remote metadata the routes
|
||||
* need to decide WHERE to read. Same 404-and-return-undefined contract for an
|
||||
* unknown/foreign session, so multi-user scoping is unchanged.
|
||||
*/
|
||||
function getKnownSessionFileScope(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string,
|
||||
reply: FastifyReply,
|
||||
req: FastifyRequest
|
||||
): SessionFileScope | undefined {
|
||||
// Multi-user: a non-admin may only reach their OWN session's files. A foreign
|
||||
// (or missing) session is reported identically as 404 so existence isn't leaked.
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession && canAccessOwned(user, liveSession.owner)) return liveSession.workingDir;
|
||||
if (liveSession && canAccessOwned(user, liveSession.owner)) {
|
||||
return { workingDir: liveSession.workingDir, remote: liveSession.remote };
|
||||
}
|
||||
|
||||
const stored = ctx.store.getSession(sessionId);
|
||||
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) return stored.workingDir;
|
||||
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) {
|
||||
return { workingDir: stored.workingDir, remote: stored.remote };
|
||||
}
|
||||
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file a read route may serve.
|
||||
*
|
||||
* For a remote case `resolvedPath` is the path with symlinks resolved ON THE REMOTE
|
||||
* HOST (never a local path), which is what the guards, the size cap and the stream
|
||||
* all operate on. `probe` carries the remote stat the resolution already paid for, so
|
||||
* the routes do not need a second round trip.
|
||||
*/
|
||||
type FileTarget =
|
||||
| { kind: 'local'; resolvedPath: string; relativePath: string }
|
||||
| {
|
||||
kind: 'remote';
|
||||
resolvedPath: string;
|
||||
relativePath: string;
|
||||
remote: SessionRemote;
|
||||
probe: RemoteProbe;
|
||||
};
|
||||
|
||||
/** Resolution outcome, so each route can answer in its own established style. */
|
||||
type FileTargetResolution =
|
||||
| { ok: true; target: FileTarget }
|
||||
| {
|
||||
ok: false;
|
||||
/** `unreachable` = the ssh probe failed; everything else is a refusal. */
|
||||
reason: 'not-found' | 'unreachable';
|
||||
status: number;
|
||||
errorCode: ApiErrorCode;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a request's `?path=` against the session's file scope.
|
||||
*
|
||||
* Never throws: a transport failure comes back as `status: 502` with the remote
|
||||
* reason, so an unreachable host reads as an infrastructure problem instead of the
|
||||
* 404 that used to make it look like user error.
|
||||
*/
|
||||
async function resolveFileTarget(scope: SessionFileScope, filePath: string): Promise<FileTargetResolution> {
|
||||
if (!scope.remote) {
|
||||
const validated = validateSessionFilePath(scope.workingDir, filePath);
|
||||
if (!validated) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'not-found',
|
||||
status: 404,
|
||||
errorCode: ApiErrorCode.NOT_FOUND,
|
||||
message: 'File not found',
|
||||
};
|
||||
}
|
||||
return { ok: true, target: { kind: 'local', ...validated } };
|
||||
}
|
||||
|
||||
const remote = scope.remote;
|
||||
// Cheap lexical reject BEFORE opening a connection: a `../` escape never needs to
|
||||
// be asked about on the remote host.
|
||||
const lexical = validateSessionFilePathLexical(scope.workingDir, filePath);
|
||||
if (!lexical) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'not-found',
|
||||
status: 404,
|
||||
errorCode: ApiErrorCode.NOT_FOUND,
|
||||
message: 'File not found',
|
||||
};
|
||||
}
|
||||
|
||||
let probes: Array<RemoteProbe | null>;
|
||||
try {
|
||||
// Both paths in one ssh round trip: the containment check below is only honest
|
||||
// when the workspace itself is canonicalized remotely too (a symlinked
|
||||
// `remotePath` is ordinary, and comparing a realpath'd file against a
|
||||
// non-canonical root would refuse every read in that case).
|
||||
probes = await remoteProbePaths(remote, [lexical.resolvedPath, scope.workingDir]);
|
||||
} catch (err) {
|
||||
const detail = err instanceof RemoteFileAccessError ? err.message : getErrorMessage(err);
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'unreachable',
|
||||
status: 502,
|
||||
errorCode: ApiErrorCode.OPERATION_FAILED,
|
||||
message: detail,
|
||||
};
|
||||
}
|
||||
|
||||
const [fileProbe, rootProbe] = probes;
|
||||
if (!fileProbe) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'not-found',
|
||||
status: 404,
|
||||
errorCode: ApiErrorCode.NOT_FOUND,
|
||||
message: 'File not found',
|
||||
};
|
||||
}
|
||||
|
||||
const root = rootProbe?.realPath ?? resolve(scope.workingDir);
|
||||
if (!isPathWithinRoot(root, fileProbe.realPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'not-found',
|
||||
status: 404,
|
||||
errorCode: ApiErrorCode.NOT_FOUND,
|
||||
message: 'File not found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
kind: 'remote',
|
||||
resolvedPath: fileProbe.realPath,
|
||||
relativePath: relative(root, fileProbe.realPath),
|
||||
remote,
|
||||
probe: fileProbe,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes of a resolved target, as a Range-aware source for `sendFileBody`.
|
||||
*
|
||||
* The remote source's `cleanup` is what keeps a client that aborts a download from
|
||||
* leaving an `ssh` process behind (see `sendRawStream`).
|
||||
*/
|
||||
function fileTargetSource(target: FileTarget) {
|
||||
if (target.kind === 'local') return localFileSource(target.resolvedPath);
|
||||
return (range?: { start: number; end: number }): FileBodySource => {
|
||||
const remote = remoteCreateReadStream(target.remote, target.resolvedPath, range);
|
||||
return { content: remote.stream, cleanup: () => remote.close() };
|
||||
};
|
||||
}
|
||||
|
||||
// Persisted sessions carry the private (externalPath-bearing) history under a
|
||||
// `__attachmentHistory` key so the list route can re-register external files.
|
||||
type StoredSessionWithPrivateAttachmentHistory = SessionState & {
|
||||
@@ -639,6 +855,20 @@ function sniffsBinary(buf: Buffer): boolean {
|
||||
* failures grep distinctly.
|
||||
*/
|
||||
function throwFileEditError(statusCode: number, code: ApiErrorCode, message: string): never {
|
||||
throwRouteError(statusCode, code, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an error the central route handler renders at `statusCode`.
|
||||
*
|
||||
* The one way to answer a NON-2xx status from a handler that otherwise RETURNS its
|
||||
* error envelope: a returned envelope is wrapped by the preSerialization hook in
|
||||
* production but arrives as a plain 200 in the `app.inject()` harness, so a status
|
||||
* asserted from a return value would be a test that cannot fail. `file-content`
|
||||
* predates that and keeps its returned envelopes for the errors it always had; every
|
||||
* NEW failure reason there (and everywhere in `file-raw`) goes through here.
|
||||
*/
|
||||
function throwRouteError(statusCode: number, code: ApiErrorCode, message: string): never {
|
||||
throw Object.assign(new Error(message), {
|
||||
statusCode,
|
||||
body: createErrorResponse(code, message),
|
||||
@@ -1217,11 +1447,27 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
||||
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||
if (!validated) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
|
||||
// For a remote (SSH) case the same boundary is resolved on the remote host (#415),
|
||||
// where the path actually lives.
|
||||
const resolution = await resolveFileTarget({ workingDir: session.workingDir, remote: session.remote }, filePath);
|
||||
if (!resolution.ok) {
|
||||
// An unreachable remote is thrown so the shared handler answers a real 502 (and
|
||||
// the test can assert it); a refusal keeps this route's historical returned
|
||||
// envelope, which its existing tests pin as 200 + success:false.
|
||||
if (resolution.reason === 'unreachable') {
|
||||
throwRouteError(resolution.status, resolution.errorCode, resolution.message);
|
||||
}
|
||||
return createErrorResponse(resolution.errorCode, resolution.message);
|
||||
}
|
||||
const target = resolution.target;
|
||||
const { resolvedPath, relativePath } = target;
|
||||
|
||||
// Remote WRITES are out of scope by design (docs/file-viewer-edit-plan.md §6: "do
|
||||
// not attempt an SFTP path"). Say so instead of returning the misleading 404 the
|
||||
// pre-#415 code produced, and never offer the editor: `editable` stays false below.
|
||||
if ((edit === '1' || edit === 'true') && target.kind === 'remote') {
|
||||
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Editing is not supported for files in a remote (SSH) case');
|
||||
}
|
||||
const { resolvedPath, relativePath } = validated;
|
||||
|
||||
// Read-for-edit: never truncated (a truncated buffer must never become an
|
||||
// edit buffer), tighter size cap, full editability gate, and the hash/eol
|
||||
@@ -1269,7 +1515,12 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
// Local: one stat. Remote: the resolution's probe already carries the size and
|
||||
// mtime, so the read path adds no second ssh round trip.
|
||||
const stat =
|
||||
target.kind === 'local'
|
||||
? await fs.stat(target.resolvedPath)
|
||||
: { size: target.probe.size, mtimeMs: target.probe.mtimeMs };
|
||||
|
||||
// Classify by extension. Known media types render with a dedicated player;
|
||||
// other known-binary types are flagged so the client offers a download
|
||||
@@ -1358,7 +1609,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// is actually binary would otherwise be dumped to the viewer as UTF-8
|
||||
// mojibake; a NUL byte in the first 8KB is a reliable binary signal that
|
||||
// (unlike a static extension list) catches arbitrary binary formats.
|
||||
const fileBuffer = await fs.readFile(resolvedPath);
|
||||
const fileBuffer =
|
||||
target.kind === 'local'
|
||||
? await fs.readFile(target.resolvedPath)
|
||||
: await remoteReadFile(target.remote, target.resolvedPath, MAX_TEXT_FILE_SIZE);
|
||||
const buf = Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(String(fileBuffer));
|
||||
const sniffLength = Math.min(buf.length, 8192);
|
||||
let looksBinary = false;
|
||||
@@ -1394,14 +1648,21 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// succeed. The UTF-8 round-trip compare is a cheap memcmp and mirrors
|
||||
// decodeEditableText; no hash here — the Edit action re-fetches with
|
||||
// edit=1, which is where the baseHash comes from.
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const editable =
|
||||
isEditableFileName(pathBasename(resolvedPath)) &&
|
||||
!isDeniedEditRelativePath(relativePath) &&
|
||||
!isSensitivePath(resolvedPath) &&
|
||||
!isBlockedAttachmentPath(resolvedPath, guard.blockedTrees) &&
|
||||
stat.size <= MAX_EDITABLE_BYTES &&
|
||||
Buffer.from(content, 'utf8').equals(buf);
|
||||
//
|
||||
// A remote case is false by construction: the advertisement is a promise the
|
||||
// PUT route cannot keep over ssh, and the viewer keys its Edit affordance off
|
||||
// this flag.
|
||||
let editable = false;
|
||||
if (target.kind === 'local') {
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
editable =
|
||||
isEditableFileName(pathBasename(target.resolvedPath)) &&
|
||||
!isDeniedEditRelativePath(relativePath) &&
|
||||
!isSensitivePath(target.resolvedPath) &&
|
||||
!isBlockedAttachmentPath(target.resolvedPath, guard.blockedTrees) &&
|
||||
stat.size <= MAX_EDITABLE_BYTES &&
|
||||
Buffer.from(content, 'utf8').equals(buf);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -1547,19 +1808,23 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
||||
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
const resolution = await resolveFileTarget({ workingDir: session.workingDir, remote: session.remote }, filePath);
|
||||
if (!resolution.ok) {
|
||||
reply.code(resolution.status).send(createErrorResponse(resolution.errorCode, resolution.message));
|
||||
return;
|
||||
}
|
||||
const target = resolution.target;
|
||||
if (target.kind === 'remote' && target.probe.kind !== 'file') {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is not a file'));
|
||||
return;
|
||||
}
|
||||
const { resolvedPath } = validated;
|
||||
|
||||
try {
|
||||
// Sanity bound only: the body below is streamed and Range-aware, so size
|
||||
// does not translate into resident memory. Configurable, 0 = unlimited.
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (exceedsDownloadLimit(stat.size)) {
|
||||
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
|
||||
const size = target.kind === 'local' ? (await fs.stat(target.resolvedPath)).size : target.probe.size;
|
||||
if (exceedsDownloadLimit(size)) {
|
||||
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(size)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1599,17 +1864,19 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
);
|
||||
reply.header('Content-Disposition', `attachment; filename="${basename}"`);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendFileBody(reply, resolvedPath, stat.size, req.headers.range);
|
||||
sendFileBody(reply, size, req.headers.range, fileTargetSource(target));
|
||||
return;
|
||||
}
|
||||
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
// Streamed, range-aware: this is the <video>/<audio> source the file
|
||||
// viewer points at, and a 200-only response makes the media unseekable.
|
||||
sendFileBody(reply, resolvedPath, stat.size, req.headers.range);
|
||||
sendFileBody(reply, size, req.headers.range, fileTargetSource(target));
|
||||
} catch (err) {
|
||||
// A failure of the remote read is an infrastructure answer, not a 500 with a
|
||||
// stack: the case points at a host this server could not reach.
|
||||
reply
|
||||
.code(500)
|
||||
.code(err instanceof RemoteFileAccessError ? 502 : 500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||
}
|
||||
});
|
||||
@@ -1777,45 +2044,63 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
||||
if (!scope) return;
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
const validated = validateSessionFilePath(workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
const resolution = await resolveFileTarget(scope, filePath);
|
||||
if (!resolution.ok) {
|
||||
reply.code(resolution.status).send(createErrorResponse(resolution.errorCode, resolution.message));
|
||||
return;
|
||||
}
|
||||
const { resolvedPath } = validated;
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
if (ext !== 'docx' && ext !== 'pptx') {
|
||||
// Everything that is not an office document IS the raw route (PDF, images,
|
||||
// text), which is now remote-aware too — so this redirect works for both kinds
|
||||
// of case with no extra branching here.
|
||||
reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await serveConvertedPreview(reply, resolvedPath, filePath, ext);
|
||||
if (resolution.target.kind === 'remote') {
|
||||
// Converting requires LibreOffice reading the bytes off THIS host's disk, so it
|
||||
// needs a local temp copy first — deliberately not part of the read-only remote
|
||||
// path (a remote preview must not spill remote bytes onto the server). Say what
|
||||
// to do instead of 404-ing like the pre-#415 code did.
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Office document preview is not available for files in a remote (SSH) case'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await serveConvertedPreview(reply, resolution.target.resolvedPath, filePath, ext);
|
||||
});
|
||||
|
||||
// Serve a first-page thumbnail for a workspace-relative path.
|
||||
app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
||||
if (!scope) return;
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
const validated = validateSessionFilePath(workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
const resolution = await resolveFileTarget(scope, filePath);
|
||||
if (!resolution.ok) {
|
||||
reply.code(resolution.status).send(createErrorResponse(resolution.errorCode, resolution.message));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1827,7 +2112,20 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
return;
|
||||
}
|
||||
|
||||
await serveThumbnail(reply, validated.resolvedPath, ext);
|
||||
if (resolution.target.kind === 'remote') {
|
||||
// Same reason as the office preview above: rendering needs the bytes locally.
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Thumbnails are not available for files in a remote (SSH) case'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await serveThumbnail(reply, resolution.target.resolvedPath, ext);
|
||||
});
|
||||
|
||||
// Stream file content via tail -f (SSE endpoint)
|
||||
@@ -1971,7 +2269,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||||
reply.header('Content-Disposition', buildContentDisposition('attachment', filename));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendFileBody(reply, resolvedPath, stat.size, req.headers.range);
|
||||
sendFileBody(reply, stat.size, req.headers.range, localFileSource(resolvedPath));
|
||||
return;
|
||||
} catch (err) {
|
||||
reply
|
||||
|
||||
Reference in New Issue
Block a user