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

331 lines
10 KiB
TypeScript

// 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;