Fase 2 (expansion): - Francesca: +6 etapas matematicas (5-10), +7 etapas lectura (3-9), +2 ortografia - Sebastian: +8 etapas matematicas (4-11), +6 lectura, +3 historia, +3 geografia, +1 banderas - Nuevas paginas: ortografia, lectura, geografia, matematicas - ~900 ejercicios nuevos, paridad ~500 por perfil Fase 3 (bug fixes + hardening): - Fix: SessionLog auto-create en grade route - Fix: engine cache (no borrar antes de revisar) - Fix: FSRS cards actualizadas en flujo curriculum - Fix: responseMs real (no hardcodeado 3000) - Fix: options dentro de content en sebastian etapa-1 - Fix: trazo-letra consonantes crash - Security: execFileSync anti-command-injection en TTS - DB: 18 cascade deletes + indices + unique constraints - APIs debug: reset, state, seed - Docker: espeak-ng + prisma + user/group - E2E: 23 tests nuevos (curriculum-flow.spec.ts) - Code review: tailwind colors, middleware dev mode, eslint config Verificado: tsc exit 0, build exit 0, 61 E2E tests pass
58 lines
1.7 KiB
TypeScript
58 lines
1.7 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server"
|
|
import { execFileSync } from "child_process"
|
|
import { existsSync, mkdirSync } from "fs"
|
|
import { createHash } from "crypto"
|
|
import { readFile, mkdir } from "fs/promises"
|
|
import { join } from "path"
|
|
|
|
const CACHE_DIR = join(process.cwd(), "public", "tts-cache")
|
|
|
|
async function ensureCacheDir() {
|
|
if (!existsSync(CACHE_DIR)) {
|
|
await mkdir(CACHE_DIR, { recursive: true })
|
|
}
|
|
}
|
|
|
|
function getCachePath(text: string): string {
|
|
const hash = createHash("md5").update(text).digest("hex")
|
|
return join(CACHE_DIR, `${hash}.wav`)
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const text = request.nextUrl.searchParams.get("text")
|
|
if (!text || text.length > 200) {
|
|
return NextResponse.json({ error: "text param required (max 200 chars)" }, { status: 400 })
|
|
}
|
|
|
|
// Sanitize: only allow alphanumeric, spaces, and common punctuation
|
|
if (!/^[\w\sáéíóúñüÁÉÍÓÚÑÜ.,;:!¿?¡()\-]+$/.test(text)) {
|
|
return NextResponse.json({ error: "Invalid text characters" }, { status: 400 })
|
|
}
|
|
|
|
await ensureCacheDir()
|
|
const cachePath = getCachePath(text)
|
|
|
|
if (!existsSync(cachePath)) {
|
|
try {
|
|
execFileSync("espeak-ng", [
|
|
"-v", "es-mx",
|
|
"-s", "140",
|
|
"-p", "60",
|
|
"-w", cachePath,
|
|
text,
|
|
], { timeout: 10000 })
|
|
} catch {
|
|
return NextResponse.json({ error: "TTS generation failed" }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
const audioBuffer = await readFile(cachePath)
|
|
return new NextResponse(audioBuffer, {
|
|
headers: {
|
|
"Content-Type": "audio/wav",
|
|
"Content-Length": audioBuffer.length.toString(),
|
|
"Cache-Control": "public, max-age=31536000, immutable",
|
|
},
|
|
})
|
|
}
|