Every assistant message gets an "Open in pane" affordance that opens the
message in the workspace splitter — Markdown pane (Copy + Download .md) by
default; HTML pane (Download .html only) when the model emits a self-contained
<!DOCTYPE html> or fenced ```html artifact. BOOCHAT.md rule keeps Markdown
default at every length; HTML opt-in on explicit user request.
Backend: services/artifacts.ts (slug derivation + write helpers with
symlink-escape guard via realpath-after-mkdir), routes/artifacts.ts (POST
download + GET stream with nosniff + CSP sandbox defense-in-depth), HTML
detection in finalizeCompletion writing a new message_parts.kind='html_artifact'
row (schema CHECK extended via v1.13.13 pattern), graceful 1MB cap via the
pure decideHtmlArtifactWrite helper. PartKind union extended.
Frontend: MarkdownRenderer.tsx extracted from MessageBubble's inline
MarkdownBody for reuse; MarkdownArtifactPane.tsx + HtmlArtifactPane.tsx with
loading/error states; pane state is reference-only ({chat_id, message_id,
title}) — content fetched on mount to keep workspace_panes jsonb small and
avoid 1MB blobs riding session_workspace_updated frames. iframe sandbox
locked to allow-scripts allow-clipboard-write allow-downloads with no
allow-same-origin, srcDoc not src. openInPane discriminates 404 (expected
fallback) from real errors (toast + bail). PanelRightOpen icon button with
mobile 44px tap-target.
31 new server unit tests including a real-symlink filesystem case; 332/332
server tests passing, tsc clean both sides, pnpm -C apps/web build green.
Smoke deferred to first deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
232 lines
7.8 KiB
TypeScript
232 lines
7.8 KiB
TypeScript
// 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 <projectRoot>/.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<ChatRow[]>`
|
|
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<MessageRow[]>`
|
|
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));
|
|
},
|
|
);
|
|
}
|