// v1.14.x-html-artifact-panes: artifact download routes. // // Two endpoints: // POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html // Materialises a file under /.boocode/artifacts/ and // returns {path, url}. fmt=html requires an existing html_artifact part // on the message (404 otherwise). fmt=md works on any assistant // message with non-empty content. // // GET /api/projects/:project_id/artifacts/:filename // Streams a previously-written artifact back with // Content-Disposition: attachment. Path-guarded to the project's // artifacts dir; rejects traversal attempts. import { createReadStream } from 'node:fs'; import { realpath, stat } from 'node:fs/promises'; import { resolve, sep, basename } from 'node:path'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; import { writeHtmlArtifact, writeMarkdownArtifact, type HtmlArtifactPayload, } from '../services/artifacts.js'; const DownloadQuery = z.object({ fmt: z.enum(['md', 'html']), }); // Filename safety: alnum, dash, dot, underscore only. Blocks `..`, slashes, // nul bytes, etc. before we even touch the filesystem. const FilenameRe = /^[A-Za-z0-9._-]+$/; interface ChatRow { id: string; session_id: string; project_id: string; project_path: string; } interface MessageRow { id: string; chat_id: string; role: string; content: string; } export function registerArtifactRoutes(app: FastifyInstance, sql: Sql): void { app.post<{ Params: { id: string; msg_id: string }; Querystring: { fmt?: string }; }>( '/api/chats/:id/messages/:msg_id/artifacts/download', async (req, reply) => { const parsed = DownloadQuery.safeParse(req.query); if (!parsed.success) { reply.code(400); return { error: 'invalid query', details: parsed.error.flatten() }; } const { fmt } = parsed.data; const { id: chatId, msg_id: messageId } = req.params; const chatRows = await sql` SELECT c.id, c.session_id, s.project_id, p.path AS project_path FROM chats c JOIN sessions s ON s.id = c.session_id JOIN projects p ON p.id = s.project_id WHERE c.id = ${chatId} `; if (chatRows.length === 0) { reply.code(404); return { error: 'chat not found' }; } const chat = chatRows[0]!; const msgRows = await sql` SELECT id, chat_id, role, content FROM messages WHERE id = ${messageId} AND chat_id = ${chatId} `; if (msgRows.length === 0) { reply.code(404); return { error: 'message not found' }; } const msg = msgRows[0]!; if (msg.role !== 'assistant') { reply.code(400); return { error: 'only assistant messages produce artifacts' }; } const ctx = { projectId: chat.project_id, projectRoot: chat.project_path }; try { if (fmt === 'md') { if (!msg.content || msg.content.trim().length === 0) { reply.code(400); return { error: 'message has no content to export' }; } const result = await writeMarkdownArtifact( { content: msg.content }, ctx, ); return result; } // fmt === 'html': require an html_artifact part on the message. const partRows = await sql<{ payload: HtmlArtifactPayload }[]>` SELECT payload FROM message_parts WHERE message_id = ${messageId} AND kind = 'html_artifact' ORDER BY sequence ASC LIMIT 1 `; if (partRows.length === 0) { reply.code(404); return { error: 'no html_artifact part on this message' }; } const result = await writeHtmlArtifact(partRows[0]!.payload, ctx); return result; } catch (err) { req.log.error({ err, messageId, fmt }, 'artifact write failed'); reply.code(500); return { error: err instanceof Error ? err.message : 'artifact write failed', }; } }, ); // v1.14.x-html-artifact-panes: HtmlArtifactPane needs the payload on click // to render its iframe. Returns 404 when the message has no html_artifact // sibling part — frontend uses that signal to open the markdown_artifact // pane variant instead. Payload shape matches HtmlArtifactPayload in // services/artifacts.ts. app.get<{ Params: { id: string; msg_id: string } }>( '/api/chats/:id/messages/:msg_id/html_artifact', async (req, reply) => { const { id: chatId, msg_id: messageId } = req.params; const partRows = await sql<{ payload: HtmlArtifactPayload }[]>` SELECT payload FROM message_parts mp JOIN messages m ON m.id = mp.message_id WHERE mp.message_id = ${messageId} AND m.chat_id = ${chatId} AND mp.kind = 'html_artifact' ORDER BY mp.sequence ASC LIMIT 1 `; if (partRows.length === 0) { reply.code(404); return { error: 'no html_artifact part on this message' }; } return partRows[0]!.payload; }, ); app.get<{ Params: { project_id: string; filename: string } }>( '/api/projects/:project_id/artifacts/:filename', async (req, reply) => { const { project_id: projectId, filename } = req.params; // Strip directory components defensively; only the basename is allowed. const base = basename(filename); if (base !== filename || !FilenameRe.test(base)) { reply.code(400); return { error: 'invalid filename' }; } const projectRows = await sql<{ id: string; path: string }[]>` SELECT id, path FROM projects WHERE id = ${projectId} `; if (projectRows.length === 0) { reply.code(404); return { error: 'project not found' }; } const project = projectRows[0]!; let resolvedRoot: string; try { resolvedRoot = await realpath(project.path); } catch { reply.code(404); return { error: 'project path missing' }; } const artifactsDir = resolve(resolvedRoot, '.boocode/artifacts'); const absPath = resolve(artifactsDir, base); if (!absPath.startsWith(artifactsDir + sep)) { reply.code(400); return { error: 'path traversal rejected' }; } // Close the symlink-escape gap: if `.boocode/artifacts` (or an // ancestor) is a symlink pointing outside resolvedRoot, the lexical // prefix check above passes but the actual read lands outside the // sandbox. Realpath the artifacts dir and re-verify. try { const realArtifactsDir = await realpath(artifactsDir); if ( realArtifactsDir !== resolvedRoot && !realArtifactsDir.startsWith(resolvedRoot + sep) ) { reply.code(400); return { error: 'path traversal rejected' }; } } catch { reply.code(404); return { error: 'artifact not found' }; } try { await stat(absPath); } catch { reply.code(404); return { error: 'artifact not found' }; } const ext = base.toLowerCase().endsWith('.html') ? 'text/html; charset=utf-8' : base.toLowerCase().endsWith('.md') ? 'text/markdown; charset=utf-8' : 'application/octet-stream'; reply.header('Content-Type', ext); // Defense-in-depth on LLM-generated HTML served through this route. // Authelia gates the proxy; these headers limit blast radius if a // payload tries to escape that boundary in-browser. reply.header('X-Content-Type-Options', 'nosniff'); reply.header('Content-Security-Policy', 'sandbox'); reply.header( 'Content-Disposition', `attachment; filename="${base.replace(/"/g, '')}"`, ); return reply.send(createReadStream(absPath)); }, ); }