El panel se va a su propio dominio y su propio proceso

Estaba mal hecho y el síntoma era el `if` sobre `/admin` que acabó metido en el
layout de la web pública: la web sabiendo que existe el panel, al revés de como
debe ser.

Ahora son dos aplicaciones del mismo repositorio. Lo que las separa son tres
líneas de `vite.config.ts`: con `APP=panel`, las rutas salen de
`src/panel/rutas`, los hooks de `src/panel/hooks.server.ts` y el resultado va a
`build-panel/`. El servidor público se compila sin las rutas del panel, así que
en `senzapaura.es` no es que `/admin` esté protegido: no existe, y hay una
prueba que lo comprueba pidiéndolo.

Comparten `src/lib` —esquema, consultas, acceso—, que es lo que evita mantener
las migraciones por duplicado. Para que eso funcione, dos cosas que resolvían
rutas del sitio salen de en medio: la navegación, a `$lib/navegacion`, y los
tipos ambientales, que ahora son dos porque el `PageData` del sitio lleva
carrito y favoritos y el del panel no.

El precio de otro dominio es que el panel necesita su propia página de entrar:
la cookie se emite para el host y no para el dominio padre, que es justo lo que
hace que la sesión del panel no viaje en las peticiones de la web. Usa el mismo
código de `$lib/server/auth`, con una diferencia: solo manda código a una cuenta
que ya administre, y contesta lo mismo exista o no. Si dijera «esa cuenta no
administra», el formulario sería una forma cómoda de averiguar cuál sí.

Y con dominio propio, el 404 de ayer pasa a ser un 403 con el motivo escrito:
esconder el panel de quien ya está en `admin.senzapaura.es` no engaña a nadie, y
a esa rama solo llega alguien que administraba y ha dejado de administrar.

Dos servidores en las pruebas, uno por proyecto, con su `baseURL`: una prueba
del panel no puede pedir por accidente una página del sitio.

Aparte, la prueba de contacto otra vez. El arreglo de ayer no valía —comprobar
que el texto está escrito no impide que lo borren un instante después—, así que
el layout marca con `data-hidratado` el momento en que Svelte toma el control y
la prueba espera a eso. Quince pasadas con cuatro trabajadores, sin un fallo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent 3981f33867
commit 34668a80e3

5
.gitignore vendored

@ -1,12 +1,17 @@
node_modules
# Output
#
# Dos de cada: el repositorio compila dos aplicaciones —la web y el panel—, y
# cada una escribe en lo suyo.
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/.svelte-kit-panel
/build
/build-panel
# OS
.DS_Store

@ -8,3 +8,8 @@ bun.lockb
# Miscellaneous
/static/
/drizzle/
# Lo que generan los dos builds. `.svelte-kit` ya lo ignora Prettier por venir
# en su lista de siempre; el del panel hay que decirlo.
/.svelte-kit-panel/
/build-panel/

