// routes/api/image-blob/[id].ts import { define } from "../../../utils.ts"; import { blobStorage, getLocalBlobUrl, uploadToLocalBlobStorage, } from "../../../utils/blobStorage.ts"; import { writeImageLog as _writeImageLog } from "../../../utils/logging.ts"; import { hashText } from "../../../services/stateMiddleware.ts"; import { consumeCreateToken, isCreateTokenValid, isTokenExpired, } from "../../../utils/tokenEvaluator.ts"; import { createCorsPreflightResponse, getIslandCorsHeaders, isIslandFetchRequestAllowed, withCors, } from "../../../utils/cors.ts"; const SUPPORTED_IMAGE_EXTENSIONS = [ ".png", ".jpg", ".jpeg", ".webp", ".gif", ] as const; const MIME_TYPE_BY_EXTENSION: Record< (typeof SUPPORTED_IMAGE_EXTENSIONS)[number], string > = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", }; function normalizeBase64Input(input: string): string { const trimmed = (input ?? "").trim(); if (trimmed.startsWith("data:")) { return trimmed.split(",")[1] ?? ""; } return trimmed; } function isProbablyBase64(input: string): boolean { if (!input) return false; if (input.length % 4 !== 0) return false; return /^[A-Za-z0-9+/]+=*$/.test(input); } function base64ToBlob(base64: string, mimeType: string): Blob { const binaryString = atob(base64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } return new Blob([bytes], { type: mimeType }); } function contentTypeForFileName(fileName: string): string { const lowerName = fileName.toLowerCase(); const extension = SUPPORTED_IMAGE_EXTENSIONS.find((ext) => lowerName.endsWith(ext) ); return extension ? MIME_TYPE_BY_EXTENSION[extension] : "image/png"; } async function statFile(path: string): Promise { try { return await Deno.stat(path); } catch (error) { if (error instanceof Deno.errors.NotFound) { return null; } throw error; } } export const handler = define.handlers({ OPTIONS(ctx) { const url = new URL(ctx.req.url); if (!isIslandFetchRequestAllowed(url)) { return new Response("Not Found", { status: 404 }); } const corsHeaders = getIslandCorsHeaders( ctx.req.headers.get("Origin"), ctx.req.headers.get("Access-Control-Request-Headers"), ); return createCorsPreflightResponse(ctx.req, corsHeaders); }, async GET(ctx) { const id = decodeURIComponent(ctx.params.id); if (!id || id.length === 0) { return new Response("ID is required", { status: 400 }); } const lowerId = id.toLowerCase(); const requestedExtension = SUPPORTED_IMAGE_EXTENSIONS.find((ext) => lowerId.endsWith(ext) ); const baseName = requestedExtension ? id.slice(0, -requestedExtension.length) : id; // Sanitize the base name to prevent path traversal attacks const sanitizedBaseName = baseName.replace(/[^-a-zA-Z0-9_.' ]/g, ""); if (sanitizedBaseName !== baseName) { return new Response("Invalid ID format", { status: 400 }); } const candidateNames = requestedExtension ? [`${sanitizedBaseName}${requestedExtension}`] : [ sanitizedBaseName, ...SUPPORTED_IMAGE_EXTENSIONS.map((ext) => `${sanitizedBaseName}${ext}` ), ]; const url = new URL(ctx.req.url); const tokenId = url.searchParams.get("token_id"); const stateId = url.searchParams.get("state_id"); if (!tokenId || !stateId) { return new Response("Token required", { status: 401 }); } const tokenExpired = await isTokenExpired(tokenId, stateId); if (tokenExpired) { return new Response( JSON.stringify({ valid: false, error: "Token invalid or expired" }), { status: 401, headers: { "Content-Type": "application/json" } }, ); } for (const candidateName of candidateNames) { const filePath = blobStorage.getFilePath(candidateName, "image"); let fileInfo: Deno.FileInfo | null = null; try { fileInfo = await statFile(filePath); } catch (error) { console.error("Error stating image file:", error); return new Response( JSON.stringify({ error: "Failed to read image file" }), { status: 500, headers: { "Content-Type": "application/json" }, }, ); } if (!fileInfo || !fileInfo.isFile) { continue; } try { const fileContent = await Deno.readFile(filePath); const contentType = contentTypeForFileName(candidateName); return new Response(fileContent, { status: 200, headers: { "Content-Type": contentType, "Cache-Control": "no-cache, no-store, must-revalidate", }, }); } catch (error) { if (error instanceof Deno.errors.NotFound) { continue; } console.error("Error reading image file:", error); return new Response( JSON.stringify({ error: "Failed to read image file" }), { status: 500, headers: { "Content-Type": "application/json" }, }, ); } } return new Response( JSON.stringify({ error: "Image file not found" }), { status: 404, headers: { "Content-Type": "application/json" }, }, ); }, async POST(ctx) { const url = new URL(ctx.req.url); if (!isIslandFetchRequestAllowed(url)) { return new Response("Not Found", { status: 404 }); } const corsHeaders = getIslandCorsHeaders( ctx.req.headers.get("Origin"), ctx.req.headers.get("Access-Control-Request-Headers"), ); const respond = (body: BodyInit | null, init: ResponseInit) => withCors(new Response(body, init), corsHeaders); const createTokenId = url.searchParams.get("create_token_id"); const createStateId = url.searchParams.get("state_id"); if (!createTokenId || !createStateId) { return respond( JSON.stringify({ error: "Authentication or create token required" }), { status: 401, headers: { "Content-Type": "application/json" } }, ); } const token = await isCreateTokenValid( createTokenId, createStateId, ); if (!token) { return respond( JSON.stringify({ error: "Invalid or expired create token" }), { status: 401, headers: { "Content-Type": "application/json" } }, ); } try { // Parse JSON data - expects blobData (base64) instead of imageUrl const payload = await ctx.req.json(); const rawFileName = typeof payload.fileName === "string" ? payload.fileName.trim() : ""; const imageText = typeof payload.imageText === "string" ? payload.imageText.trim() : ""; const blobData = payload.blobData; const fileName = rawFileName || imageText || "image"; if ( !blobData || typeof fileName !== "string" || fileName.length > 150 ) { return respond( JSON.stringify({ error: "Invalid input data" }), { status: 400, headers: { "Content-Type": "application/json" } }, ); } const normalizedBase64 = normalizeBase64Input(String(blobData)); if (!isProbablyBase64(normalizedBase64)) { return respond( JSON.stringify({ error: "Invalid base64 image data" }), { status: 400, headers: { "Content-Type": "application/json" } }, ); } const hashName = await hashText( `${token.user_id}:${fileName}:${Date.now()}`, ); // Convert base64 to blob and save to file system let blob: Blob; try { blob = base64ToBlob(normalizedBase64, "image/png"); } catch (_e) { return respond( JSON.stringify({ error: "Invalid base64 image data" }), { status: 400, headers: { "Content-Type": "application/json" } }, ); } const uploadResult = await uploadToLocalBlobStorage( hashName, blob, "image", { defaultExtension: ".png" }, ); if (uploadResult.success) { if (createTokenId) { await consumeCreateToken(createTokenId); } const signedUrl = await getLocalBlobUrl( hashName, "image", token.user_id, createStateId, ); return respond( JSON.stringify({ success: true, url: signedUrl }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } return respond( JSON.stringify({ error: "Failed to save image file" }), { status: 500, headers: { "Content-Type": "application/json" } }, ); } catch (error) { console.error(error); return respond( JSON.stringify({ error: "Server error" }), { status: 500, headers: { "Content-Type": "application/json" } }, ); } }, }); export default handler;