Skill Tracer
Skills › gstack › browse › files.ts

files.ts

47 lines · browse/src/routes/files.ts at 3f79d81

1/**
2 * GET /file — serve a downloaded file from the temp roots so remote agents
3 * can retrieve screenshots, PDFs and media. Accepts root and scoped tokens.
4 */
5
6import * as fs from 'fs';
7import * as path from 'path';
8import { jsonError, type RouteEntry } from './table';
9import { validateTempPath } from '../path-security';
11const MIME_MAP: Record<string, string> = {
12 '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
13 '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
14 '.avif': 'image/avif',
15 '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
16 '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
17 '.pdf': 'application/pdf', '.json': 'application/json',
18 '.html': 'text/html', '.txt': 'text/plain', '.mhtml': 'message/rfc822',
19};
21export const fileRoutes: RouteEntry[] = [
22 {
23 method: 'GET', path: '/file', auth: 'scoped', surfaces: ['local'],
24 handler: (_req, { url }, ctx) => {
25 const filePath = url.searchParams.get('path');
26 if (!filePath) return jsonError(400, 'Missing "path" query parameter');
27 try {
28 validateTempPath(filePath);
29 } catch (err: any) {
30 return jsonError(403, err.message);
31 }
32 if (!fs.existsSync(filePath)) return jsonError(404, 'File not found');
33 const stat = fs.statSync(filePath);
34 if (stat.size > 200 * 1024 * 1024) return jsonError(413, 'File too large (max 200MB)');
35 const contentType = MIME_MAP[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
36 ctx.resetIdleTimer();
37 return new Response(Bun.file(filePath), {
38 headers: {
39 'Content-Type': contentType,
40 'Content-Length': String(stat.size),
41 'Content-Disposition': `inline; filename="${path.basename(filePath)}"`,
42 'Cache-Control': 'no-cache',
43 },
44 });
45 },
46 },
47];