diff --git a/.gitignore b/.gitignore index 6c1468f..4e40db5 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.prettierignore b/.prettierignore index 0243576..776f7d5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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/ diff --git a/README.md b/README.md index ca37344..875ea55 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/despliegue/con-proxy-manager.md b/despliegue/con-proxy-manager.md index f45be26..d8f49a6 100644 --- a/despliegue/con-proxy-manager.md +++ b/despliegue/con-proxy-manager.md @@ -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: diff --git a/docs/modelo-de-datos.md b/docs/modelo-de-datos.md index 3bb0b9c..ea16ea3 100644 --- a/docs/modelo-de-datos.md +++ b/docs/modelo-de-datos.md @@ -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`. diff --git a/e2e/admin.e2e.ts b/e2e/admin.e2e.ts deleted file mode 100644 index e149a2f..0000000 --- a/e2e/admin.e2e.ts +++ /dev/null @@ -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); - } - }); -}); diff --git a/e2e/contacto.e2e.ts b/e2e/contacto.e2e.ts index e6c4126..356d5d0 100644 --- a/e2e/contacto.e2e.ts +++ b/e2e/contacto.e2e.ts @@ -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(); diff --git a/e2e/navegacion.e2e.ts b/e2e/navegacion.e2e.ts index c3ef32f..443f8f9 100644 --- a/e2e/navegacion.e2e.ts +++ b/e2e/navegacion.e2e.ts @@ -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); + } + }); +}); diff --git a/e2e/panel.e2e.ts b/e2e/panel.e2e.ts new file mode 100644 index 0000000..f4da4f2 --- /dev/null +++ b/e2e/panel.e2e.ts @@ -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); + }); +}); diff --git a/eslint.config.js b/eslint.config.js index ed35999..171753a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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' diff --git a/package.json b/package.json index d2a2a94..40ec522 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 209eff6..abf26c5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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'] - } - }, - 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 + launchOptions: { + args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] } +}; + +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 + } + ] }); diff --git a/scripts/como-panel.mjs b/scripts/como-panel.mjs new file mode 100644 index 0000000..49f253f --- /dev/null +++ b/scripts/como-panel.mjs @@ -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); diff --git a/scripts/construir.mjs b/scripts/construir.mjs index cea18d9..c1bba30 100644 --- a/scripts/construir.mjs +++ b/scripts/construir.mjs @@ -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); diff --git a/src/lib/components/Cabecera.svelte b/src/lib/components/Cabecera.svelte index 65d65b3..ad9c6d9 100644 --- a/src/lib/components/Cabecera.svelte +++ b/src/lib/components/Cabecera.svelte @@ -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(null); diff --git a/src/lib/navegacion.ts b/src/lib/navegacion.ts new file mode 100644 index 0000000..605d294 --- /dev/null +++ b/src/lib/navegacion.ts @@ -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' } +]; diff --git a/src/lib/server/administracion.spec.ts b/src/lib/server/administracion.spec.ts index 4b7fe55..dd1069f 100644 --- a/src/lib/server/administracion.spec.ts +++ b/src/lib/server/administracion.spec.ts @@ -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); } }); }); diff --git a/src/lib/server/administracion.ts b/src/lib/server/administracion.ts index 3d7968a..04da861 100644 --- a/src/lib/server/administracion.ts +++ b/src/lib/server/administracion.ts @@ -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; } diff --git a/src/lib/site.ts b/src/lib/site.ts index eec64ac..4efb037 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -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 = { spotify: 'Spotify', diff --git a/src/panel/app.d.ts b/src/panel/app.d.ts new file mode 100644 index 0000000..7090acf --- /dev/null +++ b/src/panel/app.d.ts @@ -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 {}; diff --git a/src/panel/hooks.server.ts b/src/panel/hooks.server.ts new file mode 100644 index 0000000..3f364ef --- /dev/null +++ b/src/panel/hooks.server.ts @@ -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); +}; diff --git a/src/routes/admin/+layout.server.ts b/src/panel/rutas/(dentro)/+layout.server.ts similarity index 100% rename from src/routes/admin/+layout.server.ts rename to src/panel/rutas/(dentro)/+layout.server.ts diff --git a/src/routes/admin/+layout.svelte b/src/panel/rutas/(dentro)/+layout.svelte similarity index 76% rename from src/routes/admin/+layout.svelte rename to src/panel/rutas/(dentro)/+layout.svelte index 25d6352..16e022d 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/panel/rutas/(dentro)/+layout.svelte @@ -1,29 +1,35 @@
- + PanelSenza Paura
{data.admin.email} - Ver el sitio + + + Ver el sitio
diff --git a/src/routes/admin/+page.server.ts b/src/panel/rutas/(dentro)/+page.server.ts similarity index 100% rename from src/routes/admin/+page.server.ts rename to src/panel/rutas/(dentro)/+page.server.ts diff --git a/src/routes/admin/+page.svelte b/src/panel/rutas/(dentro)/+page.svelte similarity index 92% rename from src/routes/admin/+page.svelte rename to src/panel/rutas/(dentro)/+page.svelte index ad3cac4..c4b3ac8 100644 --- a/src/routes/admin/+page.svelte +++ b/src/panel/rutas/(dentro)/+page.svelte @@ -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); - + + + Panel · Senza Paura +

Panel

diff --git a/src/panel/rutas/+layout.svelte b/src/panel/rutas/+layout.svelte new file mode 100644 index 0000000..999e491 --- /dev/null +++ b/src/panel/rutas/+layout.svelte @@ -0,0 +1,32 @@ + + + + + + + + +{@render children()} + + diff --git a/src/panel/rutas/entrar/+page.server.ts b/src/panel/rutas/entrar/+page.server.ts new file mode 100644 index 0000000..e3a0ac5 --- /dev/null +++ b/src/panel/rutas/entrar/+page.server.ts @@ -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); + } +}; diff --git a/src/panel/rutas/entrar/+page.svelte b/src/panel/rutas/entrar/+page.svelte new file mode 100644 index 0000000..bd7cef1 --- /dev/null +++ b/src/panel/rutas/entrar/+page.svelte @@ -0,0 +1,175 @@ + + + + Entrar · Panel + + +
+

Panel

+ + {#if paso === 'email'} +

+ Escribe el correo de la cuenta que administra el sitio. Te mandamos un código de un solo uso. +

+ +
+ + + + + + +
+ {:else} +

+ {form?.aviso ?? 'Si esa cuenta administra el sitio, le hemos mandado un código.'} +

+ +
+ + + + + + + +
+ +
+ + + +
+ {/if} + + {#if form?.error} + + {/if} +
+ + diff --git a/src/panel/rutas/robots.txt/+server.ts b/src/panel/rutas/robots.txt/+server.ts new file mode 100644 index 0000000..b569044 --- /dev/null +++ b/src/panel/rutas/robots.txt/+server.ts @@ -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' + } + }); diff --git a/src/panel/rutas/salir/+page.server.ts b/src/panel/rutas/salir/+page.server.ts new file mode 100644 index 0000000..4351ae8 --- /dev/null +++ b/src/panel/rutas/salir/+page.server.ts @@ -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'); + } +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index de3a236..b129338 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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 = ''; + }); diff --git a/src/routes/robots.txt/+server.ts b/src/routes/robots.txt/+server.ts index 0f97bdb..e597a0c 100644 --- a/src/routes/robots.txt/+server.ts +++ b/src/routes/robots.txt/+server.ts @@ -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', diff --git a/tsconfig.json b/tsconfig.json index 2c2ed3c..b4527d6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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, diff --git a/tsconfig.panel.json b/tsconfig.panel.json new file mode 100644 index 0000000..b85c10c --- /dev/null +++ b/tsconfig.panel.json @@ -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" + } +} diff --git a/vite.config.ts b/vite.config.ts index d19419c..39cc193 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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