Web de Senza Paura: catálogo, taller, blog y tienda
Sitio del letrista y compositor, con SvelteKit 2 y Svelte 5.
El catálogo es contenido, no base de datos: estilos, álbumes, canciones y
capítulos son Markdown en `src/content/`, validados al compilar y compilados
dentro del bundle, así que recorrer la música no hace ni una consulta. La base
de datos guarda solo lo que generan las personas: cuentas, carritos, pedidos,
compras, valoraciones y favoritos.
Lo que hay:
- Música agrupada por estilos, con página propia para cada estilo, álbum y
tema, buscador y tabla ordenable.
- Reproductor persistente que sobrevive a la navegación. Suena un fragmento;
el archivo completo solo se obtiene comprando.
- Taller de letras: los principios del oficio y una ficha de creación por
estilo hispanoamericano.
- Blog con feed RSS, y etiquetas que cruzan blog y catálogo.
- Tienda con cuentas por código de correo, carrito, Stripe y descargas
protegidas.
- Panel de contenido en `/admin` que escribe los mismos Markdown.
- Tema oscuro de marca y su inversión clara, aplicado antes de pintar.
Lo que falta y no es código: las claves de Stripe y de correo, el tratamiento
del IVA, las páginas de condiciones y privacidad, y el material real. Las
canciones de ejemplo son inventadas y están para sustituirse; el audio son
marcadores salvo un tema.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Derivados visuales públicos. El panel y el sitio deben compartir esta raíz. */
|
|
|
|
|
export function directorioDeOndas(): string {
|
|
|
|
|
return resolve(env.ONDA_DIR ?? join('static', 'ondas'));
|
|
|
|
|
}
|
|
|
|
|
|
Web de Senza Paura: catálogo, taller, blog y tienda
Sitio del letrista y compositor, con SvelteKit 2 y Svelte 5.
El catálogo es contenido, no base de datos: estilos, álbumes, canciones y
capítulos son Markdown en `src/content/`, validados al compilar y compilados
dentro del bundle, así que recorrer la música no hace ni una consulta. La base
de datos guarda solo lo que generan las personas: cuentas, carritos, pedidos,
compras, valoraciones y favoritos.
Lo que hay:
- Música agrupada por estilos, con página propia para cada estilo, álbum y
tema, buscador y tabla ordenable.
- Reproductor persistente que sobrevive a la navegación. Suena un fragmento;
el archivo completo solo se obtiene comprando.
- Taller de letras: los principios del oficio y una ficha de creación por
estilo hispanoamericano.
- Blog con feed RSS, y etiquetas que cruzan blog y catálogo.
- Tienda con cuentas por código de correo, carrito, Stripe y descargas
protegidas.
- Panel de contenido en `/admin` que escribe los mismos Markdown.
- Tema oscuro de marca y su inversión clara, aplicado antes de pintar.
Lo que falta y no es código: las claves de Stripe y de correo, el tratamiento
del IVA, las páginas de condiciones y privacidad, y el material real. Las
canciones de ejemplo son inventadas y están para sustituirse; el audio son
marcadores salvo un tema.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function rutaDeOnda(clave: string): string | null {
|
|
|
|
|
const base = directorioDeOndas();
|
|
|
|
|
const destino = resolve(base, clave);
|
|
|
|
|
return destino === base || destino.startsWith(base + sep) ? destino : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sirve las ondas creadas después del último build.
|
|
|
|
|
*
|
|
|
|
|
* Las que ya estaban al compilar siguen saliendo como estáticos. Esta vía
|
|
|
|
|
* evita que una canción subida desde el panel necesite reconstruir la web para
|
|
|
|
|
* que aparezca su forma de onda.
|
|
|
|
|
*/
|
|
|
|
|
export async function respuestaDeOnda(clave: string): Promise<Response | null> {
|
|
|
|
|
const ruta = rutaDeOnda(clave);
|
|
|
|
|
if (!ruta || extname(ruta).toLowerCase() !== '.json') return null;
|
|
|
|
|
|
|
|
|
|
let tamano: number;
|
|
|
|
|
try {
|
|
|
|
|
const info = await stat(ruta);
|
|
|
|
|
if (!info.isFile()) return null;
|
|
|
|
|
tamano = info.size;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Response(Readable.toWeb(createReadStream(ruta)) as ReadableStream<Uint8Array>, {
|
|
|
|
|
headers: {
|
|
|
|
|
'content-type': 'application/json; charset=utf-8',
|
|
|
|
|
'content-length': String(tamano),
|
|
|
|
|
'cache-control': 'public, max-age=86400'
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<Response | null> {
|
|
|
|
|
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<string, string> = {
|
|
|
|
|
'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<Uint8Array>, {
|
|
|
|
|
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<Uint8Array>,
|
|
|
|
|
{ status: 206, headers: cabeceras }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
Web de Senza Paura: catálogo, taller, blog y tienda
Sitio del letrista y compositor, con SvelteKit 2 y Svelte 5.
El catálogo es contenido, no base de datos: estilos, álbumes, canciones y
capítulos son Markdown en `src/content/`, validados al compilar y compilados
dentro del bundle, así que recorrer la música no hace ni una consulta. La base
de datos guarda solo lo que generan las personas: cuentas, carritos, pedidos,
compras, valoraciones y favoritos.
Lo que hay:
- Música agrupada por estilos, con página propia para cada estilo, álbum y
tema, buscador y tabla ordenable.
- Reproductor persistente que sobrevive a la navegación. Suena un fragmento;
el archivo completo solo se obtiene comprando.
- Taller de letras: los principios del oficio y una ficha de creación por
estilo hispanoamericano.
- Blog con feed RSS, y etiquetas que cruzan blog y catálogo.
- Tienda con cuentas por código de correo, carrito, Stripe y descargas
protegidas.
- Panel de contenido en `/admin` que escribe los mismos Markdown.
- Tema oscuro de marca y su inversión clara, aplicado antes de pintar.
Lo que falta y no es código: las claves de Stripe y de correo, el tratamiento
del IVA, las páginas de condiciones y privacidad, y el material real. Las
canciones de ejemplo son inventadas y están para sustituirse; el audio son
marcadores salvo un tema.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
/** `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'
|
|
|
|
|
);
|
|
|
|
|
}
|