You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
senzapaura_es/src/lib/server/medios.ts

101 lines
3.2 KiB

/**
* 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<string, string> = {
'.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'));
}
/**
* 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;
}
/** `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<Response | null> {
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<Uint8Array>;
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'
);
}

Powered by TurnKey Linux.