v1.13.19-html-artifact-panes: pane-based artifact viewer with on-request HTML
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>
This commit is contained in:
231
apps/server/src/routes/artifacts.ts
Normal file
231
apps/server/src/routes/artifacts.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// 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));
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user