merge master into claude-response-viewer-normalization

Only CLAUDE.md conflicted: master restructured it into the short-rule +
docs/architecture-invariants.md pointer layout while this PR was open.
The response-viewer detail now lives in architecture-invariants, so the
Claude turn-grouping and restored-placeholder rebind notes moved there.
Changeset rewritten to record the measured effect on real transcripts.
This commit is contained in:
Codeman maintainer
2026-07-28 11:04:41 +02:00
61 changed files with 6402 additions and 438 deletions
+370 -8
View File
@@ -4,9 +4,17 @@
*/
import { FastifyInstance, type FastifyReply } from 'fastify';
import { basename as pathBasename, join } from 'node:path';
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 {
@@ -22,12 +30,21 @@ 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 { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.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',
@@ -45,8 +62,14 @@ const MIME_TYPES: Record<string, string> = {
txt: 'text/plain',
};
function sanitizeDownloadName(fileName: string): string {
return fileName.replace(/["\\\r\n]/g, '_');
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 {
@@ -92,13 +115,12 @@ async function serveRawFile(
return;
}
const content = createReadStream(resolvedPath);
const safeName = sanitizeDownloadName(fileName);
if (download || extension === 'svg') {
reply.header(
'Content-Type',
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
);
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
reply.header('Content-Length', stat.size);
reply.header('X-Content-Type-Options', 'nosniff');
sendRawStream(reply, content);
@@ -106,7 +128,7 @@ async function serveRawFile(
}
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
reply.header('Content-Disposition', `inline; filename="${safeName}"`);
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
reply.header('Content-Length', stat.size);
reply.header('X-Content-Type-Options', 'nosniff');
sendRawStream(reply, content);
@@ -194,7 +216,10 @@ async function serveConvertedPreview(
const content = await fs.readFile(previewPath);
reply.header('Content-Type', 'application/pdf');
reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
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');
@@ -260,6 +285,170 @@ type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPa
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`;
}
@@ -375,6 +564,179 @@ async function buildExternalAttachmentRouteItem(
}
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 };
+1
View File
@@ -22,3 +22,4 @@ export { registerSearchRoutes } from './search-routes.js';
export { registerMeRoutes } from './me-routes.js';
export { registerAdminRoutes } from './admin-routes.js';
export { registerWsRoutes } from './ws-routes.js';
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
+629
View File
@@ -0,0 +1,629 @@
/**
* @fileoverview Web tabs: saved dashboard URLs, plus the reverse proxy that makes
* them embeddable.
*
* Two distinct surfaces live here, and the split matters:
*
* 1. `/api/webviews/*`, ordinary authenticated CRUD, owner-scoped like every
* other resource, returning the `ApiResponse` envelope.
* 2. `/webview/:cap/*`, the proxy. NOT an API surface. It authenticates on an
* unguessable capability in the path instead of Codeman's session cookie, and
* is correspondingly exempt from the cookie and Origin checks in
* `middleware/auth.ts`. See `src/webview-capabilities.ts` for why a cookie
* cannot work here (sandboxed iframes are opaque-origin, so their requests are
* cross-site and arrive with `Origin: null`).
*
* The proxy is registered inside an ENCAPSULATED plugin scope with its own
* catch-all content-type parser. Fastify scopes parsers to the plugin that
* registers them, which is what lets the proxy forward raw request bodies
* upstream while the rest of the app keeps its JSON parsing (and, critically,
* keeps `text/plain` raw, auto-parsing that was a real CSRF hole once).
*
* Endpoints:
* GET /api/webviews
* POST /api/webviews
* PATCH /api/webviews/:id
* DELETE /api/webviews/:id
* POST /api/webviews/probe
* POST /api/webviews/:id/open
* ALL /webview/:cap/* (+ WebSocket upgrade on GET)
*/
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { WebSocket as WsClient } from 'ws';
import type { WebSocket } from 'ws';
import { getDataDir } from '../../config/instance.js';
import {
MAX_LIVE_WEBVIEW_FRAMES,
MAX_WEBVIEWS,
MAX_WEBVIEW_HTML_REWRITE_BYTES,
MAX_WEBVIEW_SOCKETS,
WEBVIEW_PROBE_TIMEOUT_MS,
WEBVIEW_PROXY_PREFIX,
WEBVIEW_UPSTREAM_TIMEOUT_MS,
} from '../../config/webview-limits.js';
import { readWebviews, writeWebviews } from '../../webview-store.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js';
import { WebviewCreateSchema, WebviewProbeSchema, WebviewUpdateSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort } from '../ports/index.js';
import {
buildDownstreamResponseHeaders,
buildProxyCorsHeaders,
buildUpstreamRequestHeaders,
capabilityFromReferer,
extractFrameAncestors,
isFramableCrossOrigin,
isHtmlContentType,
parseWebviewUrl,
proxyPrefixFor,
resolveUpstreamUrl,
rewriteHtml,
upstreamWebSocketUrl,
} from '../webview-proxy.js';
/**
* Resolved per call rather than captured at module load. `getDataDir()` reads
* `CODEMAN_DATA_DIR` each time, so a lazy lookup keeps tests writing to a temp dir
* instead of the developer's real `~/.codeman/webviews.json`.
*/
function configDir(): string {
return getDataDir();
}
/** Live proxied WebSockets per webview id, so one dashboard cannot exhaust the socket budget. */
const socketCounts = new Map<string, number>();
interface ProxyParams {
cap: string;
'*'?: string;
}
/** Serialize webview mutations: read-modify-write on a shared JSON file otherwise races. */
let writeChain: Promise<unknown> = Promise.resolve();
function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
const next = writeChain.then(async () => {
const list = await readWebviews(configDir());
return fn(list);
});
// Keep the chain alive even if this link rejects, or every later write deadlocks.
writeChain = next.catch(() => undefined);
return next;
}
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort): void {
registerCrudRoutes(app, ctx);
registerProxyRoutes(app);
}
// ───────────────────────────── CRUD ─────────────────────────────
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
app.get('/api/webviews', async (req) => {
const user = getAuthUser(req);
const all = await readWebviews(configDir());
const webviews = all.filter((w) => canAccessOwned(user, w.owner));
return { success: true, data: { webviews, maxLiveFrames: MAX_LIVE_WEBVIEW_FRAMES } };
});
app.post('/api/webviews', async (req, reply) => {
const input = parseBody(WebviewCreateSchema, req.body);
const owner = ownerFor(req);
const user = getAuthUser(req);
const created = await withWebviews(async (list) => {
const mine = list.filter((w) => canAccessOwned(user, w.owner));
if (mine.length >= MAX_WEBVIEWS) return null;
const webview: Webview = {
id: randomUUID(),
name: input.name,
url: input.url,
icon: input.icon,
// Proxy is the safe default: it is the only mode that works for a plain-HTTP
// dashboard on an HTTPS Codeman, which is the common case.
embedMode: input.embedMode ?? 'proxy',
trusted: input.trusted ?? false,
owner,
createdAt: Date.now(),
};
list.push(webview);
await writeWebviews(configDir(), list);
return webview;
});
if (!created) {
return reply
.code(400)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `Webview limit reached (max ${MAX_WEBVIEWS})`));
}
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
return { success: true, data: created };
});
app.patch<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => {
const input = parseBody(WebviewUpdateSchema, req.body);
const user = getAuthUser(req);
const { id } = req.params;
const updated = await withWebviews(async (list) => {
const index = list.findIndex((w) => w.id === id);
if (index === -1) return 'not-found' as const;
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
const next: Webview = { ...list[index], ...input };
list[index] = next;
await writeWebviews(configDir(), list);
return next;
});
if (updated === 'not-found') {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
}
if (updated === 'forbidden') {
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
}
// Any edit invalidates the outstanding capability. Otherwise a token minted
// against the OLD url keeps proxying to it after the user repointed the tab.
webviewCapabilities.revokeWebview(id);
ctx.broadcast(SseEvent.WebviewChanged, { action: 'updated', id });
return { success: true, data: updated };
});
app.delete<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => {
const user = getAuthUser(req);
const { id } = req.params;
const result = await withWebviews(async (list) => {
const index = list.findIndex((w) => w.id === id);
if (index === -1) return 'not-found' as const;
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
list.splice(index, 1);
await writeWebviews(configDir(), list);
return 'deleted' as const;
});
if (result === 'not-found') {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
}
if (result === 'forbidden') {
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
}
webviewCapabilities.revokeWebview(id);
socketCounts.delete(id);
ctx.broadcast(SseEvent.WebviewChanged, { action: 'deleted', id });
return { success: true, data: { id } };
});
/**
* Reachability + framing probe for the editor's "Test" button.
*
* Runs from the SERVER, which is the network position the proxy will use, so a
* green result here means the proxy will actually work. Never throws upstream
* failures at the caller: an unreachable dashboard is a normal answer, not a 500.
*/
app.post('/api/webviews/probe', async (req) => {
const { url } = parseBody(WebviewProbeSchema, req.body);
return { success: true, data: await probeUrl(url) };
});
/**
* Mint the capability the iframe will load. Separate from GET /api/webviews so a
* capability exists only for dashboards actually opened, and so the TTL clock
* starts on open rather than on page load.
*/
app.post<{ Params: { id: string } }>('/api/webviews/:id/open', async (req, reply) => {
const user = getAuthUser(req);
const { id } = req.params;
const webview = await withWebviews(async (list) => {
const index = list.findIndex((w) => w.id === id);
if (index === -1) return 'not-found' as const;
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
list[index] = { ...list[index], lastOpenedAt: Date.now() };
await writeWebviews(configDir(), list);
return list[index];
});
if (webview === 'not-found') {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
}
if (webview === 'forbidden') {
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
}
// Direct mode has no capability to mint: the iframe loads the real URL.
if (webview.embedMode === 'direct') {
const data: WebviewOpenData = { webview };
return { success: true, data };
}
const capability = webviewCapabilities.mint(webview.id, webview.owner);
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability) };
return { success: true, data };
});
}
async function probeUrl(url: string): Promise<WebviewProbe> {
const target = parseWebviewUrl(url);
if (!target) {
return {
reachable: false,
framable: false,
recommendedMode: 'proxy',
reason: 'Invalid URL',
};
}
try {
const response = await fetch(target.href, {
method: 'GET',
redirect: 'manual',
signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS),
});
// The body is irrelevant to the probe; release the socket rather than leak it.
await response.body?.cancel().catch(() => undefined);
const xFrameOptions = response.headers.get('x-frame-options') ?? undefined;
const csp = response.headers.get('content-security-policy') ?? undefined;
const frameAncestors = extractFrameAncestors(csp);
const framable = isFramableCrossOrigin(xFrameOptions, csp);
const isHttp = target.protocol === 'http:';
// Direct embedding is only viable for an HTTPS target that permits framing:
// an HTTPS Codeman page cannot embed http:// at all (mixed content).
const recommendedMode = !isHttp && framable ? 'direct' : 'proxy';
const reason = isHttp
? 'Plain HTTP: an HTTPS Codeman page cannot embed it directly, so it is proxied.'
: framable
? 'Reachable and allows framing: can be embedded directly.'
: 'Reachable but refuses framing, so it is proxied.';
return {
reachable: true,
status: response.status,
xFrameOptions,
frameAncestors,
framable,
recommendedMode,
reason,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
reachable: false,
framable: false,
recommendedMode: 'proxy',
reason: `Server could not reach it: ${message}`,
};
}
}
// ───────────────────────────── Proxy ─────────────────────────────
function registerProxyRoutes(app: FastifyInstance): void {
app.register(async (scope) => {
// Encapsulated to this plugin only. The proxy must relay request bodies
// BYTE-FOR-BYTE, so every parser is replaced with a pass-through that hands
// back the raw stream. Doing this on the root instance would break JSON
// routes and un-fix the text/plain CSRF hardening.
scope.removeAllContentTypeParsers();
scope.addContentTypeParser('*', (_req, payload, done) => done(null, payload));
// A single GET route serving both roles: `handler` for normal requests,
// `wsHandler` for upgrades. Registering them as two routes on one URL would
// collide.
scope.route<{ Params: ProxyParams }>({
method: 'GET',
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
handler: proxyHttp,
wsHandler: proxyWebSocket,
});
// HEAD is deliberately absent: Fastify's `exposeHeadRoutes` already derives a
// HEAD route from the GET above, and declaring it again is a startup error.
scope.route<{ Params: ProxyParams }>({
method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
handler: proxyHttp,
});
// `/webview/<cap>` with no trailing slash: redirect rather than serve, so the
// browser's notion of the base path ends in `/` and relative URLs in the
// dashboard's HTML resolve inside the prefix instead of one level above it.
scope.get<{ Params: { cap: string } }>(`${WEBVIEW_PROXY_PREFIX}/:cap`, (req, reply) => {
return reply.redirect(proxyPrefixFor(req.params.cap), 302);
});
});
}
/** Resolve a capability to its live webview record, or null. */
async function lookupCapability(capability: string): Promise<Webview | null> {
const record = webviewCapabilities.resolve(capability);
if (!record) return null;
const list = await readWebviews(configDir());
const webview = list.find((w) => w.id === record.webviewId);
if (!webview) return null;
// The capability is bound to the identity that minted it; an ownership change
// on the record must not leave a stale token working.
if (webview.owner !== record.owner) return null;
return webview;
}
/**
* ⚠ Every exit path RETURNS `reply.send(...)`.
*
* This handler is `async`, and Fastify resolves an async handler's promise as the
* response. `reply.send(stream)` followed by a bare `return` resolves to
* `undefined` before the stream has been consumed, and Fastify then answers with
* an EMPTY body: HTML (a synchronously-set string payload) survives it, every
* streamed asset comes back zero-length. Returning the reply is what tells Fastify
* the response is already owned by this handler.
*/
function proxyHttp(req: FastifyRequest<{ Params: ProxyParams }>, reply: FastifyReply): Promise<FastifyReply> {
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '');
}
/**
* Proxy one request to the dashboard behind `cap`, serving `wildcard` as the
* upstream path. Split out from the route handler so the 404 fallback (which has
* no route params) can reuse it.
*/
async function proxyRequest(
req: FastifyRequest,
reply: FastifyReply,
cap: string,
wildcard: string
): Promise<FastifyReply> {
const webview = await lookupCapability(cap);
if (!webview) {
return reply.code(403).type('text/plain').send('Forbidden: unknown or expired webview capability');
}
// CORS is required even though the URL is on this host: a sandboxed dashboard is
// opaque-origin, so its fetch/XHR are cross-origin requests. See
// buildProxyCorsHeaders.
const cors = buildProxyCorsHeaders(
typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
typeof req.headers['access-control-request-headers'] === 'string'
? req.headers['access-control-request-headers']
: undefined
);
// Answer the preflight here rather than relaying it: the dashboard has no reason
// to know it is being framed, and most would reject an unexpected `Origin: null`.
if (req.method === 'OPTIONS' && req.headers['access-control-request-method']) {
for (const [key, value] of Object.entries(cors)) reply.header(key, value);
return reply.code(204).send();
}
const queryStart = req.url.indexOf('?');
const search = queryStart === -1 ? '' : req.url.slice(queryStart);
const upstream = resolveUpstreamUrl(webview.url, wildcard, search);
if (!upstream) {
return reply.code(400).type('text/plain').send('Bad Request: path escapes the dashboard origin');
}
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
const headers = buildUpstreamRequestHeaders(req.headers, upstream, {
forwardCookies: webview.trusted,
sessionCookieName: AUTH_COOKIE_NAME,
refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap) : undefined,
});
let response: Response;
try {
response = await fetch(upstream.href, {
method: req.method,
headers,
body: hasBody ? (req.body as Readable) : undefined,
// Required by undici whenever the body is a stream.
...(hasBody ? { duplex: 'half' } : {}),
// Redirects are rewritten into the proxy prefix instead of followed, so the
// browser's URL stays inside the frame and relative assets keep resolving.
redirect: 'manual',
signal: AbortSignal.timeout(WEBVIEW_UPSTREAM_TIMEOUT_MS),
} as RequestInit);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return reply.code(502).type('text/plain').send(`Dashboard unreachable: ${message}`);
}
const secureContext = req.protocol === 'https';
const {
headers: outHeaders,
setCookie,
csp,
} = buildDownstreamResponseHeaders(
response.headers as unknown as Iterable<[string, string]>,
response.headers.getSetCookie(),
cap,
upstream,
secureContext
);
reply.code(response.status);
for (const [key, value] of Object.entries(outHeaders)) reply.header(key, value);
// After the upstream headers, so ours win: an upstream ACAO would name the
// dashboard's own origin, not the opaque origin this frame actually has.
for (const [key, value] of Object.entries(cors)) reply.header(key, value);
for (const cookie of setCookie) reply.header('set-cookie', cookie);
// registerSecurityHeaders already stamped Codeman's own `default-src 'self'`
// policy on this reply during onRequest. Left in place it breaks essentially
// every dashboard (inline scripts, CDN assets), so it is replaced by the
// upstream's own policy, or removed when the upstream had none.
if (csp) reply.header('content-security-policy', csp);
else reply.removeHeader('content-security-policy');
if (!response.body || req.method === 'HEAD') {
return reply.send();
}
const contentType = response.headers.get('content-type') ?? undefined;
const declaredLength = Number(response.headers.get('content-length') ?? '0');
const rewritable = isHtmlContentType(contentType) && declaredLength <= MAX_WEBVIEW_HTML_REWRITE_BYTES;
if (rewritable) {
// Buffer only HTML, only under the cap: `<base>` injection needs the whole
// document, and buffering an unbounded upstream body is a memory hazard.
const html = await response.text();
return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap) : html);
}
return reply.send(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]));
}
/**
* Last-resort handler for a dashboard asset requested with a ROOT-ABSOLUTE URL.
*
* `<base href>` fixes relative URLs and the HTML rewrite fixes `src`/`href`/`action`
* attributes, but neither can reach a URL built at runtime: `fetch('/api/data')`,
* `import('/chunk.js')`, `url(/img.png)` inside a stylesheet. Those arrive at
* Codeman's root and 404.
*
* The `Referer` identifies which dashboard asked, so the request can be routed to
* the right upstream. Wiring it into the 404 handler rather than a catch-all route
* is what keeps it contained: every real Codeman route matches first, and this only
* ever sees requests that were going to fail anyway.
*
* @returns true when the request was handled (caller must not also reply).
*/
export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
// Safe methods only. A write arriving here has already lost its raw body to the
// root instance's JSON parser, so it could not be relayed faithfully anyway.
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
const capability = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
if (!capability) return false;
if (!webviewCapabilities.resolve(capability)) return false;
const path = req.url.split('?')[0].replace(/^\//, '');
await proxyRequest(req, reply, capability, path);
return true;
}
/** Turn a proxy-side Referer back into the upstream path it corresponds to. */
function stripProxyPrefix(referer: string, capability: string): string | undefined {
try {
const url = new URL(referer);
const prefix = proxyPrefixFor(capability);
if (!url.pathname.startsWith(prefix)) return undefined;
return `/${url.pathname.slice(prefix.length)}${url.search}`;
} catch {
return undefined;
}
}
// ─────────────────────────── WebSocket ───────────────────────────
/**
* Relay a WebSocket through to the dashboard.
*
* Live dashboards (Grafana, Home Assistant, Uptime Kuma) push over WebSocket, so
* without this leg they load but their realtime panels stay permanently empty.
*
* The upgrade is guarded on the capability, NOT on `Origin`: a sandboxed iframe is
* opaque-origin, so its upgrade arrives with `Origin: null`. The host allowlist
* still applies (it runs in the global onRequest hook), so DNS-rebinding
* protection is unaffected.
*/
function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyParams }>): void {
const { cap } = req.params;
void (async () => {
const webview = await lookupCapability(cap);
if (!webview) {
socket.close(4003, 'Forbidden');
return;
}
const live = socketCounts.get(webview.id) ?? 0;
if (live >= MAX_WEBVIEW_SOCKETS) {
socket.close(4008, 'Too many connections');
return;
}
const wildcard = req.params['*'] ?? '';
const queryStart = req.url.indexOf('?');
const search = queryStart === -1 ? '' : req.url.slice(queryStart);
const upstream = resolveUpstreamUrl(webview.url, wildcard, search);
if (!upstream) {
socket.close(4003, 'Forbidden');
return;
}
socketCounts.set(webview.id, live + 1);
let released = false;
const release = () => {
if (released) return;
released = true;
const count = socketCounts.get(webview.id) ?? 1;
if (count <= 1) socketCounts.delete(webview.id);
else socketCounts.set(webview.id, count - 1);
};
const protocols = req.headers['sec-websocket-protocol'];
const upstreamSocket = new WsClient(
upstreamWebSocketUrl(upstream),
protocols ? String(protocols).split(/,\s*/) : [],
{
headers: {
origin: upstream.origin,
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
},
handshakeTimeout: WEBVIEW_UPSTREAM_TIMEOUT_MS,
}
);
// Buffer anything the browser sends before the upstream handshake completes,
// rather than dropping it: a client that sends a subscribe frame immediately
// would otherwise sit connected and silent forever.
const pending: Array<Buffer | string> = [];
let upstreamOpen = false;
upstreamSocket.on('open', () => {
upstreamOpen = true;
for (const message of pending) upstreamSocket.send(message);
pending.length = 0;
});
socket.on('message', (data: Buffer, isBinary: boolean) => {
const payload = isBinary ? data : data.toString();
if (upstreamOpen) upstreamSocket.send(payload);
else if (pending.length < 64) pending.push(payload);
});
upstreamSocket.on('message', (data: Buffer, isBinary: boolean) => {
if (socket.readyState === socket.OPEN) socket.send(isBinary ? data : data.toString());
});
// Paired close in both directions, so neither side is left half-open.
const closeBoth = (code?: number, reason?: string) => {
release();
// Codes outside 3000-4999 (and 1000/1001) are not valid to send onward.
const safeCode = code && code >= 3000 && code <= 4999 ? code : 1000;
if (socket.readyState === socket.OPEN) socket.close(safeCode, reason);
if (upstreamSocket.readyState === WsClient.OPEN || upstreamSocket.readyState === WsClient.CONNECTING) {
upstreamSocket.close(safeCode, reason);
}
};
socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
socket.on('error', () => closeBoth());
upstreamSocket.on('error', () => {
release();
if (socket.readyState === socket.OPEN) socket.close(1011, 'Upstream error');
});
})();
}