331 lines
10 KiB
TypeScript
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;
|