2026-01-20 09:02:26 -06:00

335 lines
9.9 KiB
TypeScript

// 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 { getKvQuiz, setKvQuizAny } from "../../../utils/QuizDataClient.ts";
import { QuizConfig } from "../../../utils/types.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<Deno.FileInfo | null> {
try {
return await Deno.stat(path);
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return null;
}
throw error;
}
}
export const handler = define.handlers({
async 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 { quizId, questionIndex, imageText, fileName, blobData } = await ctx
.req
.json();
if (
typeof quizId !== "string" || typeof questionIndex !== "number" ||
!blobData || 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 quizConfig = await getKvQuiz(
token.user_id,
quizId,
);
if (quizConfig && quizConfig.value) {
const question = quizConfig.value.questions[questionIndex];
const hashName = await hashText(question.id + ":" + fileName);
// 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 (!token.user_id) {
if (createTokenId) {
await consumeCreateToken(createTokenId);
}
}
console.log("token.user_id", token.user_id);
console.log("createStateId", createStateId);
console.log("hashName", hashName);
const signedUrl = await getLocalBlobUrl(
hashName,
"image",
token.user_id,
createStateId,
);
console.log("signedUrl", signedUrl);
console.log("imageText", imageText);
(quizConfig.value as QuizConfig).questions[questionIndex]
.background_file = imageText;
(quizConfig.value as QuizConfig).questions[questionIndex]
.background_image_data = signedUrl;
const _upsertRes = await setKvQuizAny(quizConfig);
console.log("_upsertRes", _upsertRes);
return respond(
JSON.stringify({ success: true, url: signedUrl }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} else {
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" } },
);
}
return respond(
JSON.stringify({ error: "Server error" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
},
});
export default handler;