@ -98,14 +98,36 @@ entrar en una cuenta sin configurar nada.
## El panel
En `/admin` está la administración del contenido. Su portada dice qué hay y qué
falta —capítulos sin publicar, géneros sin ficha, marcas del glosario sin
confirmar—, que es la pregunta que se hace quien entra.
La administración es **otra aplicación**, en `admin.senzapaura.es`. Este
repositorio compila dos:
```sh
npm run dev # la web, en 5173
npm run dev:panel # el panel, en 5175
npm run build # → build/
npm run build:panel # → build-panel/
npm run check # comprueba las dos
```
Lo que las separa está en `vite.config.ts` y son tres líneas: con `APP=panel`,
las rutas salen de `src/panel/rutas`, los hooks de `src/panel/hooks.server.ts` y
el resultado va a `build-panel/`. **El servidor público no lleva dentro ni una
línea del panel**: en `senzapaura.es` no es que `/admin` esté protegido, es que
no existe.
Comparten `src/lib`, que es donde están el esquema, las consultas y el acceso.
Con dos repositorios habría que mantener las migraciones por duplicado, y el día
que se desincronizaran nadie se enteraría hasta que el panel guardara en una
columna que ya no está.
La portada del panel dice qué hay y qué falta —capítulos sin publicar, géneros
sin ficha, marcas del glosario sin confirmar—, que es la pregunta que se hace
quien entra.
Antes vivía aquí [Sveltia CMS](https://sveltiacms.app), un editor sobre git que
escribía los Markdown de `src/content/`. Se quitó al terminar la migración a
PostgreSQL: **el sitio ya no lee esos archivos**, así que el panel seguía
guardando cambios que no salían en pantalla, y eso es peor que no tener panel.
Antes había un `/admin` dentro de la web con [Sveltia CMS](https://sveltiacms.app),
un editor sobre git que escribía los Markdown de `src/content/`. Se quitó al
terminar la migración a PostgreSQL: **el sitio ya no lee esos archivos**, así que
guardaba cambios que no salían en pantalla, y eso es peor que no tener panel.
### Quién entra
@ -129,11 +151,19 @@ un sistema de permisos para un usuario.
### Qué pasa si no eres tú
Sin sesión, a `/entrar`, con la dirección de vuelta puesta. Con sesión pero sin
el rol, **404 y no 403**: un 403 confirma que el panel existe y dónde vive, y
quien entró a comprar una canción no tiene por qué enterarse. La guarda está en
el `+layout.server.ts` de la carpeta, así que una pantalla nueva queda protegida
por el hecho de estar dentro.
El panel tiene su propia página de entrar, porque una sesión de `senzapaura.es`
no vale en `admin.senzapaura.es`: la cookie se emite para el host y no para el
dominio padre, que es justo lo que hace que la sesión del panel no viaje en
ninguna petición de la web pública. Usa el mismo código por correo de
`$lib/server/auth`, con una diferencia: **solo manda código a una cuenta que ya
administre**, y contesta lo mismo exista o no. Si dijera «esa cuenta no
administra», el formulario sería una forma cómoda de averiguar cuál sí.
Sin sesión, a `/entrar` con la dirección de vuelta puesta. Con sesión pero sin el
rol, **403 y con el motivo escrito**: ahí solo llega alguien que administraba y
ha dejado de administrar. La guarda está en el `+layout.server.ts` del grupo que
envuelve las pantallas, así que una pantalla nueva queda protegida por el hecho
de estar dentro.
## Cómo se organiza el contenido

@ -144,6 +144,48 @@ Proxy Manager, y es ahí donde hay que mirar:
journalctl -u senzapaura -n 50 --no-pager
```
## El panel, en `admin.senzapaura.es`
El panel de administración es **otra aplicación y otro proceso**, del mismo
repositorio. No cuelga de la web: el servidor público se compila sin sus rutas,
así que en `senzapaura.es` no existe ninguna dirección que lleve a él.
Se compila aparte y escribe en `build-panel/`:
```sh
npm run build # la web → build/
npm run build:panel # el panel → build-panel/
```
Lo que hay que añadir es lo mismo que para la web, una vez más:
1. **DNS**: un registro `A` para `admin`, apuntando a la misma IP.
2. **Servicio**: una unidad de systemd que arranque `build-panel/index.js` en
otro puerto —**3001**—, con su propio `.env`.
3. **Proxy Host**: `admin.senzapaura.es` → `192.168.18.171:3001`, con su
certificado, «Force SSL» y «HTTP/2», igual que el de la web.
El `.env` del panel lleva `ORIGIN=https://admin.senzapaura.es`. **No es un
detalle**: con el `ORIGIN` de la web, SvelteKit rechaza todos los formularios
del panel con un 403 y no se puede ni entrar.
`PROXIMAMENTE` **no se pone aquí**. El portón es de la web pública, y encendido
en el panel dejaría fuera al administrador justo mientras la web no está
abierta, que es cuando más falta hace entrar.
### Por qué otro dominio y no `/admin`
Porque así el servidor que da la cara a internet no lleva dentro el código del
panel. No es una puerta cerrada: es que ahí no hay puerta. Y de paso, la cookie
de sesión del panel es de `admin.senzapaura.es` y solo de ahí —no se emite para
el dominio padre—, así que no viaja en ninguna petición de la web pública.
El precio es que el panel tiene su propia página de entrar: una sesión de
`senzapaura.es` no vale aquí. Usa el mismo código de acceso por correo, con una
diferencia: **solo manda código a una cuenta que ya administre**, y contesta lo
mismo exista o no, para que el formulario no sirva para averiguar qué correo
tiene las llaves.
## El portón de «muy pronto»
Mientras la web no esté abierta al público, en el `.env` del servidor:

@ -524,15 +524,33 @@ no cambia nada en pantalla**. `$lib/content` solo lo usan los scripts.
6. **La administración.** _En curso._ Está la puerta y está la portada.
**Decidido: el panel es otra aplicación, en `admin.senzapaura.es`.** Mismo
repositorio y misma base, otro proceso y otro dominio; el servidor público se
compila sin sus rutas, así que en `senzapaura.es` no es que `/admin` esté
protegido, es que no existe. Se hizo primero como `/admin` dentro de la web y
se movió: el síntoma de que estaba mal fue que el layout de la web pública
acabó con un `if` sobre `/admin` para quitarse la cabecera.
Comparten `src/lib` —esquema, consultas y acceso—, que es lo que evita
mantener las migraciones por duplicado. Lo único que las separa son tres
líneas de `vite.config.ts`.
El precio de tener otro dominio es que el panel necesita su propia página de
entrar: la cookie se emite para el host y no para el dominio padre, y eso es
deliberado —así la sesión del panel no viaja en ninguna petición de la web—.
Usa el mismo código de `$lib/server/auth`, y solo manda código a quien ya
administra, contestando lo mismo exista o no la cuenta.
El rol vive en `usuario.rol`, con un `CHECK` que impide inventarse uno, y se
da con `npm run db:admin`. En una variable de entorno con correos habría sido
más rápido, pero entonces revocar a alguien pide un despliegue y a «quién
administra esto» solo sabe contestar quien lea la configuración del servidor.
La guarda está en el `+layout.server.ts` de `/admin` y no en cada ruta: así
una pantalla nueva queda protegida por estar dentro de la carpeta. Sin sesión
manda a entrar con la vuelta puesta; con sesión y sin el rol, **404 y no
403**, que un 403 confirma que el panel existe y dónde vive.
La guarda está en el `+layout.server.ts` del grupo que envuelve las pantallas
y no en cada ruta: así una pantalla nueva queda protegida por estar dentro.
Sin sesión manda a entrar con la vuelta puesta; con sesión y sin el rol,
**403 y con el motivo**, que ahí solo llega quien administraba y ha dejado de
administrar.
La portada contesta «qué falta» y no «cuánto hay»: cada sección trae lo que
tiene, lo que está a medias y **los nombres** de lo que está a medias. Decir
@ -580,4 +598,6 @@ opción reversible. **Cambiarlas es añadir, no rehacer.**
2. **El régimen de ventanilla única** del IVA para ventas digitales a otros
países de la UE. Trámite, no código. Stripe Tax calcula los tipos si se
activa.
3. **DNS, proxy y certificado** para `m.senzapaura.es`.
3. **DNS, proxy y certificado** para `m.senzapaura.es` y para
`admin.senzapaura.es`, más el servicio del panel en el puerto 3001. Está
escrito en `despliegue/con-proxy-manager.md`.

@ -1,40 +0,0 @@
import { expect, test } from '@playwright/test';
/*
* El panel, desde fuera.
*
* Aquí solo se comprueba la puerta: que sin sesión no se ve nada y que la
* dirección de vuelta se conserva. Quién puede pasar una vez dentro lo deciden
* `esAdmin` y `exigirAdmin`, y eso se prueba en `administracion.spec.ts`, donde
* se pueden montar las tres situaciones sin necesitar un buzón de correo.
*/
test.describe('El panel', () => {
test('sin sesión no se ve, se manda a entrar', async ({ page }) => {
await page.goto('/admin');
await expect(page).toHaveURL(/\/entrar/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
test('y se vuelve a donde se iba, no a la portada', async ({ page }) => {
await page.goto('/admin?ver=pendientes');
const url = new URL(page.url());
expect(url.pathname).toBe('/entrar');
expect(url.searchParams.get('volverA')).toBe('/admin?ver=pendientes');
});
test('el panel no es contenido del sitio, y robots lo dice', async ({ request }) => {
const robots = await request.get('/robots.txt');
expect(await robots.text()).toContain('Disallow: /admin');
});
test('no hay ningún enlace al panel en la web pública', async ({ page }) => {
// No es seguridad —la guarda es el servidor—, es que el panel no forma
// parte de la navegación de nadie que venga a leer o a comprar.
for (const ruta of ['/', '/musica', '/generos', '/blog']) {
await page.goto(ruta);
await expect(page.locator('a[href^="/admin"]')).toHaveCount(0);
}
});
});

@ -1,23 +1,21 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { expect, test, type Page } from '@playwright/test';
/**
* Escribe en un campo y comprueba que se ha quedado escrito.
* Abre el formulario y espera a que la página sea interactiva.
*
* El bucle no es paranoia. `page.goto` vuelve cuando la página ha cargado, no
* cuando SvelteKit la ha hidratado, y al hidratar, Svelte reescribe el valor de
* cada `input` con el del componente, que está vacío. Lo que se teclee antes de
* ese instante desaparece.
* La espera no es adorno. `page.goto` vuelve cuando la página ha cargado, no
* cuando Svelte ha tomado el control, y al tomarlo reescribe el valor de cada
* `input` con el del componente, que está vacío: lo que se teclee antes de ese
* instante desaparece. Fallaba solo con la máquina cargada, así que se caía en
* la tanda completa y pasaba al repetir la prueba a solas.
*
* Solo le pasaba al primer campo —los demás se rellenan milisegundos después,
* ya hidratada— y solo con la máquina cargada, así que fallaba en la tanda
* completa y pasaba al repetir la prueba a solas. Es la peor clase de fallo:
* el que parece un fantasma.
* Un reintento no lo arregla —comprobar que el texto está puesto no impide que
* lo borren un instante después—, así que el layout marca el momento con
* `data-hidratado` y aquí se espera a él.
*/
async function escribir(campo: Locator, valor: string) {
await expect(async () => {
await campo.fill(valor);
await expect(campo).toHaveValue(valor, { timeout: 500 });
}).toPass({ timeout: 10_000 });
async function abrirFormulario(page: Page) {
await page.goto('/contacto');
await page.locator('html[data-hidratado]').waitFor();
}
/** Rellena el formulario con datos válidos, salvo lo que se sobrescriba. */
@ -25,18 +23,17 @@ async function rellenar(
page: Page,
campos: Partial<{ nombre: string; email: string; motivo: string; mensaje: string }> = {}
) {
await escribir(page.getByLabel('Nombre'), campos.nombre ?? 'Ana Ruiz');
await escribir(page.getByLabel('Correo electrónico'), campos.email ?? 'ana@example.com');
await page.getByLabel('Nombre').fill(campos.nombre ?? 'Ana Ruiz');
await page.getByLabel('Correo electrónico').fill(campos.email ?? 'ana@example.com');
await page.getByLabel('Motivo').selectOption(campos.motivo ?? 'colaboracion');
await escribir(
page.getByLabel('Mensaje'),
campos.mensaje ?? 'Me gustaría hablar contigo sobre una colaboración para un disco.'
);
await page
.getByLabel('Mensaje')
.fill(campos.mensaje ?? 'Me gustaría hablar contigo sobre una colaboración para un disco.');
}
test.describe('Formulario de contacto', () => {
test('la validación del servidor devuelve los errores por campo', async ({ page }) => {
await page.goto('/contacto');
await abrirFormulario(page);
// "A" y "Hola" pasan la validación del navegador y fallan en el servidor,
// que es justo lo que interesa comprobar aquí.
await rellenar(page, { nombre: 'A', mensaje: 'Hola' });
@ -47,7 +44,7 @@ test.describe('Formulario de contacto', () => {
});
test('lo escrito no se pierde cuando la validación falla', async ({ page }) => {
await page.goto('/contacto');
await abrirFormulario(page);
await rellenar(page, { nombre: 'A' });
await page.getByRole('button', { name: 'Enviar mensaje' }).click();
@ -58,7 +55,7 @@ test.describe('Formulario de contacto', () => {
});
test('el campo trampa descarta los envíos automáticos', async ({ page }) => {
await page.goto('/contacto');
await abrirFormulario(page);
await rellenar(page);
// Solo un cliente automático rellena este campo: está fuera de la vista
@ -73,7 +70,7 @@ test.describe('Formulario de contacto', () => {
});
test('un envío válido no muestra errores de campo', async ({ page }) => {
await page.goto('/contacto');
await abrirFormulario(page);
await rellenar(page);
await page.getByRole('button', { name: 'Enviar mensaje' }).click();

@ -257,3 +257,28 @@ test.describe('Fondo animado de la portada', () => {
await expect(page.locator('canvas')).toHaveCount(1);
});
});
test.describe('El panel no está en la web pública', () => {
/*
* Vive en otro dominio y en otro proceso. El servidor público se compila sin
* sus rutas, así que esto no comprueba que estén escondidas: comprueba que
* aquí no existen. Es la diferencia entre una puerta cerrada y una pared.
*/
test('«/admin» no es una dirección de este sitio', async ({ request }) => {
const respuesta = await request.get('/admin', { maxRedirects: 0 });
expect(respuesta.status()).toBe(404);
});
test('ni robots.txt lo nombra', async ({ request }) => {
// Anunciarlo en robots sería repartir la dirección del panel a quien pase.
const robots = await request.get('/robots.txt');
expect(await robots.text()).not.toContain('/admin');
});
test('ni hay un solo enlace a él', async ({ page }) => {
for (const ruta of ['/', '/musica', '/generos', '/blog', '/cuenta']) {
await page.goto(ruta);
await expect(page.locator('a[href*="/admin"]')).toHaveCount(0);
}
});
});

@ -0,0 +1,54 @@
import { expect, test } from '@playwright/test';
/*
* El panel, desde fuera.
*
* Corre contra su propio servidor —el proyecto «panel» de la configuración—,
* que es otra aplicación compilada aparte. Aquí solo se comprueba la puerta:
* quién puede pasar una vez dentro lo deciden `esAdmin` y `exigirAdmin`, y eso
* se prueba en `administracion.spec.ts`, donde se pueden montar las tres
* situaciones sin necesitar un buzón de correo.
*/
test.describe('El panel', () => {
test('sin sesión no se ve, se manda a entrar', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/entrar/);
await expect(page.getByRole('heading', { name: 'Panel' })).toBeVisible();
await expect(page.getByLabel('Correo')).toBeVisible();
});
test('y se vuelve a donde se iba, no a la portada', async ({ page }) => {
await page.goto('/?ver=pendientes');
const url = new URL(page.url());
expect(url.pathname).toBe('/entrar');
expect(url.searchParams.get('volverA')).toBe('/?ver=pendientes');
});
test('no dice qué cuenta administra', async ({ page }) => {
// La respuesta es la misma exista o no la cuenta. Si dijera «esa cuenta no
// administra», el formulario sería una forma cómoda de averiguar cuál sí.
await page.goto('/entrar');
await page.getByLabel('Correo').fill('nadie-de-aqui@example.com');
await page.getByRole('button', { name: 'Mandar el código' }).click();
await expect(page.getByText(/si esa cuenta administra/i)).toBeVisible();
await expect(page.getByLabel('Código')).toBeVisible();
});
test('nada de aquí se indexa', async ({ request }) => {
const robots = await request.get('/robots.txt');
expect(await robots.text()).toContain('Disallow: /');
});
test('no lleva la navegación del sitio', async ({ page }) => {
// Es una herramienta, no una página de la web: sin cabecera de secciones,
// sin reproductor y sin pie.
await page.goto('/entrar');
await expect(page.getByRole('navigation', { name: 'Principal' })).toHaveCount(0);
await expect(page.getByRole('region', { name: 'Reproductor' })).toHaveCount(0);
await expect(page.locator('footer')).toHaveCount(0);
});
});

@ -33,6 +33,23 @@ export default defineConfig(
}
}
},
{
/*
* El panel es la otra aplicación del repositorio y no está en el
* `tsconfig.json` de la web, así que el servicio de proyectos no encontraba
* sus componentes y fallaba al leerlos. Se le dice cuál es el suyo.
*/
files: ['src/panel/**/*.svelte', 'src/panel/**/*.ts'],
languageOptions: {
parserOptions: {
projectService: false,
project: './tsconfig.panel.json',
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.svelte'],
parser: ts.parser
}
}
},
{
// Override or add rule settings here, such as:
// 'svelte/button-has-type': 'error'

@ -7,14 +7,19 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"dev:panel": "node scripts/como-panel.mjs npx vite dev --port 5175",
"build:panel": "node scripts/como-panel.mjs npx vite build",
"preview:panel": "node scripts/como-panel.mjs npx vite preview --port 4174",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check": "npm run check:sitio && npm run check:panel",
"check:sitio": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:panel": "node scripts/como-panel.mjs npx svelte-kit sync && node scripts/como-panel.mjs npx svelte-check --tsconfig ./tsconfig.panel.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright install && node scripts/construir.mjs && playwright test",
"test:e2e": "playwright install && node scripts/construir.mjs && node scripts/construir.mjs --panel && playwright test",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "node --env-file=.env scripts/db/migrar.mjs",

@ -1,25 +1,53 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'e2e',
/**
* Dos servidores y dos proyectos, porque son dos aplicaciones.
*
* El sitio en 4173 y el panel en 4174. Cada proyecto pone su `baseURL`, así que
* una prueba del panel no puede pedir por accidente una página del sitio ni al
* revés: es la misma separación que en producción, donde son dos dominios.
*/
const comun = {
/*
* WebGL por software. El Chromium de las pruebas no trae aceleración, y sin
* esto el fondo animado de la portada no se podría comprobar: solo se vería
* su reserva, que es justo lo contrario de lo que hay que probar.
*/
use: {
launchOptions: {
args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader']
}
},
};
export default defineConfig({
testDir: 'e2e',
testMatch: '**/*.e2e.{ts,js}',
webServer: {
// El build va aparte, en `npm run test:e2e`: en Windows el empaquetador
projects: [
{
name: 'sitio',
testIgnore: '**/panel.e2e.ts',
use: { ...comun, baseURL: 'http://localhost:4173' }
},
{
name: 'panel',
testMatch: '**/panel.e2e.ts',
use: { ...comun, baseURL: 'http://localhost:4174' }
}
],
webServer: [
{
// Los builds van aparte, en `npm run test:e2e`: en Windows el empaquetador
// sale con SIGSEGV de forma intermitente aunque el bundle esté bien, y
// eso tumbaba el arranque del servidor. Lo explica scripts/construir.mjs.
command: 'npm run preview',
port: 4173,
timeout: 180_000,
reuseExistingServer: !process.env.CI
},
{
command: 'npm run preview:panel',
port: 4174,
timeout: 180_000,
reuseExistingServer: !process.env.CI
}
]
});

@ -0,0 +1,27 @@
/**
* Ejecuta un comando con `APP=panel`.
*
* node scripts/como-panel.mjs npx vite build
*
* En Windows los scripts de npm pasan por cmd, y ahí `APP=panel comando` no
* significa nada: cmd se lo toma como el nombre del programa y falla. Esto pone
* la variable en el entorno y lanza lo que se le pida, sin traer una
* dependencia para tres líneas.
*/
import { spawnSync } from 'node:child_process';
const orden = process.argv.slice(2);
if (orden.length === 0) {
console.error('Falta el comando. Ejemplo: node scripts/como-panel.mjs npx vite build');
process.exit(1);
}
const resultado = spawnSync(orden[0], orden.slice(1), {
stdio: 'inherit',
shell: process.platform === 'win32',
env: { ...process.env, APP: 'panel' }
});
process.exit(resultado.status ?? 1);

@ -25,24 +25,38 @@ import { spawnSync } from 'node:child_process';
import { readdirSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
/**
* Con `--panel` compila la administración en vez del sitio. Son dos
* aplicaciones del mismo repositorio, cada una con su carpeta de salida, y las
* pruebas necesitan las dos.
*/
const PANEL = process.argv.includes('--panel');
const SALIDA = PANEL ? 'build-panel' : 'build';
const GENERADO = PANEL ? '.svelte-kit-panel' : '.svelte-kit';
/**
* Archivos que adapter-node deja siempre en un build correcto.
*
* No está `build/server/index.js`: el adaptador no genera ese archivo. El
* servidor sale troceado en `build/server/chunks/`, y quien lo arranca es
* `build/index.js`. Comprobarlo costó un rato de creer que el build fallaba
* cuando lo que fallaba era esta lista.
* No está `server/index.js`: el adaptador no genera ese archivo. El servidor
* sale troceado en `server/chunks/`, y quien lo arranca es `index.js`.
* Comprobarlo costó un rato de creer que el build fallaba cuando lo que fallaba
* era esta lista.
*/
const ARTEFACTOS = ['build/index.js', 'build/handler.js'];
const ARTEFACTOS = [`${SALIDA}/index.js`, `${SALIDA}/handler.js`];
/** Directorios que tienen que haber quedado con algo dentro. */
const DIRECTORIOS = ['build/client', 'build/server/chunks', '.svelte-kit/output/client/_app'];
const DIRECTORIOS = [
`${SALIDA}/client`,
`${SALIDA}/server/chunks`,
`${GENERADO}/output/client/_app`
];
const inicio = Date.now();
const resultado = spawnSync('npx', ['vite', 'build'], {
stdio: 'inherit',
shell: process.platform === 'win32'
shell: process.platform === 'win32',
env: PANEL ? { ...process.env, APP: 'panel' } : process.env
});
if (resultado.status === 0) process.exit(0);

@ -5,7 +5,8 @@
import { resolve } from '$app/paths';
import BotonTema from './BotonTema.svelte';
import Icono from './Icono.svelte';
import { navegacion, site } from '$lib/site';
import { site } from '$lib/site';
import { navegacion } from '$lib/navegacion';
let menuAbierto = $state(false);
let cabecera = $state<HTMLElement | null>(null);

@ -0,0 +1,22 @@
import { resolve } from '$app/paths';
import type { ResolvedPathname } from '$app/types';
/**
* Navegacion principal. El orden es el que se pinta en la cabecera.
*
* Las rutas se resuelven aqui, una a una, y no en la cabecera: `resolve()`
* tiene una sobrecarga por ruta y no acepta una union de todas, asi que
* pasarle `enlace.href` deja de compilar en cuanto el sitio crece.
*
* Esta en su archivo y no en `$lib/site` porque `resolve()` se comprueba contra
* el mapa de rutas de la aplicacion que compila, y `$lib/site` lo importan las
* dos —el sitio y el panel—. Estas rutas solo existen en una.
*/
export const navegacion: { href: ResolvedPathname; texto: string }[] = [
{ href: resolve('/musica'), texto: 'Música' },
{ href: resolve('/taller'), texto: 'Taller de letras' },
{ href: resolve('/generos'), texto: 'Géneros' },
{ href: resolve('/blog'), texto: 'Blog' },
{ href: resolve('/bio'), texto: 'Bio' },
{ href: resolve('/contacto'), texto: 'Contacto' }
];

@ -37,26 +37,28 @@ describe('exigirAdmin', () => {
// Quien administra también se queda sin sesión de vez en cuando. Volver a
// la portada le obligaría a buscar otra vez dónde estaba.
try {
exigirAdmin({ usuario: null }, en('/admin/glosario?sin=confirmar'));
exigirAdmin({ usuario: null }, en('/glosario?sin=confirmar'));
expect.unreachable('tenía que redirigir');
} catch (salida) {
expect(isRedirect(salida)).toBe(true);
if (!isRedirect(salida)) return;
expect(salida.status).toBe(303);
expect(salida.location).toBe('/entrar?volverA=%2Fadmin%2Fglosario%3Fsin%3Dconfirmar');
expect(salida.location).toBe('/entrar?volverA=%2Fglosario%3Fsin%3Dconfirmar');
}
});
it('con sesión pero sin el rol, 404 y no 403', () => {
// Un 403 confirma que el panel existe y en qué dirección vive. Quien entró
// a comprar una canción no tiene por qué enterarse de eso.
it('con sesión pero sin el rol, 403 y con el motivo', () => {
// Aquí solo llega quien administraba y ha dejado de administrar: el acceso
// de este dominio solo manda códigos a administradores. Un 404 le dejaría
// creyendo que la página no existe.
try {
exigirAdmin({ usuario: cuenta('persona') }, en('/admin'));
exigirAdmin({ usuario: cuenta('persona') }, en('/'));
expect.unreachable('tenía que cortar');
} catch (salida) {
expect(isHttpError(salida)).toBe(true);
if (!isHttpError(salida)) return;
expect(salida.status).toBe(404);
expect(salida.status).toBe(403);
expect(salida.body.message).toMatch(/no administra/i);
}
});
});

@ -1,10 +1,15 @@
/**
* La puerta del panel.
*
* Una sola función, y se llama desde el `+layout.server.ts` de `/admin`: en
* SvelteKit el layout de una carpeta corre antes que cualquier página de dentro,
* así que la comprobación no se puede olvidar al añadir una pantalla nueva. Una
* guarda que hay que acordarse de copiar en cada ruta acaba faltando en una.
* Una sola función, y se llama desde el `+layout.server.ts` del grupo que
* envuelve a todas las pantallas: en SvelteKit el layout corre antes que
* cualquier página de dentro, así que una pantalla nueva queda comprobada por
* el hecho de estar ahí. Una guarda que hay que acordarse de copiar en cada
* ruta acaba faltando en una.
*
* Vive en `$lib` y no en `src/panel` porque `$lib` es lo que comparten las dos
* aplicaciones, y `esAdmin` también lo usa el acceso del panel para decidir a
* quién manda un código.
*/
import { error, redirect } from '@sveltejs/kit';
@ -21,10 +26,13 @@ export function esAdmin(quien: Usuario | null | undefined): boolean {
* administra también se queda sin sesión de vez en cuando, y mandarlo a la
* portada le obligaría a volver a buscar dónde estaba.
*
* Con sesión pero sin el rol, **404 y no 403**. Un 403 confirma que el panel
* existe y en qué dirección vive, que es justo lo que no tiene por qué saber
* quien entró a comprar una canción. La diferencia entre «no puedes» y «no hay
* nada» no le cambia la vida a nadie legítimo y sí a quien va probando.
* Con sesión pero sin el rol, **403 y con el motivo escrito**. Cuando el panel
* colgaba de la web pública esto era un 404, para no confirmarle a quien entró
* a comprar una canción que existía un panel y en qué dirección. Desde que vive
* en su propio dominio esa discreción no engaña a nadie: quien está aquí ya
* sabe dónde está. Y el único que puede llegar a esta rama es alguien que
* administraba y ha dejado de administrar —el acceso de este dominio solo manda
* códigos a administradores—, así que decírselo es lo útil.
*/
export function exigirAdmin(locals: App.Locals, url: URL): Usuario {
if (!locals.usuario) {
@ -32,7 +40,7 @@ export function exigirAdmin(locals: App.Locals, url: URL): Usuario {
redirect(303, `/entrar?volverA=${volverA}`);
}
if (!esAdmin(locals.usuario)) error(404, 'No hay nada en esa dirección.');
if (!esAdmin(locals.usuario)) error(403, 'Esta cuenta ya no administra el sitio.');
return locals.usuario;
}

@ -1,11 +1,14 @@
import { resolve } from '$app/paths';
import type { ResolvedPathname } from '$app/types';
/**
* Datos globales del sitio.
*
* Es el unico sitio donde tocar nombres, correos y redes: nada de esto
* deberia estar repetido en los componentes.
*
* Aqui no se resuelve ninguna ruta, y por eso la navegacion vive aparte, en
* `$lib/navegacion`. `resolve()` se comprueba contra el mapa de rutas de la
* aplicacion que compila, y este archivo lo importan las dos: el sitio y el
* panel, que necesita `site.url` para enlazar a la web desde su dominio. Con la
* navegacion dentro, el panel no compilaba.
*/
export const site = {
nombre: 'Senza Paura',
@ -83,22 +86,6 @@ export const heroe = {
poster: { oscuro: '/video/heroe.jpg', claro: '/video/heroe-claro.jpg' }
} as const;
/**
* Navegacion principal. El orden es el que se pinta en la cabecera.
*
* Las rutas se resuelven aqui, una a una, y no en la cabecera: `resolve()`
* tiene una sobrecarga por ruta y no acepta una union de todas, asi que
* pasarle `enlace.href` deja de compilar en cuanto el sitio crece.
*/
export const navegacion: { href: ResolvedPathname; texto: string }[] = [
{ href: resolve('/musica'), texto: 'Música' },
{ href: resolve('/taller'), texto: 'Taller de letras' },
{ href: resolve('/generos'), texto: 'Géneros' },
{ href: resolve('/blog'), texto: 'Blog' },
{ href: resolve('/bio'), texto: 'Bio' },
{ href: resolve('/contacto'), texto: 'Contacto' }
];
/** Etiquetas visibles de cada plataforma de escucha. */
export const etiquetasPlataforma: Record<string, string> = {
spotify: 'Spotify',

25
src/panel/app.d.ts vendored

@ -0,0 +1,25 @@
import type { Usuario } from '$lib/server/db/schema';
/**
* Los tipos ambientales del panel.
*
* Hay dos archivos y no uno porque hay dos aplicaciones. `App.Locals` coincide
* —las dos resuelven la sesión igual—, pero `App.PageData` no: la del sitio
* lleva el carrito, las compras y los favoritos, que es lo que baja su layout
* raíz, y aquí lo único que baja de arriba es quién administra.
*/
declare global {
namespace App {
interface Locals {
/** Cuenta de la sesión actual, o `null` si no ha entrado nadie. */
usuario: Usuario | null;
}
interface PageData {
/** Quién administra, puesto por el layout del grupo con la guarda. */
admin?: { email: string };
}
}
}
export {};

@ -0,0 +1,38 @@
import type { Handle } from '@sveltejs/kit';
import {
borrarCookieSesion,
COOKIE_SESION,
establecerCookieSesion,
validarSesion
} from '$lib/server/auth';
/**
* Los hooks del panel.
*
* Solo la sesión. Los de la web pública —el portón de «muy pronto» y las
* redirecciones de las direcciones que se mudaron— no pintan nada aquí: el
* portón dejaría al administrador fuera justo mientras la web no está abierta,
* que es cuando más falta hace entrar, y aquí no hay ninguna dirección vieja
* que redirigir porque el panel no ha estado publicado nunca.
*
* Es el mismo código de sesión que el sitio, importado de `$lib`, no copiado.
* La cookie, en cambio, es de este dominio y solo de este: no se le pone
* `domain`, así que la que se emite en `admin.senzapaura.es` no viaja a
* `senzapaura.es` ni al revés.
*/
export const handle: Handle = async ({ event, resolve }) => {
event.locals.usuario = null;
const token = event.cookies.get(COOKIE_SESION);
if (token) {
const abierta = await validarSesion(token);
if (abierta) {
event.locals.usuario = abierta.usuario;
establecerCookieSesion(event.cookies, token, abierta.expiraEn);
} else {
borrarCookieSesion(event.cookies);
}
}
return resolve(event);
};

@ -1,29 +1,35 @@
<script lang="ts">
/**
* El panel.
* Lo que envuelve al panel una vez dentro.
*
* Deliberadamente sobrio y distinto de la web: aquí no se viene a mirar, se
* viene a arreglar algo concreto. Lleva la misma paleta —es el mismo sitio—
* pero sin la cabecera de navegación, sin el reproductor y sin el pie: nada
* de eso ayuda a corregir una ficha, y todo ello ocupa la pantalla.
* Deliberadamente sobrio: aquí no se viene a mirar, se viene a arreglar algo
* concreto. Lleva la paleta del sitio —es la misma casa— y nada de su
* navegación: el menú de Música y Blog no ayuda a corregir una ficha.
*
* La barra dice siempre quién eres. En una herramienta que edita lo que se
* publica, saber con qué cuenta estás no es un adorno.
*/
import { resolve } from '$app/paths';
import { site } from '$lib/site';
let { data, children } = $props();
</script>
<div class="panel">
<header class="barra">
<a class="marca" href={resolve('/admin')}>
<a class="marca" href={resolve('/')}>
Panel<span class="sitio">Senza Paura</span>
</a>
<div class="quien">
<span class="correo">{data.admin.email}</span>
<a href={resolve('/')}>Ver el sitio</a>
<!--
El sitio está en otro dominio, así que el enlace es absoluto y sale de
`site.url`. La regla que pide `resolve()` es para las rutas de esta
aplicación; aquí se va fuera a propósito.
-->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={site.url}>Ver el sitio</a>
<form method="POST" action={resolve('/salir')}>
<button type="submit">Salir</button>
</form>

@ -7,14 +7,19 @@
* tarjetas, esos números quedan a distinta altura y hay que ir buscándolos.
* Alineados en columna se leen de arriba abajo de una pasada.
*/
import Meta from '$lib/components/Meta.svelte';
let { data } = $props();
const pendientes = $derived(data.secciones.filter((s) => s.pendiente).length);
</script>
<Meta titulo="Panel" descripcion="Administración del contenido." sinIndexar />
<!--
Sin el componente `Meta` del sitio: compone canónica y Open Graph contra
`senzapaura.es`, que aquí es otro dominio, y el panel no se comparte en
ninguna parte. El `noindex` lo pone el layout raíz, para todas las páginas.
-->
<svelte:head>
<title>Panel · Senza Paura</title>
</svelte:head>
<header>
<h1>Panel</h1>

@ -0,0 +1,32 @@
<script lang="ts">
/**
* La raíz del panel.
*
* Solo la hoja de estilos y el icono. Todo lo que envuelve al contenido —la
* barra, quién eres, el enlace de salir— vive en el grupo `(dentro)`, que es
* el que además comprueba el permiso: la página de entrar no puede llevar una
* barra que diga quién eres antes de saberlo.
*
* La paleta es la misma del sitio a propósito. Es la misma casa, aunque sea
* otro dominio y otro proceso.
*/
import '../../app.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<!-- El panel no es contenido de nadie: que no acabe en ningún índice. -->
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
{@render children()}
<style>
:global(body) {
background: var(--c-bg);
color: var(--c-text);
}
</style>

@ -0,0 +1,177 @@
import { fail, redirect } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import {
crearCodigoDeAcceso,
crearSesion,
establecerCookieSesion,
normalizarEmail,
verificarCodigo
} from '$lib/server/auth';
import { db } from '$lib/server/db';
import { usuario } from '$lib/server/db/schema';
import { enviarCodigoDeAcceso } from '$lib/server/correo';
import { claveDeCorreo } from '$lib/server/correo-clave';
import { comprobarBarreras, enMinutos, limpiarCaducados } from '$lib/server/limite';
import { destinoSeguro } from '$lib/server/destino';
import { esAdmin } from '$lib/server/administracion';
import { esEmail } from '$lib/validacion';
import type { Actions } from './$types';
/**
* Entrar al panel.
*
* Es una página propia y no la del sitio porque el panel vive en otro dominio,
* y una cookie de sesión no cruza de `senzapaura.es` a `admin.senzapaura.es`
* salvo que se emita para el dominio padre. Emitirla así habría sido menos
* trabajo y peor: la sesión del panel viajaría en cada petición de la web
* pública. Aquí la cookie es de este host y solo de este.
*
* El código que hace el trabajo —emitir, verificar, abrir sesión— es el mismo
* de `$lib/server/auth`, importado. Lo único propio es a quién se le manda.
*/
/** Lo que se contesta siempre, se haya mandado código o no. */
const ENVIADO = 'Si esa cuenta administra el sitio, le hemos mandado un código.';
export function load({ locals, url }) {
if (esAdmin(locals.usuario)) redirect(303, destinoSeguro(url.searchParams.get('volverA'), '/'));
const email = normalizarEmail(url.searchParams.get('email') ?? '');
const reanudar = url.searchParams.get('paso') === 'codigo' && esEmail(email);
return {
volverA: destinoSeguro(url.searchParams.get('volverA'), '/'),
paso: reanudar ? ('codigo' as const) : ('email' as const),
email: reanudar ? email : ''
};
}
export const actions: Actions = {
/** Paso 1: pedir el código. */
solicitar: async ({ request, getClientAddress }) => {
const datos = await request.formData();
const email = normalizarEmail(String(datos.get('email') ?? ''));
const volverA = destinoSeguro(datos.get('volverA'), '/');
if (!esEmail(email)) {
return fail(400, { paso: 'email' as const, email, error: 'Ese correo no parece válido.' });
}
/*
* Los mismos frenos que el acceso del sitio. Aquí solo pueden entrar dos o
* tres personas, así que un límite bajo no molesta a nadie legítimo, y sin
* él este formulario sería un botón para mandar correo a quien sea desde
* nuestro dominio.
*/
const destino = claveDeCorreo(email);
const barrera = await comprobarBarreras([
{
clave: `panel:espera:${destino}`,
maximo: 1,
ventanaMs: 60 * 1000,
mensaje: () => 'Te hemos mandado un código hace un momento. Revisa el correo.'
},
{
clave: `panel:correo-hora:${destino}`,
maximo: 5,
ventanaMs: 60 * 60 * 1000,
mensaje: (espera) =>
`Demasiados códigos seguidos. Inténtalo en ${enMinutos(espera)} minutos.`
},
{
clave: `panel:ip:${getClientAddress()}`,
maximo: 5,
ventanaMs: 15 * 60 * 1000,
mensaje: (espera) =>
`Demasiados códigos seguidos. Inténtalo en ${enMinutos(espera)} minutos.`
}
]);
if (!barrera.permitido) {
return fail(429, { paso: 'email' as const, email, error: barrera.mensaje });
}
/*
* Solo se manda código a quien ya administra.
*
* Y la respuesta es la misma se mande o no. Si dijera «esa cuenta no
* administra», este formulario sería una forma cómoda de averiguar qué
* correo tiene las llaves del sitio, que es la mitad del trabajo de quien
* quiere entrar.
*
* Además, sin esta comprobación la verificación crearía la cuenta al
* canjear el código, que es lo que hace el acceso del sitio: aquí eso
* significaría dar de alta usuarios desde el panel.
*/
const [cuenta] = await db.select().from(usuario).where(eq(usuario.email, email)).limit(1);
if (esAdmin(cuenta)) {
try {
await enviarCodigoDeAcceso(email, await crearCodigoDeAcceso(email));
} catch (causa) {
console.error('[panel] No se ha podido enviar el código', causa);
return fail(502, {
paso: 'email' as const,
email,
error: 'No hemos podido mandar el código. Inténtalo en un momento.'
});
}
await limpiarCaducados();
}
return { paso: 'codigo' as const, email, volverA, aviso: ENVIADO };
},
/** Paso 2: canjear el código por una sesión. */
verificar: async ({ request, cookies }) => {
const datos = await request.formData();
const email = normalizarEmail(String(datos.get('email') ?? ''));
const codigo = String(datos.get('codigo') ?? '').trim();
const volverA = destinoSeguro(datos.get('volverA'), '/');
if (!/^\d{6}$/.test(codigo)) {
return fail(400, {
paso: 'codigo' as const,
email,
volverA,
error: 'El código son seis dígitos.'
});
}
const resultado = await verificarCodigo(email, codigo);
if (!resultado.ok) {
const mensajes = {
caducado: 'El código ha caducado o ya se ha usado. Pide uno nuevo.',
incorrecto: 'Ese código no es correcto.',
agotado: 'Demasiados intentos. Pide un código nuevo.'
};
return fail(400, {
paso: resultado.motivo === 'incorrecto' ? ('codigo' as const) : ('email' as const),
email,
volverA,
error: mensajes[resultado.motivo]
});
}
/*
* Cinturón y tirantes: el código solo se manda a quien administra, así que
* llegar aquí sin el rol pide haber acertado seis dígitos de un código que
* nadie pidió. Aun así se comprueba, porque el día que alguien cambie lo
* de arriba esta línea es la que sigue impidiendo abrir la sesión.
*/
if (!esAdmin(resultado.usuario)) {
return fail(403, {
paso: 'email' as const,
email,
volverA,
error: 'Esa cuenta no administra el sitio.'
});
}
const sesion = await crearSesion(resultado.usuario.id);
establecerCookieSesion(cookies, sesion.token, sesion.expiraEn);
redirect(303, volverA);
}
};

@ -0,0 +1,175 @@
<script lang="ts">
/**
* Entrar al panel.
*
* Un formulario centrado y poco más. La página de acceso del sitio tiene su
* lámina y su tipografía grande porque es la primera cosa que ve alguien que
* llega a comprar; esta la ve una persona que ya sabe a qué viene.
*/
import { enhance } from '$app/forms';
let { data, form } = $props();
const paso = $derived(form?.paso ?? data.paso);
const email = $derived(form?.email ?? data.email);
const volverA = $derived(form?.volverA ?? data.volverA);
/*
* En el marcado, `{` abre una expresión de Svelte, así que el patrón del
* campo se declara aquí en vez de pelearse con el escapado. Lo mismo hace el
* formulario de acceso del sitio.
*/
const PATRON_CODIGO = '[0-9]{6}';
</script>
<svelte:head>
<title>Entrar · Panel</title>
</svelte:head>
<main>
<h1>Panel</h1>
{#if paso === 'email'}
<p class="explica">
Escribe el correo de la cuenta que administra el sitio. Te mandamos un código de un solo uso.
</p>
<form method="POST" action="?/solicitar" use:enhance>
<input type="hidden" name="volverA" value={volverA} />
<label for="email">Correo</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
value={email}
aria-describedby={form?.error ? 'fallo' : undefined}
/>
<button type="submit" class="btn btn--primary">Mandar el código</button>
</form>
{:else}
<p class="explica">
{form?.aviso ?? 'Si esa cuenta administra el sitio, le hemos mandado un código.'}
</p>
<form method="POST" action="?/verificar" use:enhance>
<input type="hidden" name="volverA" value={volverA} />
<input type="hidden" name="email" value={email} />
<label for="codigo">Código</label>
<input
id="codigo"
name="codigo"
inputmode="numeric"
autocomplete="one-time-code"
pattern={PATRON_CODIGO}
maxlength="6"
required
aria-describedby={form?.error ? 'fallo' : undefined}
/>
<button type="submit" class="btn btn--primary">Entrar</button>
</form>
<form method="POST" action="?/solicitar" use:enhance class="otra">
<input type="hidden" name="volverA" value={volverA} />
<input type="hidden" name="email" value={email} />
<button type="submit" class="enlace">Usar otro correo o pedir otro código</button>
</form>
{/if}
{#if form?.error}
<p id="fallo" class="fallo" role="alert">{form.error}</p>
{/if}
</main>
<style>
main {
display: grid;
align-content: center;
gap: var(--sp-4);
width: 100%;
max-width: 26rem;
min-height: 100vh;
margin: 0 auto;
padding: var(--sp-6);
}
h1 {
margin: 0;
font-size: var(--fs-2xl);
}
.explica {
margin: 0;
color: var(--c-text-muted);
line-height: 1.55;
}
form {
display: grid;
gap: var(--sp-2);
}
label {
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
input {
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
color: inherit;
font: inherit;
font-size: var(--boton-texto);
}
input:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
#codigo {
font-family: var(--font-mono);
font-size: var(--fs-xl);
letter-spacing: 0.35em;
text-align: center;
}
button.btn {
margin-top: var(--sp-2);
justify-self: start;
}
.otra {
margin-top: calc(var(--sp-2) * -1);
}
.enlace {
justify-self: start;
padding: 0;
border: 0;
background: none;
color: var(--c-text-muted);
font: inherit;
font-size: var(--fs-sm);
text-decoration: underline;
text-underline-offset: 0.2em;
cursor: pointer;
}
.enlace:hover {
color: var(--c-accent);
}
.fallo {
margin: 0;
color: var(--c-danger);
font-size: var(--fs-sm);
}
</style>

@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
/**
* Un «no entres» para todo el dominio.
*
* El panel está detrás de una sesión, así que un buscador no vería nada de
* dentro; lo que evita esto es que la página de entrar acabe indexada, y con
* ella el dominio anunciado en los resultados de búsqueda del sitio.
*/
export const GET: RequestHandler = () =>
new Response('User-agent: *\nDisallow: /\n', {
headers: {
'content-type': 'text/plain; charset=utf-8',
'cache-control': 'public, max-age=86400'
}
});

@ -0,0 +1,19 @@
import { redirect } from '@sveltejs/kit';
import { borrarCookieSesion, cerrarSesion, COOKIE_SESION } from '$lib/server/auth';
import type { Actions } from './$types';
/** No hay página de salida: solo la acción. */
export function load() {
redirect(303, '/entrar');
}
export const actions: Actions = {
default: async ({ cookies }) => {
const token = cookies.get(COOKIE_SESION);
// Se borra también del servidor: quitar la cookie sin invalidar la sesión
// dejaría el token vivo para quien lo hubiera copiado.
if (token) await cerrarSesion(token);
borrarCookieSesion(cookies);
redirect(303, '/entrar');
}
};

@ -18,21 +18,33 @@
const hayReproduccion = $derived(reproductor.pista !== null);
/*
* Dos páginas van desnudas: sin cabecera, sin pie y sin reproductor.
*
* La de espera, porque mientras la web no está abierta no hay a dónde ir, y
* un menú cuyos enlaces llevan todos al mismo cartel es peor que no tener
* menú. Y el panel, porque es una herramienta y no una página del sitio: el
* menú de Música y Blog no ayuda a corregir una ficha, y el reproductor
* flotando encima de un formulario tapa el último campo.
* La página de espera va desnuda: sin cabecera, sin pie y sin reproductor.
* Mientras la web no está abierta no hay a dónde ir, y un menú cuyos enlaces
* llevan todos al mismo cartel es peor que no tener menú.
*
* Se resuelve aquí y no con un grupo de rutas propio porque eso obligaría a
* mover todas las demás páginas dentro de otra carpeta, y sería un cambio
* enorme para dos excepciones.
* enorme para una excepción.
*/
const desnuda = $derived(page.url.pathname === RUTA_ESPERA);
/*
* Avisa de que la página ya es interactiva.
*
* `page.goto` de una prueba vuelve cuando la página ha cargado, no cuando
* Svelte ha tomado el control, y al tomarlo reescribe el valor de cada
* `input` con el del componente, que está vacío: lo que se teclee antes de
* ese instante desaparece. Sin una señal que esperar, la prueba del
* formulario de contacto fallaba solo con la máquina cargada —la peor clase
* de fallo, el que parece un fantasma— y un reintento no la arreglaba,
* porque el borrado llega después de comprobar que el texto estaba puesto.
*
* Es un atributo y no una variable de JavaScript para que también sirva desde
* CSS el día que haya algo que solo deba verse ya hidratado.
*/
const desnuda = $derived(
page.url.pathname === RUTA_ESPERA || page.url.pathname.startsWith('/admin')
);
$effect(() => {
document.documentElement.dataset.hidratado = '';
});
</script>
<svelte:head>

@ -16,10 +16,9 @@ export const GET: RequestHandler = () => {
'User-agent: *',
'Disallow:',
'',
'# El panel de contenido no es contenido del sitio.',
'Disallow: /admin',
'',
'# Ni lo que depende de quién mira: la cuenta, el carrito, las descargas.',
// El panel no se nombra aquí: vive en otro dominio y tiene su propio
// robots.txt. Ponerlo sería repartir su dirección a quien pase.
'# Lo que depende de quién mira: la cuenta, el carrito, las descargas.',
'Disallow: /cuenta',
'Disallow: /carrito',
'Disallow: /favoritos',

@ -1,5 +1,18 @@
{
"extends": "./.svelte-kit/tsconfig.json",
// El panel es la otra aplicación del repositorio y se comprueba con
// `tsconfig.panel.json`. Aquí queda fuera: su `./$types` sale del mapa de
// rutas del panel, no del de la web pública.
"exclude": [
"./node_modules/**",
"./src/panel/**",
"./src/service-worker.js",
"./src/service-worker/**/*.js",
"./src/service-worker.ts",
"./src/service-worker/**/*.ts",
"./src/service-worker.d.ts",
"./src/service-worker/**/*.d.ts"
],
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,

@ -0,0 +1,48 @@
{
// El mismo tsconfig, sobre lo que genera el build del panel.
//
// Hacen falta dos porque cada aplicación tiene su carpeta de rutas y su
// `.svelte-kit`, y ahí dentro está el `./$types` de cada página. El
// `exclude` no es cosmético: sin él, esta comprobación entra también en
// `src/routes` y resuelve el `./$types` de cada página del sitio contra el
// mapa de rutas del panel, donde no existe ninguna. Salían casi doscientos
// errores que no lo eran.
"extends": "./.svelte-kit-panel/tsconfig.json",
"exclude": [
"./node_modules/**",
"./src/routes/**",
"./src/hooks.server.ts",
// Los componentes son del sitio: resuelven `/musica`, `/generos` y demás,
// que en el panel no existen. Están en `$lib` por costumbre, no porque se
// compartan; el panel no usa ninguno. El día que use alguno, habrá que
// sacarlo de aquí y quitarle las rutas.
"./src/lib/components/**",
"./src/lib/navegacion.ts",
"./src/lib/audio.ts",
// `pista.ts` importa `audio.ts`, y excluir un archivo no lo saca de la
// comprobación si algo incluido lo importa. El reproductor es del sitio.
"./src/lib/player/**",
// Los tipos ambientales del sitio: su `PageData` es la del layout raíz de
// la web —usuario, carrito, compras, favoritos—, que aquí no baja nadie.
// El panel tiene los suyos en `src/panel/app.d.ts`.
"./src/app.d.ts",
"./src/service-worker.js",
"./src/service-worker/**/*.js",
"./src/service-worker.ts",
"./src/service-worker/**/*.ts",
"./src/service-worker.d.ts",
"./src/service-worker/**/*.d.ts"
],
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

@ -5,31 +5,25 @@ import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import type { Plugin } from 'vite';
import type { PreprocessorGroup } from 'svelte/compiler';
import { conExtracto } from './src/lib/content/extracto.ts';
import { ancla } from './src/lib/content/anclas.ts';
/**
* Sirve el panel de contenido en /admin durante el desarrollo.
* Dos aplicaciones, un repositorio.
*
* En produccion lo resuelve el servidor de estaticos, que ya busca el
* index.html de una carpeta; el servidor de desarrollo de Vite no lo hace y
* /admin daria 404. Sin esto, la direccion del panel seria distinta en cada
* entorno, que es justo la clase de detalle que se olvida y hace perder media
* hora.
* `APP=panel` compila la administración; sin esa variable, la web pública. Cada
* una tiene su carpeta de rutas, sus hooks y su carpeta de salida, así que **el
* servidor público no lleva dentro ni una línea del panel**: no es que la
* esconda, es que ahí no existe. Cada una se sirve en su dominio y en su
* proceso, como los archivos en `m.senzapaura.es`.
*
* Comparten `src/lib`, que es donde están el esquema, las consultas y el
* acceso. Con dos repositorios habría que mantener las migraciones por
* duplicado, y el día que se desincronizaran nadie se enteraría hasta que el
* panel guardara en una columna que ya no existe.
*/
const panelEnDesarrollo: Plugin = {
name: 'panel-en-desarrollo',
apply: 'serve',
configureServer(servidor) {
servidor.middlewares.use((peticion, _respuesta, siguiente) => {
const ruta = (peticion.url ?? '').split('?')[0];
if (ruta === '/admin' || ruta === '/admin/') peticion.url = '/admin/index.html';
siguiente();
});
}
};
const esPanel = process.env.APP === 'panel';
/**
* Escribe en el frontmatter de cada cancion los primeros versos de su letra.
@ -105,14 +99,23 @@ const corregirScriptDeMdsvex: PreprocessorGroup = {
export default defineConfig({
plugins: [
panelEnDesarrollo,
sveltekit({
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
adapter: adapter(),
/*
* Lo único que distingue a las dos aplicaciones: de dónde salen las
* rutas, qué hooks corren y dónde se escribe el resultado. Los `.svelte-kit`
* también van separados, porque ahí dentro está el manifiesto de rutas
* y con uno compartido cada build borraría el del otro.
*/
files: esPanel
? { routes: 'src/panel/rutas', hooks: { server: 'src/panel/hooks.server' } }
: undefined,
outDir: esPanel ? '.svelte-kit-panel' : '.svelte-kit',
adapter: adapter({ out: esPanel ? 'build-panel' : 'build' }),
paths: {
/*
* Rutas absolutas para los recursos —`/_app/…`— en vez de las

Loading…
Cancel
Save

Powered by TurnKey Linux.