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.
101 lines
3.2 KiB
101 lines
3.2 KiB
|
1 month ago
|
/**
|
||
|
|
* 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'
|
||
|
|
);
|
||
|
|
}
|