211 lines
5.0 KiB
TypeScript
211 lines
5.0 KiB
TypeScript
/**
|
|
* 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<string, string> = {
|
|
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<void> {
|
|
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> | 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",
|
|
});
|
|
}
|