/** * Shared request handlers reused across plan, review, and annotate servers. * handleImageRequest, handleUploadRequest, handleDraftRequest, handleFavicon */ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import type { IncomingMessage } from "node:http"; import { tmpdir } from "node:os"; import { join, resolve as resolvePath } from "node:path"; import { saveDraft, loadDraft, deleteDraft } from "../generated/draft.js"; import { FAVICON_SVG } from "../generated/favicon.js"; import { json, parseBody, send, toWebRequest } from "./helpers"; type Res = import("node:http").ServerResponse; const ALLOWED_IMAGE_EXTENSIONS = new Set([ "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "tiff", "tif", "avif", ]); const IMAGE_CONTENT_TYPES: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon", tiff: "image/tiff", tif: "image/tiff", avif: "image/avif", }; const UPLOAD_DIR = join(tmpdir(), "plannotator"); function getExtension(filePath: string): string { const lastDot = filePath.lastIndexOf("."); if (lastDot === -1) return ""; return filePath.slice(lastDot + 1).toLowerCase(); } function validateImagePath(rawPath: string): { valid: boolean; resolved: string; error?: string; } { const resolved = resolvePath(rawPath); const ext = getExtension(resolved); if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { return { valid: false, resolved, error: "Path does not point to a supported image file", }; } return { valid: true, resolved }; } function validateUploadExtension(fileName: string): { valid: boolean; ext: string; error?: string; } { const ext = getExtension(fileName) || "png"; if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { return { valid: false, ext, error: `File extension ".${ext}" is not a supported image type`, }; } return { valid: true, ext }; } function getImageContentType(filePath: string): string { return ( IMAGE_CONTENT_TYPES[getExtension(filePath)] || "application/octet-stream" ); } export function handleImageRequest(res: Res, url: URL): void { const imagePath = url.searchParams.get("path"); if (!imagePath) { send(res, "Missing path parameter", 400, { "Content-Type": "text/plain" }); return; } const tryServePath = (candidate: string): boolean => { const validation = validateImagePath(candidate); if (!validation.valid) return false; try { if (!existsSync(validation.resolved)) return false; const data = readFileSync(validation.resolved); send(res, data, 200, { "Content-Type": getImageContentType(validation.resolved), }); return true; } catch { return false; } }; if (tryServePath(imagePath)) return; const base = url.searchParams.get("base"); if ( base && !imagePath.startsWith("/") && tryServePath(resolvePath(base, imagePath)) ) { return; } const validation = validateImagePath(imagePath); if (!validation.valid) { send(res, validation.error || "Invalid image path", 403, { "Content-Type": "text/plain", }); return; } send(res, "File not found", 404, { "Content-Type": "text/plain" }); } export async function handleUploadRequest( req: IncomingMessage, res: Res, ): Promise { try { const request = toWebRequest(req); const formData = await request.formData(); const file = formData.get("file"); if ( !file || typeof file !== "object" || !("arrayBuffer" in file) || !("name" in file) ) { json(res, { error: "No file provided" }, 400); return; } const upload = file as File; const extResult = validateUploadExtension(upload.name); if (!extResult.valid) { json(res, { error: extResult.error }, 400); return; } mkdirSync(UPLOAD_DIR, { recursive: true }); const tempPath = join(UPLOAD_DIR, `${randomUUID()}.${extResult.ext}`); const bytes = Buffer.from(await upload.arrayBuffer()); writeFileSync(tempPath, bytes); json(res, { path: tempPath, originalName: upload.name }); } catch (err) { const message = err instanceof Error ? err.message : "Upload failed"; json(res, { error: message }, 500); } } export function handleDraftRequest( req: IncomingMessage, res: Res, draftKey: string, ): Promise | void { if (req.method === "POST") { return parseBody(req) .then((body) => { saveDraft(draftKey, body); json(res, { ok: true }); }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : "Failed to save draft"; console.error(`[draft] save failed: ${message}`); json(res, { error: message }, 500); }); } else if (req.method === "DELETE") { deleteDraft(draftKey); json(res, { ok: true }); } else { const draft = loadDraft(draftKey); if (!draft) { json(res, { found: false }, 404); return; } json(res, draft); } } export function handleFavicon(res: Res): void { send(res, FAVICON_SVG, 200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400", }); }