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 node_modules
# Output # Output
#
# Dos de cada: el repositorio compila dos aplicaciones —la web y el panel—, y
# cada una escribe en lo suyo.
.output .output
.vercel .vercel
.netlify .netlify
.wrangler .wrangler
/.svelte-kit /.svelte-kit
/.svelte-kit-panel
/build /build
/build-panel
# OS # OS
.DS_Store .DS_Store

@ -8,3 +8,8 @@ bun.lockb
# Miscellaneous # Miscellaneous
/static/ /static/
/drizzle/ /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 ## El panel
En `/admin` está la administración del contenido. Su portada dice qué hay y qué La administración es **otra aplicación**, en `admin.senzapaura.es`. Este
falta —capítulos sin publicar, géneros sin ficha, marcas del glosario sin repositorio compila dos:
confirmar—, que es la pregunta que se hace quien entra.
```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 Antes había un `/admin` dentro de la web con [Sveltia CMS](https://sveltiacms.app),
escribía los Markdown de `src/content/`. Se quitó al terminar la migración a un editor sobre git que escribía los Markdown de `src/content/`. Se quitó al
PostgreSQL: **el sitio ya no lee esos archivos**, así que el panel seguía terminar la migración a PostgreSQL: **el sitio ya no lee esos archivos**, así que
guardando cambios que no salían en pantalla, y eso es peor que no tener panel. guardaba cambios que no salían en pantalla, y eso es peor que no tener panel.
### Quién entra ### Quién entra
@ -129,11 +151,19 @@ un sistema de permisos para un usuario.
### Qué pasa si no eres tú ### Qué pasa si no eres tú
Sin sesión, a `/entrar`, con la dirección de vuelta puesta. Con sesión pero sin El panel tiene su propia página de entrar, porque una sesión de `senzapaura.es`
el rol, **404 y no 403**: un 403 confirma que el panel existe y dónde vive, y no vale en `admin.senzapaura.es`: la cookie se emite para el host y no para el
quien entró a comprar una canción no tiene por qué enterarse. La guarda está en dominio padre, que es justo lo que hace que la sesión del panel no viaje en
el `+layout.server.ts` de la carpeta, así que una pantalla nueva queda protegida ninguna petición de la web pública. Usa el mismo código por correo de
por el hecho de estar dentro. `$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 ## 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 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» ## El portón de «muy pronto»
Mientras la web no esté abierta al público, en el `.env` del servidor: 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. 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 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 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 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. 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í La guarda está en el `+layout.server.ts` del grupo que envuelve las pantallas
una pantalla nueva queda protegida por estar dentro de la carpeta. Sin sesión y no en cada ruta: así una pantalla nueva queda protegida por estar dentro.
manda a entrar con la vuelta puesta; con sesión y sin el rol, **404 y no Sin sesión manda a entrar con la vuelta puesta; con sesión y sin el rol,
403**, que un 403 confirma que el panel existe y dónde vive. **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 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 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 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 países de la UE. Trámite, no código. Stripe Tax calcula los tipos si se
activa. 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 * La espera no es adorno. `page.goto` vuelve cuando la página ha cargado, no
* cuando SvelteKit la ha hidratado, y al hidratar, Svelte reescribe el valor de * cuando Svelte ha tomado el control, y al tomarlo reescribe el valor de cada
* cada `input` con el del componente, que está vacío. Lo que se teclee antes de * `input` con el del componente, que está vacío: lo que se teclee antes de ese
* ese instante desaparece. * 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, * Un reintento no lo arregla —comprobar que el texto está puesto no impide que
* ya hidratada— y solo con la máquina cargada, así que fallaba en la tanda * lo borren un instante después—, así que el layout marca el momento con
* completa y pasaba al repetir la prueba a solas. Es la peor clase de fallo: * `data-hidratado` y aquí se espera a él.
* el que parece un fantasma.
*/ */
async function escribir(campo: Locator, valor: string) { async function abrirFormulario(page: Page) {
await expect(async () => { await page.goto('/contacto');
await campo.fill(valor); await page.locator('html[data-hidratado]').waitFor();
await expect(campo).toHaveValue(valor, { timeout: 500 });
}).toPass({ timeout: 10_000 });
} }
/** Rellena el formulario con datos válidos, salvo lo que se sobrescriba. */ /** Rellena el formulario con datos válidos, salvo lo que se sobrescriba. */
@ -25,18 +23,17 @@ async function rellenar(
page: Page, page: Page,
campos: Partial<{ nombre: string; email: string; motivo: string; mensaje: string }> = {} campos: Partial<{ nombre: string; email: string; motivo: string; mensaje: string }> = {}
) { ) {
await escribir(page.getByLabel('Nombre'), campos.nombre ?? 'Ana Ruiz'); await page.getByLabel('Nombre').fill(campos.nombre ?? 'Ana Ruiz');
await escribir(page.getByLabel('Correo electrónico'), campos.email ?? 'ana@example.com'); await page.getByLabel('Correo electrónico').fill(campos.email ?? 'ana@example.com');
await page.getByLabel('Motivo').selectOption(campos.motivo ?? 'colaboracion'); await page.getByLabel('Motivo').selectOption(campos.motivo ?? 'colaboracion');
await escribir( await page
page.getByLabel('Mensaje'), .getByLabel('Mensaje')
campos.mensaje ?? 'Me gustaría hablar contigo sobre una colaboración para un disco.' .fill(campos.mensaje ?? 'Me gustaría hablar contigo sobre una colaboración para un disco.');
);
} }
test.describe('Formulario de contacto', () => { test.describe('Formulario de contacto', () => {
test('la validación del servidor devuelve los errores por campo', async ({ page }) => { 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, // "A" y "Hola" pasan la validación del navegador y fallan en el servidor,
// que es justo lo que interesa comprobar aquí. // que es justo lo que interesa comprobar aquí.
await rellenar(page, { nombre: 'A', mensaje: 'Hola' }); 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 }) => { 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 rellenar(page, { nombre: 'A' });
await page.getByRole('button', { name: 'Enviar mensaje' }).click(); 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 }) => { test('el campo trampa descarta los envíos automáticos', async ({ page }) => {
await page.goto('/contacto'); await abrirFormulario(page);
await rellenar(page); await rellenar(page);
// Solo un cliente automático rellena este campo: está fuera de la vista // 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 }) => { test('un envío válido no muestra errores de campo', async ({ page }) => {
await page.goto('/contacto'); await abrirFormulario(page);
await rellenar(page); await rellenar(page);
await page.getByRole('button', { name: 'Enviar mensaje' }).click(); 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); 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: // Override or add rule settings here, such as:
// 'svelte/button-has-type': 'error' // 'svelte/button-has-type': 'error'

@ -7,14 +7,19 @@
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "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 ''", "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", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"test:unit": "vitest", "test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e", "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:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "node --env-file=.env scripts/db/migrar.mjs", "db:migrate": "node --env-file=.env scripts/db/migrar.mjs",

@ -1,25 +1,53 @@
import { defineConfig } from '@playwright/test'; 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 * 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 * 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. * su reserva, que es justo lo contrario de lo que hay que probar.
*/ */
use: { launchOptions: {
launchOptions: { args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader']
args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader']
}
},
testMatch: '**/*.e2e.{ts,js}',
webServer: {
// El build va 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
} }
};
export default defineConfig({
testDir: 'e2e',
testMatch: '**/*.e2e.{ts,js}',
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 { readdirSync, statSync } from 'node:fs';
import { resolve } from 'node:path'; 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. * Archivos que adapter-node deja siempre en un build correcto.
* *
* No está `build/server/index.js`: el adaptador no genera ese archivo. El * No está `server/index.js`: el adaptador no genera ese archivo. El servidor
* servidor sale troceado en `build/server/chunks/`, y quien lo arranca es * sale troceado en `server/chunks/`, y quien lo arranca es `index.js`.
* `build/index.js`. Comprobarlo costó un rato de creer que el build fallaba * Comprobarlo costó un rato de creer que el build fallaba cuando lo que fallaba
* cuando lo que fallaba era esta lista. * 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. */ /** 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 inicio = Date.now();
const resultado = spawnSync('npx', ['vite', 'build'], { const resultado = spawnSync('npx', ['vite', 'build'], {
stdio: 'inherit', 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); if (resultado.status === 0) process.exit(0);

@ -5,7 +5,8 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import BotonTema from './BotonTema.svelte'; import BotonTema from './BotonTema.svelte';
import Icono from './Icono.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 menuAbierto = $state(false);
let cabecera = $state<HTMLElement | null>(null); 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 // 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. // la portada le obligaría a buscar otra vez dónde estaba.
try { try {
exigirAdmin({ usuario: null }, en('/admin/glosario?sin=confirmar')); exigirAdmin({ usuario: null }, en('/glosario?sin=confirmar'));
expect.unreachable('tenía que redirigir'); expect.unreachable('tenía que redirigir');
} catch (salida) { } catch (salida) {
expect(isRedirect(salida)).toBe(true); expect(isRedirect(salida)).toBe(true);
if (!isRedirect(salida)) return; if (!isRedirect(salida)) return;
expect(salida.status).toBe(303); 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', () => { it('con sesión pero sin el rol, 403 y con el motivo', () => {
// Un 403 confirma que el panel existe y en qué dirección vive. Quien entró // Aquí solo llega quien administraba y ha dejado de administrar: el acceso
// a comprar una canción no tiene por qué enterarse de eso. // de este dominio solo manda códigos a administradores. Un 404 le dejaría
// creyendo que la página no existe.
try { try {
exigirAdmin({ usuario: cuenta('persona') }, en('/admin')); exigirAdmin({ usuario: cuenta('persona') }, en('/'));
expect.unreachable('tenía que cortar'); expect.unreachable('tenía que cortar');
} catch (salida) { } catch (salida) {
expect(isHttpError(salida)).toBe(true); expect(isHttpError(salida)).toBe(true);
if (!isHttpError(salida)) return; 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. * La puerta del panel.
* *
* Una sola función, y se llama desde el `+layout.server.ts` de `/admin`: en * Una sola función, y se llama desde el `+layout.server.ts` del grupo que
* SvelteKit el layout de una carpeta corre antes que cualquier página de dentro, * envuelve a todas las pantallas: en SvelteKit el layout corre antes que
* así que la comprobación no se puede olvidar al añadir una pantalla nueva. Una * cualquier página de dentro, así que una pantalla nueva queda comprobada por
* guarda que hay que acordarse de copiar en cada ruta acaba faltando en una. * 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'; 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 * 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. * 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 * Con sesión pero sin el rol, **403 y con el motivo escrito**. Cuando el panel
* existe y en qué dirección vive, que es justo lo que no tiene por qué saber * colgaba de la web pública esto era un 404, para no confirmarle a quien entró
* quien entró a comprar una canción. La diferencia entre «no puedes» y «no hay * a comprar una canción que existía un panel y en qué dirección. Desde que vive
* nada» no le cambia la vida a nadie legítimo y sí a quien va probando. * 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 { export function exigirAdmin(locals: App.Locals, url: URL): Usuario {
if (!locals.usuario) { if (!locals.usuario) {
@ -32,7 +40,7 @@ export function exigirAdmin(locals: App.Locals, url: URL): Usuario {
redirect(303, `/entrar?volverA=${volverA}`); 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; return locals.usuario;
} }

@ -1,11 +1,14 @@
import { resolve } from '$app/paths';
import type { ResolvedPathname } from '$app/types';
/** /**
* Datos globales del sitio. * Datos globales del sitio.
* *
* Es el unico sitio donde tocar nombres, correos y redes: nada de esto * Es el unico sitio donde tocar nombres, correos y redes: nada de esto
* deberia estar repetido en los componentes. * 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 = { export const site = {
nombre: 'Senza Paura', nombre: 'Senza Paura',
@ -83,22 +86,6 @@ export const heroe = {
poster: { oscuro: '/video/heroe.jpg', claro: '/video/heroe-claro.jpg' } poster: { oscuro: '/video/heroe.jpg', claro: '/video/heroe-claro.jpg' }
} as const; } 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. */ /** Etiquetas visibles de cada plataforma de escucha. */
export const etiquetasPlataforma: Record<string, string> = { export const etiquetasPlataforma: Record<string, string> = {
spotify: 'Spotify', 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"> <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 * Deliberadamente sobrio: aquí no se viene a mirar, se viene a arreglar algo
* viene a arreglar algo concreto. Lleva la misma paleta —es el mismo sitio— * concreto. Lleva la paleta del sitio —es la misma casa— y nada de su
* pero sin la cabecera de navegación, sin el reproductor y sin el pie: nada * navegación: el menú de Música y Blog no ayuda a corregir una ficha.
* de eso ayuda a corregir una ficha, y todo ello ocupa la pantalla.
* *
* La barra dice siempre quién eres. En una herramienta que edita lo que se * 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. * publica, saber con qué cuenta estás no es un adorno.
*/ */
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { site } from '$lib/site';
let { data, children } = $props(); let { data, children } = $props();
</script> </script>
<div class="panel"> <div class="panel">
<header class="barra"> <header class="barra">
<a class="marca" href={resolve('/admin')}> <a class="marca" href={resolve('/')}>
Panel<span class="sitio">Senza Paura</span> Panel<span class="sitio">Senza Paura</span>
</a> </a>
<div class="quien"> <div class="quien">
<span class="correo">{data.admin.email}</span> <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')}> <form method="POST" action={resolve('/salir')}>
<button type="submit">Salir</button> <button type="submit">Salir</button>
</form> </form>

@ -7,14 +7,19 @@
* tarjetas, esos números quedan a distinta altura y hay que ir buscándolos. * 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. * Alineados en columna se leen de arriba abajo de una pasada.
*/ */
import Meta from '$lib/components/Meta.svelte';
let { data } = $props(); let { data } = $props();
const pendientes = $derived(data.secciones.filter((s) => s.pendiente).length); const pendientes = $derived(data.secciones.filter((s) => s.pendiente).length);
</script> </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> <header>
<h1>Panel</h1> <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); const hayReproduccion = $derived(reproductor.pista !== null);
/* /*
* Dos páginas van desnudas: sin cabecera, sin pie y sin reproductor. * 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
* La de espera, porque mientras la web no está abierta no hay a dónde ir, y * llevan todos al mismo cartel es peor que no tener menú.
* 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.
* *
* Se resuelve aquí y no con un grupo de rutas propio porque eso obligaría a * 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 * 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( $effect(() => {
page.url.pathname === RUTA_ESPERA || page.url.pathname.startsWith('/admin') document.documentElement.dataset.hidratado = '';
); });
</script> </script>
<svelte:head> <svelte:head>

@ -16,10 +16,9 @@ export const GET: RequestHandler = () => {
'User-agent: *', 'User-agent: *',
'Disallow:', 'Disallow:',
'', '',
'# El panel de contenido no es contenido del sitio.', // El panel no se nombra aquí: vive en otro dominio y tiene su propio
'Disallow: /admin', // 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.',
'# Ni lo que depende de quién mira: la cuenta, el carrito, las descargas.',
'Disallow: /cuenta', 'Disallow: /cuenta',
'Disallow: /carrito', 'Disallow: /carrito',
'Disallow: /favoritos', 'Disallow: /favoritos',

@ -1,5 +1,18 @@
{ {
"extends": "./.svelte-kit/tsconfig.json", "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": { "compilerOptions": {
"rewriteRelativeImportExtensions": true, "rewriteRelativeImportExtensions": true,
"allowJs": 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 { playwright } from '@vitest/browser-playwright';
import adapter from '@sveltejs/adapter-node'; import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import type { Plugin } from 'vite';
import type { PreprocessorGroup } from 'svelte/compiler'; import type { PreprocessorGroup } from 'svelte/compiler';
import { conExtracto } from './src/lib/content/extracto.ts'; import { conExtracto } from './src/lib/content/extracto.ts';
import { ancla } from './src/lib/content/anclas.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 * `APP=panel` compila la administración; sin esa variable, la web pública. Cada
* index.html de una carpeta; el servidor de desarrollo de Vite no lo hace y * una tiene su carpeta de rutas, sus hooks y su carpeta de salida, así que **el
* /admin daria 404. Sin esto, la direccion del panel seria distinta en cada * servidor público no lleva dentro ni una línea del panel**: no es que la
* entorno, que es justo la clase de detalle que se olvida y hace perder media * esconda, es que ahí no existe. Cada una se sirve en su dominio y en su
* hora. * 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 = { const esPanel = process.env.APP === 'panel';
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();
});
}
};
/** /**
* Escribe en el frontmatter de cada cancion los primeros versos de su letra. * Escribe en el frontmatter de cada cancion los primeros versos de su letra.
@ -105,14 +99,23 @@ const corregirScriptDeMdsvex: PreprocessorGroup = {
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
panelEnDesarrollo,
sveltekit({ sveltekit({
compilerOptions: { compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6. // Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) => runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true 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: { paths: {
/* /*
* Rutas absolutas para los recursos —`/_app/…`— en vez de las * Rutas absolutas para los recursos —`/_app/…`— en vez de las

Loading…
Cancel
Save

Powered by TurnKey Linux.