// routes/api/audio-blob/[id].ts import { define } from "../../../utils.ts"; import { blobStorage, getLocalBlobUrl, uploadToLocalBlobStorage, } from "../../../utils/blobStorage.ts"; import { getKvQuiz, setKvQuizAny } from "../../../utils/QuizDataClient.ts"; import { writeImageLog as _writeImageLog } from "../../../utils/logging.ts"; import { QuizConfig } from "../../../utils/types.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_AUDIO_EXTENSIONS = [".webm", ".m4a", ".mp4", ".ogg"] as const; const MIME_TYPE_BY_EXTENSION: Record< (typeof SUPPORTED_AUDIO_EXTENSIONS)[number], string > = { ".webm": "audio/webm", ".m4a": "audio/mp4", ".mp4": "audio/mp4", ".ogg": "audio/ogg", }; function normalizeAudioMimeType(mimeType: unknown): string { if (typeof mimeType !== "string") return "audio/webm"; const base = mimeType.split(";")[0]?.trim().toLowerCase(); if (base === "audio/webm" || base === "audio/mp4" || base === "audio/ogg") { return base; } return "audio/webm"; } function extensionForAudioMimeType( mimeType: string, ): (typeof SUPPORTED_AUDIO_EXTENSIONS)[number] { switch (mimeType) { case "audio/mp4": return ".m4a"; case "audio/ogg": return ".ogg"; case "audio/webm": default: return ".webm"; } } function base64ToBlob(base64: string, mimeType: string): Blob { // Decode Base64 to binary string const binaryString = atob(base64); // Convert binary string to Uint8Array const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } // Create and return Blob return new Blob([bytes], { type: mimeType }); } 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_AUDIO_EXTENSIONS.find((ext) => lowerId.endsWith(ext) ); const baseName = lowerId.indexOf(".") >= 0 && 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 requestedBlobId = requestedExtension ? `${sanitizedBaseName}${requestedExtension}` : sanitizedBaseName; 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" } }, ); } const candidateExtensions = requestedExtension ? [requestedExtension] : [...SUPPORTED_AUDIO_EXTENSIONS]; try { const noExt = await Deno.readFile( blobStorage.getFilePath(`${sanitizedBaseName}`, "audio"), ); return new Response(noExt, { status: 200, headers: { "Content-Type": "audio/webm", "Content-Length": noExt.byteLength.toString(), "Accept-Ranges": "bytes", "Cache-Control": "no-cache, no-store, must-revalidate", }, }); } catch { // File doesn't exist, continue with extension search } for (const ext of candidateExtensions) { const filePath = blobStorage.getFilePath( `${sanitizedBaseName}${ext}`, "audio", ); try { const fileContent = await Deno.readFile(filePath); const contentType = MIME_TYPE_BY_EXTENSION[ext]; return new Response(fileContent, { status: 200, headers: { "Content-Type": contentType, "Content-Length": fileContent.byteLength.toString(), "Accept-Ranges": "bytes", "Cache-Control": "no-cache, no-store, must-revalidate", }, }); } catch (error) { if (error instanceof Deno.errors.NotFound) { continue; } console.error("Error reading audio file:", error); return new Response( JSON.stringify({ error: "Failed to read audio file" }), { status: 500, headers: { "Content-Type": "application/json" }, }, ); } } return new Response( JSON.stringify({ error: "Audio 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 { const _id = decodeURIComponent(ctx.params.id); // Parse JSON body const { quizId, questionIndex, audioField, blobData, contentType } = await ctx.req.json(); // Validate input if ( typeof quizId !== "string" || typeof questionIndex !== "number" || typeof blobData !== "string" || typeof audioField !== "string" || (typeof contentType !== "undefined" && typeof contentType !== "string") ) { return respond( JSON.stringify({ error: "Invalid input data" }), { status: 400, headers: { "Content-Type": "application/json" } }, ); } // Validate audioField is either audio_file_1 or audio_file_2 if (audioField !== "audio_file_1" && audioField !== "audio_file_2") { return respond( JSON.stringify({ error: "Invalid audioField value" }), { status: 400, headers: { "Content-Type": "application/json" } }, ); } const quizConfig = await getKvQuiz( token.user_id, quizId, ); //console.log("get quiz",userId,quizId,quizConfig); if (quizConfig && quizConfig.value) { //console.log("quiz name:",quizConfig.value.quiz_name); //writeAudioLog("Recording," + quizConfig.value.quiz_name + "," + userId); const question = quizConfig.value.questions[questionIndex]; const mimeType = normalizeAudioMimeType(contentType); const extension = extensionForAudioMimeType(mimeType); // Include audioField in the hash to guarantee unique filenames for audio_file_1 vs audio_file_2 const hashName = await hashText(question.id + ":" + audioField); const blob = base64ToBlob(blobData, mimeType); const uploadResult = await uploadToLocalBlobStorage( hashName, blob, "audio", ); if (uploadResult.success) { if (!token.user_id) { if (createTokenId) { await consumeCreateToken(createTokenId); } } const signedUrl = await getLocalBlobUrl( uploadResult.fileName || hashName, "audio", token.user_id, token.state_id, ); // Dynamically set the audio field based on audioField parameter (quizConfig.value as QuizConfig) .questions[questionIndex][audioField] = signedUrl; const upsertRes = await setKvQuizAny(quizConfig); //console.log("upsert\n",upsertRes); if (!upsertRes.ok) { return respond( JSON.stringify({ error: "Failed to upsert quiz" }), { status: 500, headers: { "Content-Type": "application/json" } }, ); } return respond( JSON.stringify({ success: true, url: signedUrl }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } else { return respond( JSON.stringify({ error: "Upload failed" }), { 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;