/** * Entrega de los archivos de audio completos. * * Los masters no estan en `static/`: viven en un directorio privado que el * servidor web no publica. La unica forma de obtenerlos es esta, y siempre * despues de comprobar que quien pide el archivo lo ha comprado. * * Configuracion: * MEDIA_DIR directorio raiz del audio privado (por defecto ./media/audio) * * Si algun dia el catalogo se mueve a almacenamiento de objetos (S3, R2), lo * que cambia es este archivo: en vez de leer del disco, se firmaria una URL * temporal. El resto de la aplicacion no se entera. */ import { createReadStream } from 'node:fs'; import { stat } from 'node:fs/promises'; import { extname, join, resolve, sep } from 'node:path'; import { Readable } from 'node:stream'; import { env } from '$env/dynamic/private'; const TIPOS: Record = { '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.wav': 'audio/wav', '.flac': 'audio/flac' }; /** Raiz del almacen privado, como ruta absoluta. */ export function directorioDeMedios(): string { return resolve(env.MEDIA_DIR ?? join('media', 'audio')); } /** Las copias de escucha también son privadas, aunque sean de menor calidad. */ export function directorioDeEscuchas(): string { return resolve(env.ESCUCHA_DIR ?? join('media', 'escucha')); } /** * Resuelve una clave de contenido a una ruta absoluta dentro del almacen. * * Devuelve `null` si la clave se sale del directorio. La validacion del * frontmatter ya descarta las rutas raras, pero esta comprobacion no depende * de ella: es la ultima linea antes de abrir un archivo del disco. */ export function rutaDeAudio(clave: string): string | null { const base = directorioDeMedios(); const destino = resolve(base, clave); return destino === base || destino.startsWith(base + sep) ? destino : null; } export function rutaDeEscucha(clave: string): string | null { const base = directorioDeEscuchas(); const destino = resolve(base, clave); return destino === base || destino.startsWith(base + sep) ? destino : null; } /** * Transmite una escucha con soporte de `Range`, imprescindible para buscar en * la canción sin descargarla entera y para que el navegador mida su duración. */ export async function respuestaDeEscucha( clave: string, rango: string | null ): Promise { const ruta = rutaDeEscucha(clave); if (!ruta) return null; let tamano: number; try { const info = await stat(ruta); if (!info.isFile()) return null; tamano = info.size; } catch { return null; } const cabeceras: Record = { 'accept-ranges': 'bytes', 'content-type': TIPOS[extname(ruta).toLowerCase()] ?? 'application/octet-stream', 'cache-control': 'private, no-store' }; if (!rango) { cabeceras['content-length'] = String(tamano); return new Response(Readable.toWeb(createReadStream(ruta)) as ReadableStream, { headers: cabeceras }); } const pedido = /^bytes=(\d*)-(\d*)$/.exec(rango.trim()); if (!pedido || (!pedido[1] && !pedido[2])) { return new Response(null, { status: 416, headers: { ...cabeceras, 'content-range': `bytes */${tamano}` } }); } let inicio: number; let fin: number; if (!pedido[1]) { const ultimos = Number(pedido[2]); if (!Number.isSafeInteger(ultimos) || ultimos <= 0) inicio = tamano; else inicio = Math.max(0, tamano - ultimos); fin = tamano - 1; } else { inicio = Number(pedido[1]); fin = pedido[2] ? Number(pedido[2]) : tamano - 1; } if ( !Number.isSafeInteger(inicio) || !Number.isSafeInteger(fin) || inicio < 0 || inicio >= tamano || fin < inicio ) { return new Response(null, { status: 416, headers: { ...cabeceras, 'content-range': `bytes */${tamano}` } }); } fin = Math.min(fin, tamano - 1); cabeceras['content-length'] = String(fin - inicio + 1); cabeceras['content-range'] = `bytes ${inicio}-${fin}/${tamano}`; return new Response( Readable.toWeb( createReadStream(ruta, { start: inicio, end: fin }) ) as ReadableStream, { status: 206, headers: cabeceras } ); } /** `true` si el archivo existe en el almacen privado. */ /** * Respuesta de descarga del archivo completo. * * Se transmite en flujo, sin cargarlo entero en memoria, y con * `Content-Disposition: attachment` para que el navegador lo guarde en vez de * reproducirlo. `private, no-store` evita que quede cacheado en un proxy * compartido: es contenido de pago. */ export async function respuestaDeDescarga( clave: string, nombreDeArchivo: string ): Promise { const ruta = rutaDeAudio(clave); if (!ruta) return null; let tamano: number; try { const info = await stat(ruta); if (!info.isFile()) return null; tamano = info.size; } catch { return null; } const flujo = Readable.toWeb(createReadStream(ruta)) as ReadableStream; return new Response(flujo, { headers: { 'content-type': TIPOS[extname(ruta).toLowerCase()] ?? 'application/octet-stream', 'content-length': String(tamano), 'content-disposition': `attachment; filename="${nombreSeguro(nombreDeArchivo)}"; filename*=UTF-8''${encodeURIComponent(nombreDeArchivo)}`, 'cache-control': 'private, no-store' } }); } /** * Version del nombre apta para el parametro `filename` clasico: solo ASCII y * sin comillas, que romperian la cabecera. Los clientes modernos usan el * `filename*`, que sí lleva el nombre completo. */ function nombreSeguro(nombre: string): string { return ( nombre .normalize('NFD') .replace(/\p{Diacritic}/gu, '') .replace(/[^\w.\- ]+/g, '_') .trim() || 'audio' ); }