mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
Both picker endpoints are a second file-serving surface, and they inherited neither the attachment guard's confinement nor its ownership scoping. Two separate holes: 1. `sessionId` contributes that session's workingDir as a browse root, but it was resolved straight off ctx.sessions/ctx.store with no owner check, unlike the nine other session-scoped handlers in this file. A non-admin could pin ANOTHER user's working directory as a root just by passing their session id, then list and preview underneath it. Now runs canAccessOwned and reports 404, which also avoids confirming that a session id exists. 2. `Home` and `CASES_DIR` were unconditional roots for every caller. Per-user spaces live at <USER_SPACES_DIR>/<username>, which is INSIDE homedir(), so the Home root alone exposed every other user's workspace. A multi-user non-admin now gets only their own userSpacePath plus anything explicitly listed in CODEMAN_FILE_PICKER_ROOTS. /mnt/d is dropped as well: a broad host mount should be an explicit operator decision in a multi-user deployment, and operators who want it can name it in that env var. Admins and single-user mode keep the host-wide roots, so behavior is unchanged unless CODEMAN_MULTIUSER is on (opt-in, off by default). All three discriminating tests were verified to fail against the previous code: browse and preview both returned 200 instead of 404, and the roots came back as [Home, Codeman Cases, ...] instead of [My Space]. Full suite green, 3784 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1469 lines
51 KiB
TypeScript
1469 lines
51 KiB
TypeScript
/**
|
|
* @fileoverview File browser and streaming routes.
|
|
* Provides directory listing, file content preview, raw file serving, and tail streaming.
|
|
*/
|
|
|
|
import { FastifyInstance, type FastifyReply } from 'fastify';
|
|
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
|
import fs from 'node:fs/promises';
|
|
import { homedir } from 'node:os';
|
|
import type {
|
|
ApiResponse,
|
|
FilesystemBrowseData,
|
|
FilesystemBrowseEntry,
|
|
FilesystemBrowseRoot,
|
|
FilesystemPreviewKind,
|
|
} from '../../types.js';
|
|
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
|
import { fileStreamManager } from '../../file-stream-manager.js';
|
|
import {
|
|
AttachmentRegistrationError,
|
|
attachmentRecordToEvent,
|
|
attachmentRegistry,
|
|
buildFileThumbnailRoute,
|
|
isSupportedAttachmentExtension,
|
|
registerExternalAttachment,
|
|
type AttachmentRecord,
|
|
} from '../../attachment-registry.js';
|
|
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
|
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
|
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
|
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
|
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
|
|
import {
|
|
CASES_DIR,
|
|
canAccessOwned,
|
|
findSessionOrFail,
|
|
getAuthUser,
|
|
parseBody,
|
|
validateSessionFilePath,
|
|
} from '../route-helpers.js';
|
|
import type { FastifyRequest } from 'fastify';
|
|
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
|
import { isSensitivePath } from '../sensitive-path.js';
|
|
import { SseEvent } from '../sse-events.js';
|
|
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
|
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema } from '../schemas.js';
|
|
|
|
const MIME_TYPES: Record<string, string> = {
|
|
png: 'image/png',
|
|
jpg: 'image/jpeg',
|
|
jpeg: 'image/jpeg',
|
|
gif: 'image/gif',
|
|
webp: 'image/webp',
|
|
ico: 'image/x-icon',
|
|
bmp: 'image/bmp',
|
|
pdf: 'application/pdf',
|
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
json: 'application/json',
|
|
md: 'text/markdown',
|
|
txt: 'text/plain',
|
|
};
|
|
|
|
function buildContentDisposition(disposition: 'inline' | 'attachment', fileName: string): string {
|
|
const cleaned = fileName.replace(/["\\\r\n]/g, '_');
|
|
const fallback = cleaned.replace(/[^\x20-\x7e]/g, '_') || 'file';
|
|
const encoded = encodeURIComponent(cleaned).replace(
|
|
/['()*]/g,
|
|
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
|
);
|
|
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
|
}
|
|
|
|
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
|
const headers = reply.getHeaders();
|
|
reply.hijack();
|
|
|
|
for (const [name, value] of Object.entries(headers)) {
|
|
if (value !== undefined) {
|
|
reply.raw.setHeader(name, value);
|
|
}
|
|
}
|
|
|
|
content.on('error', (err) => {
|
|
if (reply.raw.headersSent) {
|
|
reply.raw.destroy(err);
|
|
return;
|
|
}
|
|
|
|
reply.raw.statusCode = 500;
|
|
reply.raw.end('Failed to read file');
|
|
});
|
|
content.pipe(reply.raw);
|
|
}
|
|
|
|
async function serveRawFile(
|
|
reply: FastifyReply,
|
|
resolvedPath: string,
|
|
fileName: string,
|
|
extension: string,
|
|
download?: boolean
|
|
): Promise<void> {
|
|
const stat = await fs.stat(resolvedPath);
|
|
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
|
|
if (stat.size > MAX_RAW_ATTACHMENT_SIZE) {
|
|
reply
|
|
.code(413)
|
|
.send(
|
|
createErrorResponse(
|
|
ApiErrorCode.INVALID_INPUT,
|
|
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)`
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
const content = createReadStream(resolvedPath);
|
|
if (download || extension === 'svg') {
|
|
reply.header(
|
|
'Content-Type',
|
|
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
|
);
|
|
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
|
reply.header('Content-Length', stat.size);
|
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
sendRawStream(reply, content);
|
|
return;
|
|
}
|
|
|
|
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
|
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
|
reply.header('Content-Length', stat.size);
|
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
sendRawStream(reply, content);
|
|
}
|
|
|
|
function getAttachmentOr404(
|
|
reply: FastifyReply,
|
|
sessionId: string,
|
|
attachmentId: string
|
|
): AttachmentRecord | undefined {
|
|
const record = attachmentRegistry.get(sessionId, attachmentId);
|
|
if (!record) {
|
|
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Attachment not found'));
|
|
return undefined;
|
|
}
|
|
return record;
|
|
}
|
|
|
|
/**
|
|
* COD-53 defense-in-depth: refuse to stream a record whose underlying path is
|
|
* blocked by the active attachment-guard policy, even though registration
|
|
* already blocks them. Guards against records that predate the guard or were
|
|
* crafted to point at a sensitive file. Resolves symlinks before the check so a
|
|
* record pointing at a symlink that now resolves to a sensitive target is also
|
|
* caught; if the path can't be resolved (deleted/unreadable) the check still
|
|
* runs on the stored path. When workspace confinement is enabled it additionally
|
|
* rejects any record outside the session workspace. Returns true (and sends a
|
|
* 403) when blocked.
|
|
*/
|
|
async function resolveServableAttachmentPath(
|
|
reply: FastifyReply,
|
|
record: AttachmentRecord,
|
|
sessionWorkingDir?: string
|
|
): Promise<string | null> {
|
|
let pathToCheck = record.filePath;
|
|
let resolved = false;
|
|
try {
|
|
pathToCheck = realpathSync(record.filePath);
|
|
resolved = true;
|
|
} catch {
|
|
// Fall back to the stored (already realpath-resolved at registration) path.
|
|
}
|
|
|
|
const guard = await loadAttachmentGuardConfig();
|
|
|
|
const blocked =
|
|
isBlockedAttachmentPath(pathToCheck, guard.blockedTrees) ||
|
|
isBlockedAttachmentPath(record.filePath, guard.blockedTrees) ||
|
|
(guard.confineToWorkspace && (!sessionWorkingDir || !validateSessionFilePath(sessionWorkingDir, pathToCheck)));
|
|
|
|
if (blocked) {
|
|
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
|
|
return null;
|
|
}
|
|
// Serve the freshly-resolved path, not the stored one: if a path component
|
|
// became a symlink after registration, the guard checked the resolved target
|
|
// but streaming record.filePath would follow the symlink to a swapped file.
|
|
return resolved ? pathToCheck : record.filePath;
|
|
}
|
|
|
|
/**
|
|
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
|
|
* stream it inline. PDF/PNG and text formats don't need conversion — callers
|
|
* redirect those to the raw route instead.
|
|
*/
|
|
async function serveConvertedPreview(
|
|
reply: FastifyReply,
|
|
resolvedPath: string,
|
|
fileName: string,
|
|
extension: string
|
|
): Promise<void> {
|
|
if (extension !== 'docx' && extension !== 'pptx') {
|
|
reply
|
|
.code(400)
|
|
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Preview is not supported for this file type'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const previewPath = await getOfficePreviewPdfPath(resolvedPath, extension);
|
|
if (!previewPath) {
|
|
reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Document preview conversion failed'));
|
|
return;
|
|
}
|
|
|
|
const content = await fs.readFile(previewPath);
|
|
reply.header('Content-Type', 'application/pdf');
|
|
reply.header(
|
|
'Content-Disposition',
|
|
buildContentDisposition('inline', getPreviewPdfDownloadName(fileName, extension))
|
|
);
|
|
reply.header('Cache-Control', 'no-cache');
|
|
reply.header('Content-Length', content.length);
|
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
reply.send(content);
|
|
} catch (err) {
|
|
reply
|
|
.code(500)
|
|
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to generate preview: ${getErrorMessage(err)}`));
|
|
}
|
|
}
|
|
|
|
/** Generate and stream a first-page thumbnail (PNG) for a supported attachment. */
|
|
async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extension: string): Promise<void> {
|
|
const thumbnail = await generateFirstPageThumbnail(resolvedPath, extension);
|
|
if (!thumbnail) {
|
|
reply.code(204).send();
|
|
return;
|
|
}
|
|
|
|
reply.header('Content-Type', thumbnail.contentType);
|
|
reply.header('Cache-Control', 'no-cache');
|
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
reply.send(thumbnail.content);
|
|
}
|
|
|
|
/**
|
|
* Resolve a session's working dir from the live session, falling back to the
|
|
* persisted record so preview/thumbnail requests keep working for a session
|
|
* that has since detached. Sends a 404 and returns undefined when unknown.
|
|
*/
|
|
function getKnownSessionWorkingDir(
|
|
ctx: SessionPort & ConfigPort,
|
|
sessionId: string,
|
|
reply: FastifyReply,
|
|
req: FastifyRequest
|
|
): string | 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;
|
|
|
|
const stored = ctx.store.getSession(sessionId);
|
|
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) return stored.workingDir;
|
|
|
|
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
|
|
return undefined;
|
|
}
|
|
|
|
// Persisted sessions carry the private (externalPath-bearing) history under a
|
|
// `__attachmentHistory` key so the list route can re-register external files.
|
|
type StoredSessionWithPrivateAttachmentHistory = SessionState & {
|
|
__attachmentHistory?: SessionAttachmentHistoryItem[];
|
|
};
|
|
|
|
type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPath'> & {
|
|
missing: boolean;
|
|
rawUrl?: string;
|
|
url?: string;
|
|
previewUrl?: string;
|
|
thumbnailUrl?: string;
|
|
downloadUrl?: string;
|
|
attachmentId?: string;
|
|
};
|
|
|
|
const FILESYSTEM_PICKER_ENTRY_LIMIT = 500;
|
|
const FILESYSTEM_TEXT_PREVIEW_LIMIT = 2 * 1024 * 1024;
|
|
const FILESYSTEM_BINARY_PREVIEW_LIMIT = 50 * 1024 * 1024;
|
|
const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
|
|
const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']);
|
|
const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']);
|
|
|
|
function isPathWithinRoot(root: string, candidate: string): boolean {
|
|
const rel = relative(root, candidate);
|
|
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
|
}
|
|
|
|
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
|
|
return roots
|
|
.filter((root) => isPathWithinRoot(root.path, candidate))
|
|
.sort((a, b) => b.path.length - a.path.length)[0];
|
|
}
|
|
|
|
function containsHiddenPickerSegment(root: string, candidate: string): boolean {
|
|
const rel = relative(root, candidate);
|
|
return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.'));
|
|
}
|
|
|
|
function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined {
|
|
const extension = extname(fileName).slice(1).toLowerCase();
|
|
if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image';
|
|
if (FILESYSTEM_TEXT_PREVIEW_EXTENSIONS.has(extension)) return 'text';
|
|
if (FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS.has(extension)) return 'document';
|
|
return undefined;
|
|
}
|
|
|
|
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
|
|
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
|
// The shared sensitive-path matcher describes file locations such as
|
|
// ~/.ssh/<key>. Probe a child path as well so the directory itself cannot be
|
|
// opened and used to enumerate those filenames.
|
|
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
|
|
}
|
|
|
|
function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> {
|
|
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
|
|
if (!extraRoots) return [];
|
|
return extraRoots
|
|
.split(',')
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
.map((path, index) => ({ label: `Configured ${index + 1}`, path }));
|
|
}
|
|
|
|
/**
|
|
* Browse roots for the requesting identity.
|
|
*
|
|
* Single-user mode (and multi-user admins) get the host-wide set. ⚠️ A regular
|
|
* multi-user user must NOT: per-user spaces live at `<USER_SPACES_DIR>/<name>`,
|
|
* which is *inside* `homedir()`, so handing out a `Home` root would let any
|
|
* authenticated user browse and preview every other user's workspace. The
|
|
* shared `CASES_DIR` leaks the same way, and `/mnt/d` is a broad host mount
|
|
* that a multi-user deployment should not expose by default. Operators who
|
|
* genuinely want a shared area can still name it in `CODEMAN_FILE_PICKER_ROOTS`,
|
|
* which stays an explicit opt-in in both modes.
|
|
*/
|
|
function configuredFilesystemPickerRoots(req: FastifyRequest): Array<{ label: string; path: string }> {
|
|
const user = getAuthUser(req);
|
|
if (isMultiUserMode() && user.role !== 'admin') {
|
|
return [{ label: 'My Space', path: userSpacePath(user.username) }, ...extraConfiguredPickerRoots()];
|
|
}
|
|
return [
|
|
{ label: 'Home', path: homedir() },
|
|
{ label: 'Codeman Cases', path: CASES_DIR },
|
|
{ label: 'WSL D:', path: '/mnt/d' },
|
|
...extraConfiguredPickerRoots(),
|
|
];
|
|
}
|
|
|
|
async function resolveFilesystemPickerRoots(
|
|
ctx: SessionPort & ConfigPort,
|
|
req: FastifyRequest,
|
|
sessionId?: string
|
|
): Promise<FilesystemBrowseRoot[]> {
|
|
const candidates = configuredFilesystemPickerRoots(req);
|
|
if (sessionId) {
|
|
const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId);
|
|
// ⚠️ Ownership must be checked here, exactly as `findSessionOrFail` does for
|
|
// the other session-scoped handlers in this file. Without it a multi-user
|
|
// caller could pin ANOTHER user's `workingDir` as a browse root just by
|
|
// passing their sessionId. Report not-found rather than forbidden so the
|
|
// endpoint does not confirm that a session id exists.
|
|
if (!session || !canAccessOwned(getAuthUser(req), (session as { owner?: string }).owner)) {
|
|
throw Object.assign(new Error(`Session ${sessionId} not found`), {
|
|
statusCode: 404,
|
|
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
|
});
|
|
}
|
|
candidates.unshift({ label: 'Current Folder', path: session.workingDir });
|
|
}
|
|
|
|
const guard = await loadAttachmentGuardConfig();
|
|
const roots: FilesystemBrowseRoot[] = [];
|
|
const seen = new Set<string>();
|
|
for (const candidate of candidates) {
|
|
if (!isAbsolute(candidate.path)) continue;
|
|
try {
|
|
const resolved = realpathSync(candidate.path);
|
|
if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue;
|
|
const stat = await fs.stat(resolved);
|
|
if (!stat.isDirectory()) continue;
|
|
seen.add(resolved);
|
|
roots.push({ label: candidate.label, path: resolved });
|
|
} catch {
|
|
// Optional roots (for example /mnt/d on non-WSL hosts) are omitted.
|
|
}
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
type ResolvedFilesystemPickerPath = {
|
|
candidatePath: string;
|
|
resolvedPath: string;
|
|
roots: FilesystemBrowseRoot[];
|
|
matchingRoot: FilesystemBrowseRoot;
|
|
blockedTrees: readonly string[];
|
|
};
|
|
|
|
function throwFilesystemPickerError(statusCode: number, code: ApiErrorCode, message: string): never {
|
|
throw Object.assign(new Error(message), {
|
|
statusCode,
|
|
body: createErrorResponse(code, message),
|
|
});
|
|
}
|
|
|
|
async function resolveFilesystemPickerPath(
|
|
ctx: SessionPort & ConfigPort,
|
|
req: FastifyRequest,
|
|
requestedPath: string | undefined,
|
|
sessionId?: string
|
|
): Promise<ResolvedFilesystemPickerPath> {
|
|
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
|
|
if (roots.length === 0) {
|
|
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
|
|
}
|
|
|
|
const fallbackRoot =
|
|
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
|
|
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
|
|
|
|
let resolvedPath: string;
|
|
try {
|
|
resolvedPath = realpathSync(candidatePath);
|
|
} catch {
|
|
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `Path not found: ${candidatePath}`);
|
|
}
|
|
|
|
const matchingRoot = findMatchingPickerRoot(roots, resolvedPath);
|
|
if (!matchingRoot) {
|
|
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
|
|
}
|
|
if (containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
|
|
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker');
|
|
}
|
|
|
|
const guard = await loadAttachmentGuardConfig();
|
|
return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees };
|
|
}
|
|
|
|
function appendDownloadFlag(url: string): string {
|
|
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
|
}
|
|
|
|
function getSessionAttachmentHistory(
|
|
ctx: SessionPort & ConfigPort,
|
|
sessionId: string,
|
|
req: FastifyRequest
|
|
): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined {
|
|
const user = getAuthUser(req);
|
|
const liveSession = ctx.sessions.get(sessionId);
|
|
if (liveSession) {
|
|
if (!canAccessOwned(user, liveSession.owner)) return undefined;
|
|
return {
|
|
workingDir: liveSession.workingDir,
|
|
history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [],
|
|
};
|
|
}
|
|
|
|
const stored = ctx.store.getSession(sessionId) as StoredSessionWithPrivateAttachmentHistory | undefined;
|
|
if (!stored || !canAccessOwned(user, (stored as { owner?: string }).owner)) return undefined;
|
|
|
|
return {
|
|
workingDir: stored.workingDir,
|
|
history: stored.__attachmentHistory ?? stored.attachmentHistory ?? [],
|
|
};
|
|
}
|
|
|
|
// History item for a file detected inside the workspace: re-stat for live
|
|
// size/mtime and resolve preview/thumbnail/raw routes off the relative path.
|
|
async function buildDetectedAttachmentRouteItem(
|
|
sessionId: string,
|
|
workingDir: string,
|
|
item: SessionAttachmentHistoryItem
|
|
): Promise<AttachmentHistoryRouteItem> {
|
|
const safe = sanitizeAttachmentHistoryItem(item);
|
|
if (!item.relativePath) {
|
|
return { ...safe, missing: true };
|
|
}
|
|
|
|
const validated = validateSessionFilePath(workingDir, item.relativePath);
|
|
if (!validated) {
|
|
return { ...safe, missing: true };
|
|
}
|
|
|
|
let size = item.size;
|
|
let mtimeMs = item.mtimeMs;
|
|
try {
|
|
const stat = await fs.stat(validated.resolvedPath);
|
|
size = stat.size;
|
|
mtimeMs = stat.mtimeMs ?? mtimeMs;
|
|
} catch {
|
|
return { ...safe, missing: true };
|
|
}
|
|
|
|
const encodedPath = encodeURIComponent(item.relativePath);
|
|
const rawUrl = `/api/sessions/${sessionId}/file-raw?path=${encodedPath}`;
|
|
const previewUrl =
|
|
item.extension === 'docx' || item.extension === 'pptx'
|
|
? `/api/sessions/${sessionId}/file-preview?path=${encodedPath}`
|
|
: rawUrl;
|
|
const thumbnailUrl = isSupportedAttachmentExtension(item.extension)
|
|
? buildFileThumbnailRoute(sessionId, item.relativePath)
|
|
: undefined;
|
|
|
|
return {
|
|
...safe,
|
|
size,
|
|
mtimeMs,
|
|
missing: false,
|
|
rawUrl,
|
|
url: rawUrl,
|
|
previewUrl,
|
|
thumbnailUrl,
|
|
downloadUrl: appendDownloadFlag(rawUrl),
|
|
};
|
|
}
|
|
|
|
// History item for an explicitly published external file: re-register it to mint
|
|
// a fresh id + by-id routes (the guard runs again), or mark it missing.
|
|
async function buildExternalAttachmentRouteItem(
|
|
sessionId: string,
|
|
item: SessionAttachmentHistoryItem,
|
|
sessionWorkingDir?: string
|
|
): Promise<AttachmentHistoryRouteItem> {
|
|
const safe = sanitizeAttachmentHistoryItem(item);
|
|
if (!item.externalPath) {
|
|
return { ...safe, missing: true };
|
|
}
|
|
|
|
try {
|
|
const event = await registerExternalAttachment(sessionId, item.externalPath, { sessionWorkingDir });
|
|
return {
|
|
...safe,
|
|
fileName: event.fileName,
|
|
extension: event.extension,
|
|
attachmentType: event.attachmentType,
|
|
size: event.size,
|
|
missing: false,
|
|
attachmentId: event.attachmentId,
|
|
rawUrl: event.rawUrl,
|
|
url: event.rawUrl,
|
|
previewUrl: event.previewUrl,
|
|
thumbnailUrl: event.thumbnailUrl,
|
|
downloadUrl: appendDownloadFlag(event.rawUrl),
|
|
};
|
|
} catch (err) {
|
|
if (err instanceof AttachmentRegistrationError) {
|
|
return { ...safe, missing: true };
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
|
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
|
|
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
|
|
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
|
|
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
|
|
ctx,
|
|
req,
|
|
requestedPath,
|
|
sessionId
|
|
);
|
|
|
|
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
|
|
reply.code(403);
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked');
|
|
}
|
|
|
|
try {
|
|
const stat = await fs.stat(resolvedPath);
|
|
if (!stat.isDirectory()) {
|
|
reply.code(400);
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The browse path must be a directory');
|
|
}
|
|
} catch {
|
|
reply.code(404);
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
|
|
}
|
|
|
|
let dirEntries;
|
|
try {
|
|
dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
|
} catch {
|
|
reply.code(403);
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be read');
|
|
}
|
|
|
|
dirEntries.sort((a, b) => {
|
|
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
const entries: FilesystemBrowseEntry[] = [];
|
|
let truncated = false;
|
|
for (const entry of dirEntries) {
|
|
if (entry.name.startsWith('.')) continue;
|
|
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
|
|
const visiblePath = join(candidatePath, entry.name);
|
|
let targetPath: string;
|
|
try {
|
|
targetPath = realpathSync(visiblePath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const targetRoot = findMatchingPickerRoot(roots, targetPath);
|
|
if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
|
|
|
|
let type: FilesystemBrowseEntry['type'];
|
|
let size: number | undefined;
|
|
const symlink = entry.isSymbolicLink();
|
|
if (entry.isDirectory()) {
|
|
type = 'directory';
|
|
} else if (entry.isFile()) {
|
|
type = 'file';
|
|
} else if (symlink) {
|
|
try {
|
|
const targetStat = await fs.stat(targetPath);
|
|
type = targetStat.isDirectory() ? 'directory' : 'file';
|
|
if (type === 'file') size = targetStat.size;
|
|
} catch {
|
|
continue;
|
|
}
|
|
} else {
|
|
continue;
|
|
}
|
|
|
|
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
|
|
if (type === 'file' && size === undefined) {
|
|
try {
|
|
size = (await fs.stat(targetPath)).size;
|
|
} catch {
|
|
// The path is still selectable even when a size lookup races a change.
|
|
}
|
|
}
|
|
entries.push({
|
|
name: entry.name,
|
|
path: visiblePath,
|
|
type,
|
|
size,
|
|
symlink: symlink || undefined,
|
|
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
|
|
});
|
|
}
|
|
|
|
const parentCandidate = resolve(candidatePath, '..');
|
|
let parent: string | null = null;
|
|
if (candidatePath !== matchingRoot.path) {
|
|
try {
|
|
const resolvedParent = realpathSync(parentCandidate);
|
|
if (isPathWithinRoot(matchingRoot.path, resolvedParent)) parent = parentCandidate;
|
|
} catch {
|
|
// A concurrently removed parent simply disables upward navigation.
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
path: candidatePath,
|
|
parent,
|
|
root: matchingRoot.path,
|
|
roots,
|
|
entries,
|
|
truncated,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Inline preview for files selected through the root-confined filesystem picker.
|
|
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
|
|
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
|
|
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
|
|
ctx,
|
|
req,
|
|
requestedPath,
|
|
sessionId
|
|
);
|
|
if (isBlockedPickerPath(resolvedPath, blockedTrees)) {
|
|
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked');
|
|
}
|
|
|
|
let stat;
|
|
try {
|
|
stat = await fs.stat(resolvedPath);
|
|
} catch {
|
|
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `File not found: ${candidatePath}`);
|
|
}
|
|
if (!stat.isFile()) {
|
|
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'The preview path must be a file');
|
|
}
|
|
|
|
const fileName = pathBasename(candidatePath);
|
|
const extension = extname(fileName).slice(1).toLowerCase();
|
|
const previewKind = getFilesystemPreviewKind(fileName);
|
|
if (!previewKind) {
|
|
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'This file type cannot be previewed');
|
|
}
|
|
const sizeLimit = previewKind === 'text' ? FILESYSTEM_TEXT_PREVIEW_LIMIT : FILESYSTEM_BINARY_PREVIEW_LIMIT;
|
|
if (stat.size > sizeLimit) {
|
|
throwFilesystemPickerError(
|
|
413,
|
|
ApiErrorCode.INVALID_INPUT,
|
|
`File too large to preview (${Math.ceil(stat.size / 1024 / 1024)}MB limit: ${sizeLimit / 1024 / 1024}MB)`
|
|
);
|
|
}
|
|
|
|
reply.header('Cache-Control', 'no-cache');
|
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
if (previewKind === 'text') {
|
|
const content = await fs.readFile(resolvedPath, 'utf8');
|
|
reply.type('text/plain; charset=utf-8').send(content);
|
|
return;
|
|
}
|
|
if (extension === 'docx' || extension === 'pptx') {
|
|
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
|
|
return;
|
|
}
|
|
await serveRawFile(reply, resolvedPath, fileName, extension);
|
|
});
|
|
|
|
// File tree listing
|
|
app.get('/api/sessions/:id/files', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const { depth, showHidden } = req.query as { depth?: string; showHidden?: string };
|
|
const session = findSessionOrFail(ctx, id, req);
|
|
|
|
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
|
const includeHidden = showHidden === 'true';
|
|
const workingDir = session.workingDir;
|
|
|
|
// Default excludes - large/generated directories
|
|
const excludeDirs = new Set([
|
|
'.git',
|
|
'node_modules',
|
|
'dist',
|
|
'build',
|
|
'__pycache__',
|
|
'.cache',
|
|
'.next',
|
|
'.nuxt',
|
|
'coverage',
|
|
'.venv',
|
|
'venv',
|
|
'.tox',
|
|
'target',
|
|
'vendor',
|
|
]);
|
|
|
|
interface FileTreeNode {
|
|
name: string;
|
|
path: string;
|
|
type: 'file' | 'directory';
|
|
size?: number;
|
|
extension?: string;
|
|
children?: FileTreeNode[];
|
|
}
|
|
|
|
let totalFiles = 0;
|
|
let totalDirectories = 0;
|
|
let truncated = false;
|
|
const maxFiles = 5000;
|
|
|
|
const scanDirectory = async (dirPath: string, currentDepth: number): Promise<FileTreeNode[]> => {
|
|
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
|
|
truncated = true;
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
const nodes: FileTreeNode[] = [];
|
|
|
|
// Sort: directories first, then alphabetically
|
|
entries.sort((a, b) => {
|
|
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
for (const entry of entries) {
|
|
if (totalFiles + totalDirectories > maxFiles) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
|
|
// Skip hidden files unless requested
|
|
if (!includeHidden && entry.name.startsWith('.')) continue;
|
|
|
|
// Skip excluded directories
|
|
if (entry.isDirectory() && excludeDirs.has(entry.name)) continue;
|
|
|
|
const fullPath = join(dirPath, entry.name);
|
|
const relativePath = fullPath.slice(workingDir.length + 1);
|
|
|
|
if (entry.isDirectory()) {
|
|
totalDirectories++;
|
|
const children = await scanDirectory(fullPath, currentDepth + 1);
|
|
nodes.push({
|
|
name: entry.name,
|
|
path: relativePath,
|
|
type: 'directory',
|
|
children,
|
|
});
|
|
} else {
|
|
totalFiles++;
|
|
const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined;
|
|
let size: number | undefined;
|
|
try {
|
|
const stat = await fs.stat(fullPath);
|
|
size = stat.size;
|
|
} catch {
|
|
// Skip if can't stat
|
|
}
|
|
nodes.push({
|
|
name: entry.name,
|
|
path: relativePath,
|
|
type: 'file',
|
|
size,
|
|
extension: ext,
|
|
});
|
|
}
|
|
}
|
|
|
|
return nodes;
|
|
} catch {
|
|
// Can't read directory (permission denied, etc.)
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const tree = await scanDirectory(workingDir, 1);
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
root: workingDir,
|
|
tree,
|
|
totalFiles,
|
|
totalDirectories,
|
|
truncated,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Get file content for preview (File Browser)
|
|
app.get('/api/sessions/:id/file-content', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string };
|
|
const session = findSessionOrFail(ctx, id, req);
|
|
|
|
if (!filePath) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
const { resolvedPath } = validated;
|
|
|
|
try {
|
|
const stat = await fs.stat(resolvedPath);
|
|
|
|
// Classify by extension. Known media types render with a dedicated player;
|
|
// other known-binary types are flagged so the client offers a download
|
|
// affordance instead of trying to decode the bytes as text. Matches the
|
|
// breadth of formats the attachments viewer renders (image/audio/video/pdf)
|
|
// so the file viewer can open the same files.
|
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
|
|
const videoExts = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
|
|
const audioExts = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
|
|
const otherBinaryExts = new Set([
|
|
'pdf',
|
|
'zip',
|
|
'tar',
|
|
'gz',
|
|
'bz2',
|
|
'xz',
|
|
'7z',
|
|
'rar',
|
|
'exe',
|
|
'dll',
|
|
'so',
|
|
'dylib',
|
|
'bin',
|
|
'wasm',
|
|
'class',
|
|
'o',
|
|
'a',
|
|
'woff',
|
|
'woff2',
|
|
'ttf',
|
|
'eot',
|
|
'otf',
|
|
'xlsx',
|
|
'xls',
|
|
'doc',
|
|
'docx',
|
|
'ppt',
|
|
'pptx',
|
|
'odt',
|
|
'ods',
|
|
'odp',
|
|
'avi',
|
|
'mkv',
|
|
'wmv',
|
|
'flv',
|
|
]);
|
|
|
|
const mediaType = imageExts.has(ext)
|
|
? 'image'
|
|
: videoExts.has(ext)
|
|
? 'video'
|
|
: audioExts.has(ext)
|
|
? 'audio'
|
|
: null;
|
|
|
|
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
|
|
|
|
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
|
|
// Return metadata for media/binary files (no text body)
|
|
return {
|
|
success: true,
|
|
data: {
|
|
path: filePath,
|
|
size: stat.size,
|
|
type: mediaType ?? 'binary',
|
|
extension: ext,
|
|
url: fileRawUrl,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Validate file size before reading (DoS protection - prevent memory exhaustion)
|
|
const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
if (stat.size > MAX_TEXT_FILE_SIZE) {
|
|
return createErrorResponse(
|
|
ApiErrorCode.INVALID_INPUT,
|
|
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit)`
|
|
);
|
|
}
|
|
|
|
// Read as raw bytes so we can sniff for binary content before decoding. An
|
|
// unrecognized extension (none at all, or a format not listed above) that
|
|
// 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 buf = Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(String(fileBuffer));
|
|
const sniffLength = Math.min(buf.length, 8192);
|
|
let looksBinary = false;
|
|
for (let i = 0; i < sniffLength; i++) {
|
|
if (buf[i] === 0) {
|
|
looksBinary = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (looksBinary) {
|
|
return {
|
|
success: true,
|
|
data: {
|
|
path: filePath,
|
|
size: stat.size,
|
|
type: 'binary',
|
|
extension: ext,
|
|
url: fileRawUrl,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Read text file with line limit (bounded to prevent DoS)
|
|
const MAX_LINES_LIMIT = 10000;
|
|
const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT);
|
|
const content = buf.toString('utf-8');
|
|
const allLines = content.split('\n');
|
|
const truncatedContent = allLines.length > maxLines;
|
|
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
path: filePath,
|
|
content: displayContent,
|
|
size: stat.size,
|
|
totalLines: allLines.length,
|
|
truncated: truncatedContent,
|
|
extension: ext,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`);
|
|
}
|
|
});
|
|
|
|
// Serve raw file content (for images/binary files)
|
|
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
const { path: filePath, download } = req.query as { path?: string; download?: string };
|
|
const session = findSessionOrFail(ctx, id, req);
|
|
|
|
if (!filePath) {
|
|
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
|
return;
|
|
}
|
|
|
|
// 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'));
|
|
return;
|
|
}
|
|
const { resolvedPath } = validated;
|
|
|
|
try {
|
|
// Validate file size before reading (DoS protection - prevent memory exhaustion)
|
|
const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files
|
|
const stat = await fs.stat(resolvedPath);
|
|
if (stat.size > MAX_RAW_FILE_SIZE) {
|
|
reply
|
|
.code(400)
|
|
.send(
|
|
createErrorResponse(
|
|
ApiErrorCode.INVALID_INPUT,
|
|
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_FILE_SIZE / 1024 / 1024}MB limit)`
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
const mimeTypes: Record<string, string> = {
|
|
png: 'image/png',
|
|
jpg: 'image/jpeg',
|
|
jpeg: 'image/jpeg',
|
|
gif: 'image/gif',
|
|
webp: 'image/webp',
|
|
ico: 'image/x-icon',
|
|
bmp: 'image/bmp',
|
|
mp4: 'video/mp4',
|
|
webm: 'video/webm',
|
|
mov: 'video/quicktime',
|
|
m4v: 'video/mp4',
|
|
ogv: 'video/ogg',
|
|
mp3: 'audio/mpeg',
|
|
wav: 'audio/wav',
|
|
ogg: 'audio/ogg',
|
|
oga: 'audio/ogg',
|
|
opus: 'audio/ogg',
|
|
m4a: 'audio/mp4',
|
|
aac: 'audio/aac',
|
|
flac: 'audio/flac',
|
|
pdf: 'application/pdf',
|
|
json: 'application/json',
|
|
};
|
|
|
|
const content = await fs.readFile(resolvedPath);
|
|
const rawBasename = filePath!.split('/').pop() || 'download';
|
|
// Sanitize filename for Content-Disposition header (prevent header injection)
|
|
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
|
if (download === 'true' || ext === 'svg') {
|
|
reply.raw.writeHead(200, {
|
|
'Content-Type': ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream',
|
|
'Content-Disposition': `attachment; filename="${basename}"`,
|
|
'Content-Length': content.length,
|
|
'X-Content-Type-Options': 'nosniff',
|
|
});
|
|
reply.raw.end(content);
|
|
return;
|
|
}
|
|
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
reply.send(content);
|
|
} catch (err) {
|
|
reply
|
|
.code(500)
|
|
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
|
}
|
|
});
|
|
|
|
// ===== Live external attachments =====
|
|
// Register an explicit, live external file (absolute host path) as an
|
|
// attachment with a stable id so browser requests never carry arbitrary
|
|
// paths. Registration enforces the COD-53 attachment-guard policy. Serving is
|
|
// by id via the /raw route below; document previews/thumbnails and the
|
|
// attachment-history list are layered on separately.
|
|
app.post('/api/sessions/:id/attachments', async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
const session = findSessionOrFail(ctx, id, req);
|
|
const body = (req.body || {}) as { path?: string };
|
|
|
|
if (!body.path || typeof body.path !== 'string') {
|
|
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir });
|
|
ctx.broadcast(SseEvent.AttachmentDetected, event);
|
|
return { success: true, data: event };
|
|
} catch (err) {
|
|
if (err instanceof AttachmentRegistrationError) {
|
|
reply.code(err.statusCode).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message));
|
|
return;
|
|
}
|
|
return reply
|
|
.code(500)
|
|
.send(
|
|
createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to register attachment: ${getErrorMessage(err)}`)
|
|
);
|
|
}
|
|
});
|
|
|
|
// List a session's attachment history (live session or persisted), resolving
|
|
// each entry to current metadata + routes. External entries are re-registered.
|
|
app.get('/api/sessions/:id/attachments', async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
const sessionHistory = getSessionAttachmentHistory(ctx, id, req);
|
|
if (!sessionHistory) {
|
|
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`));
|
|
return;
|
|
}
|
|
|
|
const items = await Promise.all(
|
|
sessionHistory.history.map((item) =>
|
|
(item.source === 'external'
|
|
? buildExternalAttachmentRouteItem(id, item, sessionHistory.workingDir)
|
|
: buildDetectedAttachmentRouteItem(id, sessionHistory.workingDir, item)
|
|
).catch(() => ({ ...sanitizeAttachmentHistoryItem(item), missing: true }))
|
|
)
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
items,
|
|
count: items.length,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Metadata poll for a single registered attachment (re-stats for live
|
|
// size/mtime as the underlying file is rewritten).
|
|
app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => {
|
|
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
|
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
|
if (!workingDir) return;
|
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
|
if (!record) return;
|
|
if (!(await resolveServableAttachmentPath(reply, record, workingDir))) return;
|
|
const event = attachmentRecordToEvent(record);
|
|
let size = record.size;
|
|
let mtimeMs = record.mtimeMs;
|
|
try {
|
|
const stat = await fs.stat(record.filePath);
|
|
size = stat.size;
|
|
mtimeMs = stat.mtimeMs ?? mtimeMs;
|
|
} catch {
|
|
// File temporarily unavailable mid-write — keep cached values.
|
|
}
|
|
return {
|
|
success: true,
|
|
data: {
|
|
path: record.fileName,
|
|
size,
|
|
mtimeMs,
|
|
type: record.attachmentType,
|
|
extension: record.extension,
|
|
url: event.rawUrl,
|
|
previewUrl: event.previewUrl,
|
|
thumbnailUrl: event.thumbnailUrl,
|
|
attachmentId: record.attachmentId,
|
|
fileName: record.fileName,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Serve the raw bytes of a registered attachment by id. Re-checks the
|
|
// attachment-guard policy on every request (defense-in-depth) before streaming.
|
|
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
|
|
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
|
const { download } = req.query as { download?: string };
|
|
const session = findSessionOrFail(ctx, id, req);
|
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
|
if (!record) return;
|
|
const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
|
|
if (!servePath) return;
|
|
|
|
try {
|
|
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true');
|
|
} catch (err) {
|
|
reply
|
|
.code(500)
|
|
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
|
}
|
|
});
|
|
|
|
// Serve a converted PDF preview of a registered attachment by id. Office docs
|
|
// convert server-side; PDF/PNG/text redirect to the raw route.
|
|
app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
|
|
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
|
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
|
if (!workingDir) return;
|
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
|
if (!record) return;
|
|
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
|
|
if (!servePath) return;
|
|
|
|
// Only Office formats need server-side conversion; PDF/PNG and text formats
|
|
// (md/txt) preview directly from their raw bytes.
|
|
if (record.extension !== 'docx' && record.extension !== 'pptx') {
|
|
reply.redirect(`/api/sessions/${id}/attachments/${encodeURIComponent(attachmentId)}/raw`);
|
|
return;
|
|
}
|
|
|
|
await serveConvertedPreview(reply, servePath, record.fileName, record.extension);
|
|
});
|
|
|
|
// Serve a first-page thumbnail of a registered attachment by id.
|
|
app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
|
|
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
|
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
|
if (!workingDir) return;
|
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
|
if (!record) return;
|
|
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
|
|
if (!servePath) return;
|
|
await serveThumbnail(reply, servePath, record.extension);
|
|
});
|
|
|
|
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
|
|
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
|
|
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;
|
|
|
|
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'));
|
|
return;
|
|
}
|
|
const { resolvedPath } = validated;
|
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
|
|
if (ext !== 'docx' && ext !== 'pptx') {
|
|
reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`);
|
|
return;
|
|
}
|
|
|
|
await serveConvertedPreview(reply, 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;
|
|
|
|
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'));
|
|
return;
|
|
}
|
|
|
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
if (!isSupportedAttachmentExtension(ext)) {
|
|
reply
|
|
.code(400)
|
|
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Thumbnail is not supported for this file type'));
|
|
return;
|
|
}
|
|
|
|
await serveThumbnail(reply, validated.resolvedPath, ext);
|
|
});
|
|
|
|
// Stream file content via tail -f (SSE endpoint)
|
|
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
const { path: filePath, lines } = req.query as { path?: string; lines?: string };
|
|
const session = findSessionOrFail(ctx, id, req);
|
|
|
|
if (!filePath) {
|
|
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
|
return;
|
|
}
|
|
|
|
// Set up SSE headers
|
|
reply.raw.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
});
|
|
|
|
// Track stream for cleanup
|
|
const streamRef: { id?: string } = {};
|
|
|
|
// Create the file stream
|
|
const result = await fileStreamManager.createStream({
|
|
sessionId: id,
|
|
filePath,
|
|
workingDir: session.workingDir,
|
|
lines: lines ? parseInt(lines, 10) : undefined,
|
|
onData: (data) => {
|
|
// Send data as SSE event
|
|
reply.raw.write(`data: ${JSON.stringify({ type: 'data', content: data })}\n\n`);
|
|
},
|
|
onEnd: () => {
|
|
reply.raw.write(`data: ${JSON.stringify({ type: 'end' })}\n\n`);
|
|
reply.raw.end();
|
|
},
|
|
onError: (error) => {
|
|
reply.raw.write(`data: ${JSON.stringify({ type: 'error', error })}\n\n`);
|
|
},
|
|
});
|
|
|
|
if (!result.success) {
|
|
reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: result.error })}\n\n`);
|
|
reply.raw.end();
|
|
return;
|
|
}
|
|
|
|
streamRef.id = result.streamId;
|
|
|
|
// Notify client of successful connection
|
|
reply.raw.write(`data: ${JSON.stringify({ type: 'connected', streamId: result.streamId, filePath })}\n\n`);
|
|
|
|
// Handle client disconnect
|
|
req.raw.on('close', () => {
|
|
if (streamRef.id) {
|
|
fileStreamManager.closeStream(streamRef.id);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Close a file stream. Returns { closed } rather than { success: closed } —
|
|
// a top-level `success` key would collide with the envelope discriminator
|
|
// (the preSerialization hook would pass `{success:false}` through as a
|
|
// malformed error envelope instead of wrapping it).
|
|
app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
|
|
const { id, streamId } = req.params as { id: string; streamId: string };
|
|
findSessionOrFail(ctx, id, req); // Validates session exists
|
|
const closed = fileStreamManager.closeStream(streamId);
|
|
return { closed };
|
|
});
|
|
// Session-scoped file download.
|
|
// Uses the same realpath-based workspace boundary as file preview/raw routes;
|
|
// the shared sensitive-path blocklist (../sensitive-path.js, also used by the
|
|
// attachment guard) remains defense-in-depth, not the primary boundary.
|
|
app.get('/api/download', async (req, reply) => {
|
|
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
|
|
|
|
if (!filePath) {
|
|
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
|
return;
|
|
}
|
|
|
|
if (!sessionId) {
|
|
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing sessionId parameter'));
|
|
return;
|
|
}
|
|
|
|
const session = findSessionOrFail(ctx, sessionId, req);
|
|
const validated = validateSessionFilePath(session.workingDir, filePath);
|
|
if (!validated) {
|
|
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
|
return;
|
|
}
|
|
const { resolvedPath } = validated;
|
|
|
|
// Check sensitive path blocklist
|
|
if (isSensitivePath(resolvedPath)) {
|
|
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const stat = await fs.stat(resolvedPath);
|
|
|
|
if (!stat.isFile()) {
|
|
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is not a file'));
|
|
return;
|
|
}
|
|
|
|
// 50MB size limit
|
|
const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
|
|
if (stat.size > MAX_DOWNLOAD_SIZE) {
|
|
reply
|
|
.code(400)
|
|
.send(
|
|
createErrorResponse(
|
|
ApiErrorCode.INVALID_INPUT,
|
|
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > 50MB limit)`
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
const mimeTypes: Record<string, string> = {
|
|
png: 'image/png',
|
|
jpg: 'image/jpeg',
|
|
jpeg: 'image/jpeg',
|
|
gif: 'image/gif',
|
|
webp: 'image/webp',
|
|
svg: 'image/svg+xml',
|
|
pdf: 'application/pdf',
|
|
json: 'application/json',
|
|
txt: 'text/plain',
|
|
md: 'text/markdown',
|
|
csv: 'text/csv',
|
|
xml: 'application/xml',
|
|
zip: 'application/zip',
|
|
gz: 'application/gzip',
|
|
tar: 'application/x-tar',
|
|
};
|
|
|
|
const filename = pathBasename(resolvedPath);
|
|
const content = await fs.readFile(resolvedPath);
|
|
// Bypass Fastify compression — write directly to raw response
|
|
reply.raw.writeHead(200, {
|
|
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
'Content-Length': content.length,
|
|
});
|
|
reply.raw.end(content);
|
|
return;
|
|
} catch (err) {
|
|
reply
|
|
.code(500)
|
|
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
|
}
|
|
});
|
|
}
|