315 lines
8.9 KiB
TypeScript
315 lines
8.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 {
|
|
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({
|
|
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;
|