A dashboard widget lists what expires in the next two weeks, and a daily command reminds whoever edits it a week and a day ahead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2000 lines
99 KiB
Markdown
2000 lines
99 KiB
Markdown
# Pireon Admin
|
|
|
|
Panel de administración y API REST en Laravel 13 + Filament 5 para un colectivo
|
|
de programadores sin ánimo de lucro. Autenticación delegada en el servidor de
|
|
identidad **Pireon** (`https://oauth.pireon.dev`).
|
|
|
|
**Este repositorio es solo admin + API.** La web pública es una aplicación
|
|
aparte, con **renderizado en servidor (SSR)**, que consume `/api/public/v1`. Con
|
|
qué framework esté hecha —Astro, Qwik, Angular— es indiferente para este lado, y
|
|
por eso no se nombra ninguno en el código: lo único que esta API da por supuesto
|
|
es el SSR. Aquí no hay maquetación: el renderizado, las rutas, el sitemap y las
|
|
etiquetas `hreflang` son cosa del frontend. Lo que sí vive aquí es el dato que
|
|
necesita para hacerlo.
|
|
|
|
## Las dos ideas que lo estructuran todo
|
|
|
|
**1. El servidor OAuth dice quién eres; esta aplicación decide qué puedes hacer.**
|
|
|
|
| | Lo gestiona | Dónde se cambia |
|
|
|---|---|---|
|
|
| Nombre, email, cuenta activa/aprobada | Servidor de identidad | `oauth.pireon.dev` |
|
|
| Roles y permisos | Esta aplicación | Panel → Administración → Roles |
|
|
| Contenido, idiomas, repositorios | Esta aplicación | Panel |
|
|
|
|
Los `app_roles` que devuelve el servidor OAuth se guardan en `users.remote_roles`
|
|
como información de apoyo, pero **no conceden nada**.
|
|
|
|
**2. Un miembro del colectivo no es un usuario del panel.**
|
|
|
|
La mayoría de la gente que aparece en la web nunca necesita entrar al admin.
|
|
`Member` tiene una relación *opcional* con `User`, así que quitarle a alguien el
|
|
acceso de administración no lo borra del sitio.
|
|
|
|
## Manual de uso
|
|
|
|
Para quien **usa** el panel (no para quien lo despliega) hay un manual aparte:
|
|
[`docs/manual-de-uso.pdf`](docs/manual-de-uso.pdf) — 26 páginas cubriendo
|
|
idiomas y traducciones que se quedan atrás, proyectos, publicación programada, la vitrina,
|
|
convocatorias, el buzón, imágenes, texto alternativo y galerías, miembros, radar,
|
|
repositorios de Gitea, pedir revisión, redirecciones y URLs que nadie encuentra,
|
|
salud del sitio y visitas, papelera, historial, mantenimiento y la tabla de qué
|
|
puede hacer cada rol.
|
|
|
|
Se genera desde [`docs/manual-de-uso.html`](docs/manual-de-uso.html):
|
|
|
|
```bash
|
|
node docs/build-pdf.mjs
|
|
```
|
|
|
|
## Puesta en marcha
|
|
|
|
```bash
|
|
composer install
|
|
cp .env.example .env # si aún no existe
|
|
php artisan key:generate
|
|
php artisan migrate --seed # crea roles, permisos y contenido de ejemplo
|
|
```
|
|
|
|
`composer install` activa también [`.githooks/pre-commit`](.githooks/pre-commit)
|
|
(`git config core.hooksPath .githooks`, dentro de `post-autoload-dump`): antes
|
|
de cada commit corre `pint --dirty --test` sobre lo que has tocado y lo
|
|
rechaza si algo no está formateado. Solo avisa, nunca reescribe por su cuenta
|
|
un fichero que estás a punto de comitear — el arreglo es `pint --dirty` a
|
|
mano, ver el diff, y comitear otra vez. Nada de esto sustituye a la CI: el
|
|
hook protege el minuto a minuto de quien escribe, la CI protege lo que llega
|
|
al remoto por si alguien comitea con `--no-verify`.
|
|
|
|
### 1. Registrar la aplicación en el servidor de identidad
|
|
|
|
En `https://oauth.pireon.dev` crea un cliente OAuth con:
|
|
|
|
- **Grant types**: `password` — es el que usa el formulario de acceso del panel
|
|
- **Redirect URI**: `https://tu-dominio/auth/callback` (en local,
|
|
`http://localhost:8000/auth/callback`), solo si quieres conservar el flujo de
|
|
redirección, que sigue disponible aunque el panel ya no lo use
|
|
|
|
Copia las credenciales al `.env`:
|
|
|
|
```dotenv
|
|
PIREON_SERVER=https://oauth.pireon.dev
|
|
PIREON_CLIENT_ID=...
|
|
PIREON_CLIENT_SECRET=...
|
|
PIREON_REDIRECT_URI="${APP_URL}/auth/callback"
|
|
```
|
|
|
|
Mientras `PIREON_CLIENT_ID` esté vacío, la pantalla de login lo indica en vez
|
|
de fallar contra el servidor.
|
|
|
|
### 2. Crear el primer administrador
|
|
|
|
No hay usuarios locales hasta que alguien entra por OAuth. El arranque es:
|
|
|
|
```bash
|
|
php artisan serve
|
|
# entra en http://localhost:8000/admin y haz login con tu cuenta de Pireon
|
|
php artisan user:role tu@email.com super_admin
|
|
```
|
|
|
|
Tu primer login te deja con el rol `viewer` (configurable en
|
|
`PIREON_DEFAULT_ROLE`); el comando te promociona a super admin. A partir de ahí
|
|
todo se gestiona desde el panel.
|
|
|
|
## Autenticación
|
|
|
|
### Panel — email y contraseña
|
|
|
|
`/admin/login` es un formulario normal. Quien entra no ve nada de OAuth: el
|
|
servidor de identidad es un detalle de implementación.
|
|
|
|
```
|
|
POST /admin/login → grant "password" contra oauth.pireon.dev
|
|
→ GET /api/user para saber quién es
|
|
→ crea o actualiza el usuario local, inicia sesión
|
|
```
|
|
|
|
**No hay contraseñas locales.** La columna `password` no se usa nunca: quien
|
|
decide si las credenciales son correctas sigue siendo el servidor de identidad.
|
|
Desactivar a alguien allí, o aquí (`is_active = false`), le corta el acceso
|
|
**a la próxima petición** — la sesión ya abierta seguía existiendo hasta ahora,
|
|
sin que nadie pudiera verla ni cerrarla desde aquí.
|
|
|
|
En **Administración → Usuarios → [nombre] → Sesiones** se ve desde dónde
|
|
tiene cada cuenta la sesión abierta ahora mismo — IP, navegador, última
|
|
actividad — leído directamente de la tabla `sessions` que el driver
|
|
`database` de Laravel ya mantiene; no se escribe nada nuevo, solo se hace
|
|
visible. Con botón de **Cerrar sesión** por cada una, salvo la que la propia
|
|
persona que mira la pantalla está usando en ese momento: ofrecer cerrarla
|
|
dejaría un clic capaz de desconectarte a ti mismo a mitad de gestionar a otro.
|
|
|
|
El precio de esta comodidad, dicho claro: **esta aplicación ve la contraseña**.
|
|
No la guarda, no la registra y no sale de la petición en la que llega —va
|
|
directa al servidor de identidad—, pero un compromiso de este panel expondría
|
|
credenciales del servidor de identidad, cosa que el flujo de redirección hacía
|
|
imposible. Es una decisión deliberada, no un descuido.
|
|
|
|
Lo que sí se mantiene:
|
|
|
|
- **Cinco intentos** por IP y a esperar (el limitador de Filament), para no
|
|
gastar el del servidor de identidad.
|
|
- **Un solo mensaje de error** para credenciales incorrectas y para direcciones
|
|
que no existen: el formulario no sirve para averiguar quién tiene cuenta.
|
|
- **Pero un servidor de identidad caído no se disfraza de contraseña
|
|
incorrecta.** Si responde 5xx o no responde, el mensaje lo dice: no ha
|
|
juzgado nada sobre esas credenciales, y hacer creer lo contrario manda a
|
|
alguien a revisar una contraseña que nunca fue el problema. Lo mismo en
|
|
`POST /api/v1/auth/token`, que devuelve 503 en vez de 401.
|
|
- **Rotación del id de sesión** al entrar, contra fijación de sesión.
|
|
- Cuenta desactivada, pendiente de aprobación o sin rol en este panel: mensajes
|
|
distintos, pero solo **después** de acertar la contraseña.
|
|
|
|
Las rutas de **Authorization Code + PKCE** siguen ahí
|
|
([`PireonAuthController`](app/Http/Controllers/Auth/PireonAuthController.php),
|
|
`/auth/redirect` y `/auth/callback`) y funcionando, simplemente ya no se
|
|
anuncian. Volver a ese flujo es cambiar la página de login del panel.
|
|
|
|
Requiere `PIREON_PASSWORD_GRANT=true` y que el cliente OAuth registrado
|
|
permita el grant `password`. Si está desactivado, el formulario lo dice en vez
|
|
de fingir que las credenciales son incorrectas.
|
|
|
|
Los tokens viven en la sesión ([`TokenStore`](app/Services/Pireon/TokenStore.php)),
|
|
se refrescan solos al caducar y se borran al cerrar sesión.
|
|
|
|
### API — Bearer token
|
|
|
|
El servidor de identidad no publica JWKS ni endpoint de introspección, así que
|
|
[`PireonTokenGuard`](app/Auth/PireonTokenGuard.php) valida cada token
|
|
gastándolo contra `GET /api/user`. El resultado se cachea unos segundos
|
|
(`PIREON_INTROSPECTION_TTL`), indexado por un hash SHA-256 del token — el token
|
|
en sí nunca llega al almacén de caché.
|
|
|
|
Ese TTL es la ventana durante la cual un token revocado arriba sigue
|
|
funcionando aquí. Mantenlo bajo.
|
|
|
|
Desactivar un usuario en este panel (`is_active = false`) le corta el acceso al
|
|
instante, sin tocar el servidor OAuth.
|
|
|
|
### Password grant
|
|
|
|
Para clientes que no pueden abrir un navegador (tests, apps móviles):
|
|
|
|
```bash
|
|
curl -X POST http://localhost:8000/api/v1/auth/token \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"grant_type":"password","username":"tu@email.com","password":"..."}'
|
|
```
|
|
|
|
Es el mismo grant que usa el formulario del panel, así que
|
|
`PIREON_PASSWORD_GRANT=false` desactiva los dos a la vez.
|
|
|
|
## Permisos
|
|
|
|
El catálogo vive en un único sitio: [`app/Support/Permissions.php`](app/Support/Permissions.php).
|
|
|
|
```php
|
|
public const RESOURCES = [
|
|
'pages' => [...self::CRUD_ABILITIES, 'publish'],
|
|
'categories' => self::CRUD_ABILITIES,
|
|
'projects' => [...self::CRUD_ABILITIES, 'publish'],
|
|
'members' => self::CRUD_ABILITIES,
|
|
'redirects' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
'services' => [...self::CRUD_ABILITIES, 'publish'],
|
|
'clients' => self::CRUD_ABILITIES,
|
|
'testimonials' => self::CRUD_ABILITIES,
|
|
'talks' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
'technologies' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
'calls' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
'applications' => ['view_any', 'view', 'update', 'delete', 'restore', 'force_delete'],
|
|
'repositories' => ['view_any', 'view', 'create', 'update', 'delete', 'sync'],
|
|
'users' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
'roles' => ['view_any', 'view', 'create', 'update', 'delete'],
|
|
'settings' => ['view', 'update', 'maintenance'],
|
|
];
|
|
```
|
|
|
|
Añade un permiso ahí y ejecuta `php artisan permissions:sync`: aparece
|
|
automáticamente en el editor de roles, en las policies y en la API. El sync es
|
|
idempotente y borra los permisos que quites del catálogo.
|
|
|
|
Roles que se siembran:
|
|
|
|
- **super_admin** — sin permisos explícitos; lo puede todo por un
|
|
`Gate::before`, así que nunca se queda desactualizado
|
|
- **admin** — todo el catálogo
|
|
- **editor** — todo el contenido (páginas, proyectos, miembros, tecnologías,
|
|
convocatorias) y el buzón, porque quien lanza una convocatoria es quien tiene
|
|
que contestar a quien responde; repositorios, usuarios y ajustes en solo
|
|
lectura
|
|
- **writer** — escribe páginas, proyectos y servicios y **no puede
|
|
publicarlos**: es el rol para el que existían los permisos `publish` sueltos,
|
|
y el que hace que el paso de [revisión](#revisión) tenga sentido. Sin buzón
|
|
(mismo motivo que el viewer) y sin borrado definitivo
|
|
- **viewer** — lectura de contenido, **sin** acceso al buzón
|
|
|
|
Las [policies](app/Policies/) las comparten el panel y la API, que es lo que
|
|
impide que las dos superficies se desincronicen. Incluyen las salvaguardas
|
|
habituales: nadie se borra a sí mismo, no se elimina el último super admin,
|
|
solo un super admin concede `super_admin`, y una categoría con páginas no se
|
|
puede borrar.
|
|
|
|
Estos permisos están separados a propósito:
|
|
|
|
- `pages.publish` / `projects.publish` / `services.publish` — se puede escribir
|
|
sin poder poner nada en la web pública. Es lo que hace posible el paso de
|
|
[revisión](#revisión).
|
|
- `repositories.sync` — lanzar una sincronización gasta el rate limit del
|
|
proveedor de git, que es un recurso compartido por toda la aplicación.
|
|
- `settings.maintenance` — tumbar la web pública es un acto deliberado y muy
|
|
visible, así que no va con el resto de ajustes.
|
|
- `applications.*` — el buzón guarda nombres, emails y mensajes de gente ajena
|
|
al colectivo. No hay `applications.create`: las solicitudes llegan de la web,
|
|
nadie las escribe a mano.
|
|
|
|
## Multilanguage
|
|
|
|
Tres idiomas — **es** (por defecto), **en**, **ca** — configurados en
|
|
[`config/locales.php`](config/locales.php).
|
|
|
|
**Interfaz**: `laravel-lang` cubre validación y mensajes de framework; las
|
|
cadenas propias están en `lang/{es,en,ca}/{panel,pireon,rules,enums}.php`.
|
|
|
|
**Contenido**: `Page`, `Category`, `Project`, `Member`, `Technology` y `Call`
|
|
usan `spatie/laravel-translatable`. Los
|
|
campos traducibles se guardan como JSON `{"es": "...", "en": "...", "ca": "..."}`.
|
|
En el panel se editan con una pestaña por idioma
|
|
([`TranslatableTabs`](app/Filament/Support/TranslatableTabs.php)), y una columna
|
|
de estado muestra de un vistazo qué falta por traducir.
|
|
|
|
Orden de resolución del idioma (de más a menos específico):
|
|
|
|
1. `?lang=ca`
|
|
2. sesión (lo que eligió en el selector)
|
|
3. `users.locale`
|
|
4. cabecera `Accept-Language`
|
|
5. `locales.default`
|
|
|
|
En la API los campos traducibles se devuelven resueltos a un idioma. Con
|
|
`?with=translations` llegan todos:
|
|
|
|
```bash
|
|
curl '.../api/v1/pages?lang=ca' # title: "Inici"
|
|
curl '.../api/v1/pages?with=translations' # translations.title.{es,en,ca}
|
|
```
|
|
|
|
Al escribir, un objeto parcial **fusiona**: enviar `{"title":{"ca":"..."}}` no
|
|
borra el castellano.
|
|
|
|
Los slugs son únicos **por idioma** ([`UniqueTranslatedSlug`](app/Rules/UniqueTranslatedSlug.php));
|
|
el mismo slug en dos idiomas distintos es válido. Al vivir dentro de JSON no
|
|
puede ser una restricción de base de datos, así que se valida en la aplicación.
|
|
|
|
Esa regla es dura y solo mira la dirección. Lo que no ve es un **título** casi
|
|
idéntico con un slug distinto — el caso real: alguien no recuerda que el
|
|
proyecto ya existe y empieza uno nuevo. Al escribir el título en páginas,
|
|
proyectos y servicios, si algo existente se le parece mucho ([`DuplicateTitles`](app/Support/DuplicateTitles.php),
|
|
comparación por semejanza de caracteres, sin operador de "parecido" común entre
|
|
SQLite y Postgres), sale un aviso con la ficha existente enlazada. **No bloquea
|
|
el guardado** — a veces el segundo es de verdad otra cosa que se llama casi
|
|
igual —, solo evita que nadie se entere tarde.
|
|
|
|
## El modelo de contenido
|
|
|
|
Cuatro modelos, pensados para lo que un colectivo así necesita de verdad:
|
|
|
|
**Proyectos** — además de lo obvio, dos campos que son la razón de ser del
|
|
modelo: `decisions` (por qué se eligió lo que se eligió) y `postmortem` (qué
|
|
salió mal). Es lo que una consultora comercial no puede publicar y vosotros sí,
|
|
y es lo que otros desarrolladores leen. Y `client`: para qué ONG o asociación se
|
|
hizo, ahora como registro propio y no como texto suelto.
|
|
|
|
**Servicios** — qué ofrece el colectivo, enlazado a los proyectos que lo
|
|
demuestran. **Clientes** — para quién se hizo, con logo. **Testimonios** — qué
|
|
dijeron, colgado de lo que avalan. **Charlas** — dónde se ha contado.
|
|
|
|
**Miembros** — perfil público con bio traducible, stack y enlaces. Es el
|
|
escaparate personal de cada uno, que es la moneda con la que se paga el trabajo
|
|
voluntario. Quien se va conserva su historial (`left_at`) en vez de desaparecer.
|
|
|
|
**Tecnologías** — vocabulario compartido entre proyectos, miembros y
|
|
convocatorias, ahora con **logo propio** además del color —`icon` guardaba el
|
|
nombre de un icono de un set que el frontend puede no traer, y la mayoría de
|
|
tecnologías no están en ninguno—, y fuente del **tech radar** (`adopt` / `trial` / `assess` /
|
|
`hold`, con la nota que explica el porqué). El `name` no se traduce: «Rust» es
|
|
«Rust» en los tres idiomas; solo la prosa se traduce.
|
|
|
|
**Convocatorias** — el punto de conversión. La clave son `hours_per_week` y
|
|
`duration_weeks`: en un colectivo por hobby lo escaso es el tiempo, y «~4h/semana
|
|
durante 8 semanas» permite decidir en diez segundos donde «buscamos frontend» no
|
|
dice nada. `what_you_get` hace explícito el intercambio por trabajo no
|
|
remunerado. Lo que la gente contesta a una convocatoria aterriza en el
|
|
**buzón**, que es la otra mitad del punto de conversión: sin él la web enseña lo
|
|
que hace falta y no recoge a quien se ofrece.
|
|
|
|
## Imágenes
|
|
|
|
Una imagen dejó de ser una cadena. Un frontend que renderiza en servidor no
|
|
puede maquetar bien con una ruta: sin `width` y `height` la página baila
|
|
mientras cargan, sin `srcset` cada visitante se descarga el original de 2400px
|
|
para un hueco de 400, y sin `alt` no es accesible. Todo eso se sabe aquí, así
|
|
que se manda:
|
|
|
|
```json
|
|
"cover_image": {
|
|
"url": "https://admin.example/storage/projects/cover.jpg",
|
|
"alt": "Captura del panel de la asociación",
|
|
"width": 2400, "height": 1350, "aspect_ratio": 1.7778,
|
|
"mime": "image/jpeg",
|
|
"placeholder": "data:image/webp;base64,UklGRlIA...",
|
|
"srcset": {
|
|
"webp": ".../cover-400.webp 400w, .../cover-800.webp 800w, ..."
|
|
}
|
|
}
|
|
```
|
|
|
|
El panel sigue subiendo igual que antes: eliges un archivo y se guarda su ruta.
|
|
Lo que cambia es lo que pasa después.
|
|
|
|
**La tabla `media` es derivada.** Se identifica por (disco, ruta) —el archivo es
|
|
la identidad—, así que dos registros apuntando a la misma subida comparten una
|
|
fila y un solo juego de versiones. No se edita a mano: si se borrara entera se
|
|
puede reconstruir con `php artisan media:process --all`, que es justo lo que hay
|
|
que ejecutar tras cambiar los anchos o añadir un formato.
|
|
|
|
**El alt vive en el registro, no en el archivo.** La misma foto puede necesitar
|
|
una descripción distinta en un proyecto que en un listado, y solo el contexto
|
|
sabe cuál. Por eso `pages.featured_image_alt` y `projects.cover_image_alt` son
|
|
columnas traducibles del propio registro. Los avatares de miembros no tienen
|
|
campo: el alt de un retrato es el nombre de la persona, y uno que hubiera que
|
|
escribir solo se escribiría mal.
|
|
|
|
**Nunca se amplía.** Un original de 900px genera las versiones de 400 y 800 y
|
|
para. Republicar una imagen pequeña como «1600px» solo produce algo borroso.
|
|
|
|
**AVIF se pide, no se supone.** Se genera solo si el PHP que ejecuta sabe
|
|
codificarlo; si no, sale WebP y ya. Los vectores y los GIF pasan de largo sin
|
|
tocarlos: remuestrear un SVG pierde el sentido y remuestrear un GIF pierde la
|
|
animación. Del SVG sí se lee el `viewBox`, porque el hueco hay que reservarlo
|
|
igual.
|
|
|
|
**Se degrada a propósito.** Una imagen recién subida cuya cola aún no ha corrido
|
|
sale con `url` y el resto a `null`: la página pinta un `<img>` normal en vez de
|
|
un agujero.
|
|
|
|
### El catálogo de medios
|
|
|
|
**Administración → Medios** enseña todo lo subido: miniatura, medidas, cuántas
|
|
versiones se derivaron, **cuánto pesa contando esas versiones** —el número que
|
|
explica adónde se fue el disco— y si algo lo sigue usando.
|
|
|
|
Filtros para las cuatro preguntas que no se pueden responder en ningún otro
|
|
sitio: qué está **sin procesar**, qué **falló**, qué **no usa nadie** (lo mismo
|
|
que se llevaría `content:prune --media`) y qué **ya no está en disco**.
|
|
|
|
No se sube desde ahí: los archivos llegan al subirlos en una ficha (o por la
|
|
API, abajo). Y **no se puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que
|
|
es justo de lo que se queja la página de salud. El botón solo aparece en los
|
|
huérfanos.
|
|
|
|
«Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la
|
|
tabla es derivada, así que reconstruirla siempre es seguro.
|
|
|
|
### Subir por la API
|
|
|
|
`POST /api/v1/media` es el campo de subida de una ficha, sin la ficha: guarda la
|
|
imagen donde el panel pone las de ese tipo de ficha y contesta con el `path` que
|
|
se manda como `featured_image` de una página o `logo` de una tecnología. `for`
|
|
dice para qué es (`pages`, por defecto, `technologies`, `projects`, `members`,
|
|
`clients` o `testimonials`), y con ello la carpeta y el permiso que hace falta. Existe para el [MCP](#servidor-mcp), que sin él
|
|
podía crear una página pero nunca ponerle una foto. Pasa por `Media::for()`,
|
|
así que se procesa, se sirve y se poda igual que una subida desde el panel.
|
|
|
|
Dos formas de entrada, una por petición: **multipart** con `file`, o **JSON**
|
|
con una `url` que la API descarga ella misma. JPEG, PNG, WebP, GIF, AVIF y SVG,
|
|
decidido por los bytes y no por el nombre ni el tipo declarado. 20 MB como mucho
|
|
(`MEDIA_UPLOAD_MAX_KB`).
|
|
|
|
**El SVG entra, pero nunca tal cual**: es lo que es un logo, y también un
|
|
documento que puede llevar script servido desde este dominio. Pasa antes por
|
|
[`enshrined/svg-sanitize`](https://github.com/darylldoyle/svg-sanitizer), que
|
|
quita scripts, manejadores de eventos y referencias a cualquier cosa fuera del
|
|
fichero; y Caddy sirve los `/storage/*.svg` con una `Content-Security-Policy`
|
|
que pararía un script igualmente si se colara.
|
|
|
|
**La URL es la parte delicada**, porque la petición la hace este servidor desde
|
|
dentro de la red del stack: sin control, sería una forma de pedirle
|
|
`http://postgres:5432` o el servicio de metadatos de la nube.
|
|
[`RemoteImage`](app/Services/Media/RemoteImage.php) exige HTTPS, resuelve el
|
|
dominio y **rechaza si una sola de sus direcciones no es pública** (privadas,
|
|
reservadas, loopback, link-local, 100.64/10, IPv4 mapeada en IPv6), **fija la
|
|
conexión a la dirección comprobada** con `CURLOPT_RESOLVE` para que un DNS que
|
|
cambie entre la comprobación y la petición no cuele otra, **no sigue
|
|
redirecciones** y deja de leer al pasar del límite, diga lo que diga
|
|
`Content-Length`.
|
|
|
|
El permiso es escribir el tipo de ficha de `for` (`pages.create` o
|
|
`pages.update`, `technologies.create` o…), no ninguno de `media.*`: esos siempre describieron cuidar ficheros que ya existen, y
|
|
`MediaPolicy::create` sigue siendo `false` para que el panel no gane un botón de
|
|
«nuevo fichero» suelto.
|
|
|
|
### Galerías
|
|
|
|
Un proyecto tiene además una galería —capturas, el antes y el después— que se
|
|
edita arrastrando desde su propia pestaña y sale en `gallery`, **solo en el
|
|
endpoint de detalle**: un listado de veinte proyectos no necesita todas las
|
|
capturas de cada uno.
|
|
|
|
Cada imagen lleva dos textos distintos, y la diferencia importa:
|
|
|
|
- **`alt`** — qué se ve, para quien no puede verlo.
|
|
- **`caption`** — el pie, que se muestra a todo el mundo. «Antes» y «después»
|
|
son pies, no descripciones.
|
|
|
|
La tabla es polimórfica (`image_attachments`), así que darle galería a las
|
|
páginas es añadir una relación `morphMany` y registrar el relation manager; no
|
|
hace falta otra tabla.
|
|
|
|
```bash
|
|
php artisan media:process # lo que la cola no haya hecho
|
|
php artisan media:process --all # reprocesar todo
|
|
```
|
|
|
|
El scheduler lo ejecuta cada diez minutos, así que un worker caído cuesta un
|
|
retraso y no una imagen a medias para siempre.
|
|
|
|
**Nota de despliegue:** `MEDIA_DISK` tiene que ser un disco con URL pública
|
|
(`public` requiere `php artisan storage:link`). Si tenéis subidas anteriores a
|
|
esta versión están en el disco privado `local`: o las movéis a
|
|
`storage/app/public` o ponéis `MEDIA_DISK=local`, que se sirve por la ruta
|
|
`/storage` de la propia aplicación.
|
|
|
|
## Repositorios de git (Gitea)
|
|
|
|
Los repositorios viven en **vuestra instancia de Gitea**. Se conectan indicando
|
|
solo proveedor y `owner/repo`; todo lo demás —estrellas, lenguajes,
|
|
contribuidores, último push, topics— lo trae la sincronización y no es editable
|
|
a mano: un contador de estrellas escrito a dedo es una mentira esperando a
|
|
ocurrir.
|
|
|
|
```dotenv
|
|
GITEA_URL=https://git.example.org # raíz de la instancia, sin /api/v1
|
|
GITEA_TOKEN= # solo si hay repos privados
|
|
GITEA_ORG=colectivo # permite escribir solo "mi-repo"
|
|
```
|
|
|
|
De cada repositorio se replican **solo las issues que sirven para entrar**: las
|
|
etiquetadas como *good first issue* o *help wanted* (las variantes aceptadas
|
|
están en [`config/git.php`](config/git.php), y el match ignora mayúsculas).
|
|
Es lo más eficaz que puede tener una web así, porque convierte a alguien que
|
|
pasaba por ahí en contribuidor con un clic. Las que se cierran arriba
|
|
desaparecen aquí, para no hacer perder el tiempo a nadie.
|
|
|
|
```bash
|
|
php artisan repos:sync # encola los que toque refrescar
|
|
php artisan repos:sync --sync # en primer plano, con salida legible
|
|
php artisan repos:sync colectivo/web --force
|
|
```
|
|
|
|
El scheduler lo ejecuta cada hora ([`routes/console.php`](routes/console.php)).
|
|
Mantenerlo vivo importa más de lo que parece: **el mayor riesgo de una web así
|
|
es parecer abandonada**, y lo que se actualiza solo vale más que la prosa que
|
|
nadie va a revisar. El panel marca en rojo los repos que llevan varios intentos
|
|
fallando (normalmente renombrados o borrados) y los que llevan medio año sin
|
|
un push.
|
|
|
|
### Diferencias de Gitea que resuelve el driver
|
|
|
|
La API de Gitea se parece a la de GitHub pero no es igual, y lo que cambia está
|
|
encapsulado en [`GiteaProvider`](app/Services/Git/GiteaProvider.php):
|
|
|
|
- Los contadores se llaman `stars_count` / `forks_count`, no `stargazers_count`.
|
|
- Los topics están en su propio endpoint, no en el payload del repositorio.
|
|
- Las issues aceptan `type=issues`, así que los pull requests se filtran en el
|
|
servidor (en GitHub hay que descartarlos a mano).
|
|
- La autenticación usa el esquema `token`, no `Bearer`.
|
|
- **No hay endpoint de contribuidores.** El número se deriva de los autores
|
|
distintos de los últimos 100 commits. Es una aproximación deliberada: para los
|
|
repos de un colectivo pequeño el historial reciente cubre a todo el mundo que
|
|
está realmente involucrado, y cuesta una petición en vez de recorrer el log.
|
|
- No expone licencia en el payload del repo, así que ese campo queda vacío.
|
|
|
|
Sin `GITEA_URL` el proveedor ni siquiera se ofrece en el panel: un driver que no
|
|
sabe adónde apuntar solo produciría fallos más tarde.
|
|
|
|
El driver de **GitHub** sigue disponible para mirrors o algún repo alojado
|
|
fuera. Añadir GitLab es escribir una clase que implemente
|
|
[`RepositoryProvider`](app/Services/Git/Contracts/RepositoryProvider.php) y
|
|
registrarla en `config/git.php`, sin tocar nada más.
|
|
|
|
## API pública (para el frontend)
|
|
|
|
Sin autenticación y de solo lectura. Todo lo que devuelve ya está publicado en
|
|
la web, así que pedir un token añadiría ceremonia sin añadir protección. Lo que
|
|
**no** está publicado se protege con el secreto de preview.
|
|
|
|
```
|
|
GET /api/public/v1/site idiomas + cifras de cabecera
|
|
GET /api/public/v1/slugs todas las URLs publicadas, por idioma
|
|
GET /api/public/v1/projects[/{slug}]
|
|
GET /api/public/v1/members[/{slug}]
|
|
GET /api/public/v1/calls[/{slug}]
|
|
GET /api/public/v1/pages[/{slug}]
|
|
GET /api/public/v1/technologies
|
|
GET /api/public/v1/radar
|
|
GET /api/public/v1/good-first-issues
|
|
|
|
POST /api/public/v1/calls/{slug}/apply responder a una convocatoria
|
|
POST /api/public/v1/contact escribir sin convocatoria concreta
|
|
|
|
POST /api/public/v1/telemetry/not-found lo que la web no supo resolver
|
|
POST /api/public/v1/telemetry/views lo que la web ha mostrado
|
|
```
|
|
|
|
Los dos últimos llevan token (`X-Ingest-Token`): los llama el frontend en su
|
|
propio nombre, no un visitante. Están descritos en [URLs que nadie
|
|
encuentra](#urls-que-nadie-encuentra) y [Qué se lee de
|
|
verdad](#qué-se-lee-de-verdad).
|
|
|
|
Tres decisiones que conviene conocer:
|
|
|
|
**Se resuelve por slug dentro del idioma activo**, no por id, porque es el
|
|
patrón de acceso real de un frontend SSR: recibe una URL. Un slug catalán no
|
|
resuelve en castellano, así que un mismo registro nunca responde en dos
|
|
direcciones.
|
|
|
|
**Cada respuesta trae `alternates`**, la ruta del mismo registro en cada idioma
|
|
que tenga traducción:
|
|
|
|
```json
|
|
{
|
|
"es": "/proyectos/mi-web",
|
|
"en": "/en/projects/my-web",
|
|
"ca": "/ca/projectes/el-meu-web"
|
|
}
|
|
```
|
|
|
|
Solo esta API sabe que esas tres URLs son la misma cosa, así que sin esto el
|
|
frontend no puede emitir `hreflang` correctos y Google trata los tres idiomas
|
|
como contenido duplicado. Los idiomas sin traducción se omiten, para no
|
|
anunciar una URL que daría 404.
|
|
|
|
**El rate limit está pensado para SSR.** Con renderizado en servidor todas las
|
|
visitas llegan desde la IP del servidor del frontend; un límite por IP
|
|
dimensionado para una persona estrangularía la web entera. De ahí
|
|
`SITE_RATE_LIMIT` (600/min por defecto) y `SITE_TRUSTED_IPS` para saltárselo del
|
|
todo.
|
|
|
|
Las respuestas llevan `ETag`, `Cache-Control: s-maxage`,
|
|
`stale-while-revalidate` y `Vary: Accept-Language`. Esta API está en el camino
|
|
crítico de cada visita: si va lenta, la web va lenta. El
|
|
`stale-while-revalidate` hace que una caída se degrade en contenido algo viejo
|
|
en vez de una página rota.
|
|
|
|
### El buzón (los únicos endpoints de escritura)
|
|
|
|
Hasta aquí la API pública era de solo lectura, así que una convocatoria
|
|
terminaba en «esto es lo que necesitamos» sin ningún sitio donde contestar.
|
|
Estos dos endpoints cierran ese circuito:
|
|
|
|
```
|
|
POST /api/public/v1/calls/{slug}/apply responder a una convocatoria
|
|
POST /api/public/v1/contact escribir sin convocatoria concreta
|
|
```
|
|
|
|
```bash
|
|
curl -X POST https://admin.example/api/public/v1/calls/backend/apply \
|
|
-H 'Content-Type: application/json' -H 'Accept-Language: ca' \
|
|
-d '{"name":"Ada","email":"ada@example.org",
|
|
"message":"Tinc unes hores els caps de setmana...",
|
|
"hours_per_week":4,"consent":true}'
|
|
```
|
|
|
|
Lo que llega aparece en **Colectivo → Buzón**, con la campana del panel
|
|
avisando a quien pueda leerlo. Cada solicitud guarda el idioma en que se
|
|
escribió, para contestar en el mismo.
|
|
|
|
Cinco decisiones que conviene conocer:
|
|
|
|
**Solo una convocatoria abierta acepta solicitudes** (404 en cualquier otro
|
|
caso). Hacer que alguien redacte un mensaje para un puesto ya cubierto es peor
|
|
que no enseñarle formulario.
|
|
|
|
**El honeypot archiva, no descarta.** Un envío con el campo trampa relleno se
|
|
guarda como `spam` y responde 201 igual que el resto: el bot no aprende nada, y
|
|
la persona real a la que el gestor de contraseñas le rellenó el campo oculto se
|
|
puede rescatar desde la pestaña de spam. Un mensaje tirado a la basura no.
|
|
|
|
**Repetir el envío no duplica.** El mismo email a la misma convocatoria dentro
|
|
de la ventana de deduplicación (24 h) responde 200 con `duplicate: true` y no
|
|
crea otra fila. Un doble clic en «enviar» no es una segunda persona, y
|
|
devolverle un error solo hace que insista.
|
|
|
|
**El límite de envíos no es por IP.** Con SSR todos los envíos del sitio llegan
|
|
desde la IP del frontend —que además está en `SITE_TRUSTED_IPS`—, así que un
|
|
límite por IP no protegería de nada. El presupuesto se gasta **por dirección de
|
|
email** (3/min) con un techo global por hora (60) detrás.
|
|
|
|
**`consent` es obligatorio.** Son datos personales de alguien que no es usuario
|
|
de este sistema, así que el consentimiento viaja en la petición en vez de darse
|
|
por supuesto. Si el formulario del frontend no lo envía, la API responde 422.
|
|
|
|
El buzón lleva permiso propio (`applications.*`) y **el rol `viewer` no lo
|
|
tiene**: leer el contenido de la web y leer los datos de contacto de quien
|
|
escribe son dos cosas distintas a propósito. De la IP solo se guarda un HMAC con
|
|
la clave de la aplicación, suficiente para reconocer una avalancha y no para
|
|
convertir la tabla en un registro de visitantes.
|
|
|
|
Si un día se llena de spam, `SITE_ACCEPT_SUBMISSIONS=false` cierra los
|
|
formularios sin desplegar; `/site` devuelve `submissions_enabled: false` para
|
|
que el frontend los oculte en vez de enseñar un formulario que sería rechazado.
|
|
|
|
### Preview de borradores
|
|
|
|
```bash
|
|
curl -H "X-Preview-Token: $SITE_PREVIEW_TOKEN" \
|
|
https://admin.example/api/public/v1/projects/mi-borrador
|
|
```
|
|
|
|
El frontend reenvía el secreto desde su servidor, así que nunca llega al
|
|
navegador. Las respuestas de preview se marcan `no-store`. Un
|
|
`SITE_PREVIEW_TOKEN` vacío **no** concede acceso a nadie.
|
|
|
|
Esto **da por supuesto el renderizado en servidor**: es la única forma de que el
|
|
secreto viaje sin acabar en el navegador de cualquiera. Un frontend que
|
|
renderizara en cliente necesitaría otra cosa —un token de preview por sesión y
|
|
con caducidad—, no este secreto compartido.
|
|
|
|
## La vitrina: servicios, clientes, testimonios y charlas
|
|
|
|
Cuatro tipos de contenido con los que se compone una landing o un portfolio.
|
|
**No son bloques de portada**: aquí no se decide qué secciones hay ni en qué
|
|
orden. Cada uno tiene su endpoint y el frontend decide qué monta.
|
|
|
|
```
|
|
GET /api/public/v1/services[/{slug}]
|
|
GET /api/public/v1/clients
|
|
GET /api/public/v1/testimonials?client=…&service=…
|
|
GET /api/public/v1/talks
|
|
```
|
|
|
|
**Servicios** — el reverso de un proyecto: uno dice lo que se ha hecho, el otro
|
|
lo que se puede pedir. Lo que lo hace algo más que una tarjeta bonita es la
|
|
relación con proyectos: **un servicio que no puede señalar trabajo real es una
|
|
promesa**, y el panel lo marca en naranja cuando lleva cero. Tienen slug por
|
|
idioma, `alternates` y entran en `/slugs`, así que el frontend puede darles
|
|
página propia.
|
|
|
|
**Clientes** — antes eran dos campos de texto (`beneficiary`,
|
|
`beneficiary_url`) escritos a mano en cada proyecto: la misma asociación
|
|
tecleada dos veces eran dos entidades distintas y el logo no tenía dónde vivir.
|
|
Ahora es una tabla, la migración convierte sola lo que ya había, y el muro de
|
|
logos sale gratis. Con `is_public`, porque salir en la web de otro es decisión
|
|
suya, y sin proyectos publicados detrás no aparece: sería un logo que la web no
|
|
puede justificar.
|
|
|
|
**Testimonios** — cuelgan de un cliente, un proyecto o un servicio, **no de la
|
|
portada**. Así cada página pide los suyos en vez de haber una lista suelta que
|
|
alguien tiene que recordar actualizar.
|
|
|
|
Y llevan `original_locale`: **traducir una cita es ponerle palabras en la boca a
|
|
alguien**. La API manda `is_original: false` cuando lo que estás leyendo lo
|
|
hemos traducido nosotros, para que la web pueda decir «traducido del
|
|
castellano» en vez de fingir que lo dijo así.
|
|
|
|
**Charlas** — el contenido más barato de mantener y el único que demuestra que
|
|
el colectivo existe fuera de su propia web. El título **no se traduce**: es el
|
|
nombre de una charla que se dio una vez y en un idioma; traducirlo inventaría
|
|
una charla que no existió. Solo se traduce el resumen escrito después. Se
|
|
escriben una vez y salen a la vez en la landing y en el perfil de quien la dio.
|
|
|
|
## Publicación programada y caducidad
|
|
|
|
Dos fechas por registro: cuándo entra y cuándo sale.
|
|
|
|
```
|
|
published_at con fecha futura, no sale en la web hasta que llegue
|
|
unpublish_at al llegar, desaparece sola
|
|
```
|
|
|
|
**Lo hacen las consultas, no un job.** Los scopes `published()` leen las dos
|
|
fechas, así que la ventana se respeta esté corriendo lo que esté corriendo: un
|
|
worker caído no puede devolver a la web el aviso del mes pasado. El scheduler no
|
|
es lo que la hace funcionar, es solo lo que mantiene honesto lo que el panel
|
|
dice.
|
|
|
|
Porque ese es el otro problema: **un panel que pone «Publicada» sobre algo que
|
|
la web no está enseñando le está mintiendo a quien lo escribió**. Ahora la
|
|
columna de estado dice «Programada · dentro de 3 días» o «Caducada · el 12 de
|
|
mayo», con filtros para las dos, y avisa de lo que está a punto de irse.
|
|
|
|
```bash
|
|
php artisan content:schedule # cierra convocatorias vencidas
|
|
php artisan content:schedule --dry-run # dice qué haría
|
|
```
|
|
|
|
El scheduler lo ejecuta cada cinco minutos. Cierra las convocatorias cuya fecha
|
|
pasó —ya no salían en la web, pero seguían inflando el contador de la barra
|
|
lateral, y un contador que miente deja de leerse— y registra en el log lo que
|
|
está esperando fecha o lo que acaba de caducar.
|
|
|
|
**Lo que a propósito no hace:** tocar el estado de páginas y proyectos. Su
|
|
ventana ya las oculta, y reescribir un estado tiraría el que alguien eligió para
|
|
grabar algo que las fechas ya dicen. En un proyecto, además, el estado es su
|
|
ciclo de vida («en marcha», «en pausa»), que no es lo mismo que si se ve o no:
|
|
por eso la visibilidad tiene columna propia en la tabla en vez de pisar la otra.
|
|
|
|
`unpublish_at` viaja también en la API pública, para que una caché de más abajo
|
|
pueda caducar a la vez que el contenido en vez de quedarse con un aviso que ya
|
|
no es cierto.
|
|
|
|
## Redirecciones
|
|
|
|
Cambiar un slug rompe todos los enlaces compartidos, todos los marcadores y
|
|
todos los resultados de búsqueda — **en silencio**, porque aquí no se entera
|
|
nadie y la web solo lo descubre cuando un visitante recibe un 404.
|
|
|
|
El momento en que el slug cambia es el único en que la dirección vieja todavía
|
|
se conoce, así que es cuando se apunta:
|
|
|
|
```
|
|
GET /api/public/v1/redirects todas las reglas activas, de una vez
|
|
```
|
|
|
|
Se crean solas para páginas, proyectos, convocatorias y servicios, **una por
|
|
idioma** —la URL catalana tiene otro prefijo y otro segmento, así que una regla
|
|
nunca podría cubrir las dos—. Y no encadenan: si algo se renombra dos veces,
|
|
las dos direcciones viejas apuntan a la actual, no una a otra. Renombrar de
|
|
vuelta al slug original borra la regla en vez de crear un bucle.
|
|
|
|
En **Administración → Redirecciones** se pueden añadir a mano las de enlaces
|
|
anteriores a esta web. La columna de usos dice si alguien sigue viniendo por
|
|
ahí, que es lo que decide si una regla se puede borrar algún día.
|
|
|
|
Dos comprobaciones vigilan lo que el propio panel escribe: una regla que
|
|
**apunta a sí misma o a otra regla** (el navegador da vueltas o paga saltos de
|
|
más) y una que **lleva a una página que ya no existe**, que es peor que el 404
|
|
que venía a evitar — pasa cuando se despublica lo que motivó la regla, que es
|
|
algo perfectamente normal y no deja rastro en ningún otro sitio.
|
|
|
|
### URLs que nadie encuentra
|
|
|
|
Todo lo anterior tiene un punto ciego: **las redirecciones solo se escriben
|
|
desde este lado**. Cubren los slugs que se renombran aquí, que es de lo único
|
|
que esta aplicación se entera. Los enlaces de la web anterior, una dirección
|
|
impresa en un papel, el enlace que otro sitio puso hace tres años a una página
|
|
que ya se movió — todo eso falla en la web, delante de un visitante, y aquí no
|
|
se sabe nunca.
|
|
|
|
Así que lo cuenta el frontend:
|
|
|
|
```
|
|
POST /api/public/v1/telemetry/not-found lo que no ha sabido resolver
|
|
```
|
|
|
|
```bash
|
|
curl -X POST https://admin.example/api/public/v1/telemetry/not-found \
|
|
-H "X-Ingest-Token: $SITE_INGEST_TOKEN" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"path":"/proyectos/nombre-viejo","locale":"es",
|
|
"referrer":"https://otro-sitio.example/articulo"}'
|
|
```
|
|
|
|
Aparecen en **Administración → URLs no encontradas**, ordenadas por número de
|
|
visitas, y cada fila tiene el único botón que importa: **crear la redirección**,
|
|
con la dirección rota ya puesta. Solo hay que decir a dónde debería llevar. La
|
|
regla se escribe en la lista de al lado y la entrada deja de estar pendiente.
|
|
|
|
Seis decisiones que conviene conocer:
|
|
|
|
**Lleva token, a diferencia del resto de la API pública.** Los endpoints de
|
|
lectura no lo necesitan porque devuelven lo que ya está publicado, y el buzón
|
|
lo escribe un visitante rellenando un formulario. Este lo llama el frontend en
|
|
su propio nombre, así que puede guardar un secreto — y un endpoint de escritura
|
|
sin autenticar es una forma de llenar una tabla con lo que a cualquiera se le
|
|
ocurra. `SITE_INGEST_TOKEN` vacío **desactiva** el endpoint; no lo abre.
|
|
|
|
**Es un contador, no un registro.** Una fila por (dirección, idioma) con el
|
|
número de visitas. Un rastreador recorriendo una sección muerta cuesta una fila,
|
|
no una por petición, y ese número es justo lo que decide si una dirección merece
|
|
redirección. Acepta lotes de hasta 50.
|
|
|
|
**Las direcciones se normalizan igual que en las redirecciones.** `/algo/` y
|
|
`/algo?utm_source=x` son la misma dirección rota. Si las dos normalizaciones se
|
|
separaran, la lista se llenaría de direcciones que ya tienen regla.
|
|
|
|
**Se tira lo que nunca estuvo aquí.** `/wp-admin`, `/.env`, cualquier cosa
|
|
acabada en `.php`: casi todo lo que se le pide a una web pública es alguien
|
|
buscando el panel de otro, y una lista donde los enlaces rotos de verdad están
|
|
enterrados debajo de eso es una lista que no abre nadie. La respuesta dice
|
|
cuántos reportes se guardaron de los que llegaron, para que el frontend pueda
|
|
comparar su filtro con este.
|
|
|
|
**La procedencia es la columna que más dice.** Si el `referrer` es de nuestro
|
|
propio dominio, no es el marcador viejo de alguien: es que **algo que
|
|
publicamos enlaza a una dirección que no existe**. El panel lo marca en rojo y
|
|
el check de salud lo sube de aviso a fallo.
|
|
|
|
**Ignorar y resolver no son lo mismo cuando la dirección vuelve.** Una resuelta
|
|
que se vuelve a pedir regresa a pendiente —la redirección se borró, o nunca
|
|
llegó a funcionar—; una ignorada se queda ignorada por muchas visitas que reciba,
|
|
porque si no el botón no serviría dos veces.
|
|
|
|
El check **URLs que nadie encuentra** cuenta las pendientes con más de una
|
|
visita: una sola petición es tan probable que sea un escáner como una persona, y
|
|
un número que no llega nunca a cero deja de leerse. `content:prune` se lleva las
|
|
de una sola visita que llevan 30 días sin repetirse; las ignoradas y las
|
|
resueltas se quedan, porque olvidarlas traería la misma decisión otra vez la
|
|
semana siguiente.
|
|
|
|
### Qué se lee de verdad
|
|
|
|
El panel siempre ha sabido qué existe y qué está publicado. Nunca ha sabido si
|
|
alguien lo abre — y esa es la mitad que falta de casi cualquier decisión
|
|
editorial: **qué merece traducirse primero**, si el proyecto destacado lo mira
|
|
alguien, si una página compensa el trabajo de mantenerla al día.
|
|
|
|
```
|
|
POST /api/public/v1/telemetry/views lo que la web ha mostrado
|
|
```
|
|
|
|
```bash
|
|
curl -X POST https://admin.example/api/public/v1/telemetry/views \
|
|
-H "X-Ingest-Token: $SITE_INGEST_TOKEN" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"views":[{"type":"project","slug":"mi-web","locale":"es","count":12},
|
|
{"type":"page","slug":"quienes-somos","locale":"ca"}]}'
|
|
```
|
|
|
|
**Esto no es analítica, y la diferencia importa.** No hay visitante aquí ni
|
|
forma de construir uno: ni IP, ni sesión, ni user agent, ni referrer, ni hora
|
|
del día. Lo que se guarda es **una fila por registro, idioma y día con un
|
|
número** — justo lo que hacen falta para las preguntas de arriba y nada que se
|
|
pueda volver a convertir en una persona. La tabla no tiene dónde meterlo, y hay
|
|
un test que lo comprueba.
|
|
|
|
Cuatro decisiones más:
|
|
|
|
**Se reporta tipo y slug, no una ruta.** La web pidió
|
|
`/public/v1/projects/mi-web` para pintar la página, así que ya sabe qué era; no
|
|
tiene que volver a parsear su propia URL. Y así un reporte solo puede nombrar
|
|
algo que esta API sirve.
|
|
|
|
**Cada idioma cuenta aparte.** Saber en qué idioma se lee algo es la mitad del
|
|
valor: es lo que dice si la traducción catalana la usa alguien.
|
|
|
|
**Un slug que ya no resuelve no cuenta.** Eso es un 404, y para eso está el
|
|
endpoint de al lado. Sumarlo aquí sería atribuir una lectura a nada.
|
|
|
|
**El `count` está acotado a 1000 por reporte.** La web cuenta sus propios
|
|
renders; no le corresponde inventarse una audiencia.
|
|
|
|
En el panel salen como columna **Visitas** (30 días, sumando idiomas) en
|
|
páginas, proyectos y servicios, con filtro **«Publicado y sin visitas»** — que
|
|
es la pregunta accionable— y un widget de **lo más leído** en el dashboard, que
|
|
no aparece hasta que hay algo que enseñar: un widget con tres ceros enseña a
|
|
ignorar esa esquina de la pantalla.
|
|
|
|
La columna está **vacía**, no a cero, cuando no hay datos: mientras el frontend
|
|
no reporte, un cero sería mentira. `content:prune` conserva 400 días
|
|
(`--views-days`), que da para comparar con la primavera pasada.
|
|
|
|
## Traducciones que se quedaron atrás
|
|
|
|
El check de traducciones que faltan encuentra el agujero: publicado en
|
|
castellano, ausente en catalán. **No encuentra el caso que de verdad pasa**: la
|
|
traducción existe —así que ninguna pantalla se queja y el registro cuenta como
|
|
terminado— y lleva dos meses diciendo algo que el original dejó de decir. Una
|
|
traducción desactualizada es peor que una que falta, porque la que falta al
|
|
menos es honesta.
|
|
|
|
No hace falta ninguna columna nueva para saberlo: **el historial ya lo sabe**.
|
|
Cada revisión guarda el mapa de idiomas entero del campo que tocó, antes y
|
|
después, así que «cuándo cambió por última vez el cuerpo en castellano y cuándo
|
|
en catalán» es una pregunta que ya se puede responder. Una columna sería una
|
|
segunda copia de eso, que alguien tendría que mantener a mano.
|
|
|
|
Se ve donde ya se miraba, en la columna de idiomas:
|
|
|
|
```
|
|
ES EN CA las tres al día
|
|
ES EN ca catalán sin traducir
|
|
ES EN CA! catalán traducido y desactualizado
|
|
```
|
|
|
|
Con filtro **«Traducción desactualizada»** en páginas, proyectos y servicios, y
|
|
un check de salud que dice desde cuándo lleva a la deriva cada idioma —«desde
|
|
marzo» es lo que decide si corre prisa.
|
|
|
|
Tres decisiones:
|
|
|
|
**El slug no cuenta.** Renombrar la dirección castellana no hace menos cierto el
|
|
texto catalán, y es el único campo traducible que cambia solo constantemente:
|
|
contarlo marcaría media web como desactualizada la primera vez que alguien
|
|
ordena una URL.
|
|
|
|
**Un idioma vacío no está desactualizado, está sin traducir**, y de eso ya
|
|
informa el otro check. Decir las dos cosas del mismo registro sería tener dos
|
|
listas que no se vacían nunca.
|
|
|
|
**Un registro que nadie ha editado nunca está al día**, se escribiera en uno o
|
|
en tres idiomas: si llegaron juntos, ninguno va por detrás de otro.
|
|
|
|
El cálculo se cachea contra el `updated_at` del registro, así que se rehace en
|
|
cuanto algo cambia y ni un minuto antes.
|
|
|
|
## Revisión
|
|
|
|
`pages.publish` lleva separado desde el principio: **poner algo en la web es un
|
|
acto mayor que escribirlo**. Lo que faltaba era la otra mitad. Quien tenía
|
|
`pages.update` y no `publish` podía terminar un texto y **no tenía forma de
|
|
decir que estaba listo** — el campo de estado que lo diría está deshabilitado
|
|
justo para esos usuarios. El traspaso ocurría por chat, o no ocurría.
|
|
|
|
Ahora son dos botones, en páginas, proyectos y servicios:
|
|
|
|
**Pedir revisión** — para quien no puede publicar. Opcionalmente se lo pide a
|
|
alguien concreto; si no, lo ve todo el que pueda publicarlo. Puede dejar una
|
|
nota. No hace falta ningún permiso nuevo: **poder editar algo ya es el derecho a
|
|
decir que has terminado con ello**.
|
|
|
|
**Devolver** — para quien sí puede publicar, cuando todavía no. El motivo es
|
|
**obligatorio**: devolver el trabajo de alguien sin decir por qué es lo que hace
|
|
que la gente deje de usar la revisión y publique por su cuenta.
|
|
|
|
No hay botón de «aprobar», porque ya existía: **publicar es la aprobación**. Al
|
|
publicar se cierra la revisión sola, venga del formulario, del botón de la tabla
|
|
o de donde sea — está enganchado al estado y no al botón, así que nada puede
|
|
dejar algo en la web y a la vez marcado como pendiente. Programar para dentro de
|
|
una semana **no** la cierra: la web todavía no lo enseña.
|
|
|
|
La pestaña **Revisión** de cada ficha guarda la conversación: qué se pidió, qué
|
|
se devolvió, por qué, y quién publicó al final. Es lo que el historial de
|
|
cambios no puede guardar — una revisión sabe que el título antes decía otra
|
|
cosa, pero no puede saber que esto volvió porque falta la foto, que es la frase
|
|
que nadie escribió en ningún campo.
|
|
|
|
Alrededor de eso:
|
|
|
|
- **La campana** avisa a quien puede publicarlo. Al devolver, avisa a quien lo
|
|
pidió y **solo** a esa persona: una cola que anuncia cada decisión a todo el
|
|
mundo es una campana que se deja de leer.
|
|
- **El dashboard** dice cuántas cosas esperan tu revisión. La campana se marca
|
|
como leída y desaparece; esto no.
|
|
- **Filtro «Esperando mi revisión»** en las tres tablas. Las peticiones sin
|
|
destinatario salen en la de todos: una revisión que no se pidió a nadie es de
|
|
todos, que sigue siendo mejor que una dirigida a quien el autor adivinó que
|
|
estaba libre.
|
|
- **Check de salud** para lo que lleva más de una semana esperando. Es el fallo
|
|
que introduce este paso: antes, quien no podía publicar estaba atascado y lo
|
|
sabía; ahora está esperando y cree que algo está pasando.
|
|
|
|
**Solo en páginas, proyectos y servicios**, que son los tres con permiso de
|
|
publicación propio. Añadir revisión a un testimonio sería ceremonia: quien puede
|
|
escribirlo puede publicarlo, así que no hay a quién preguntar.
|
|
|
|
> **Esto solo se activa para quien pueda editar y no publicar**, que es el rol
|
|
> **writer**. `editor` tiene todas las habilidades de contenido, `publish`
|
|
> incluida, así que a un editor los botones no le salen: no los necesita.
|
|
> Después de actualizar, `php artisan permissions:sync` crea el rol nuevo.
|
|
|
|
### En lote
|
|
|
|
Los dos botones de revisión existen también para una selección entera:
|
|
**Pedir revisión** y **Publicar**, en la barra de acciones en lote de páginas,
|
|
proyectos y servicios.
|
|
|
|
Ninguno de los dos trata la selección como un bloque. **Publicar** salta lo que
|
|
ya está publicado; **Pedir revisión** salta lo que ya espera revisión o ya está
|
|
publicado — una selección de veinte filas en estados mezclados es el caso
|
|
normal, no uno raro que haya que evitar. El aviso final dice cuántas se tocaron
|
|
de verdad, no cuántas se marcaron.
|
|
|
|
Los registros se guardan **uno a uno, no en una sola consulta**: publicar
|
|
dispara el cierre de la revisión pendiente y la entrada en el historial de
|
|
cambios, y una única `UPDATE` sobre toda la selección se saltaría las dos cosas
|
|
sin avisar — el mismo motivo por el que `content:prune` ya recorre la papelera
|
|
registro a registro en vez de en bloque.
|
|
|
|
La visibilidad de los botones se decide **una vez, por el permiso**
|
|
(`pages.publish`, no una comprobación registro a registro): a diferencia de
|
|
`update`, que en páginas tiene una excepción por autoría, publicar en lote no
|
|
tiene ese matiz, y preguntárselo a la política de un modelo sin un registro
|
|
concreto no tiene sentido.
|
|
|
|
## Ediciones simultáneas
|
|
|
|
Dos personas pueden abrir la misma ficha a la vez, y hasta ahora el panel no
|
|
tenía ni idea. Quien guardaba segundo pisaba a quien guardaba primero **sin
|
|
avisar a nadie**: sin error, sin rastro más allá de una revisión que parece una
|
|
edición cualquiera.
|
|
|
|
Dos avisos, uno por cada momento en que la colisión se puede notar:
|
|
|
|
**Al abrir la ficha**, si alguien más la tiene abierta ahora mismo, se dice
|
|
quién. Solo se avisa a quien llega segundo — quien ya estaba no tiene por qué
|
|
esperar compañía, y no hay nada que este aviso pudiera decirle que le sirviera
|
|
de algo.
|
|
|
|
**Al guardar**, si el registro cambió por debajo mientras se editaba, **se
|
|
rechaza el guardado**. Este es el que de verdad evita perder trabajo, y no
|
|
depende de que el aviso anterior se viera ni de que llegara a mostrarse: una
|
|
pestaña cerrada, un segundo monitor, alguien que guardó hace diez minutos, todo
|
|
acaba atrapado igual, porque se comprueba la base de datos en el momento de
|
|
guardar, no lo que decía un aviso anterior. Cuando el historial de la ficha
|
|
sabe quién fue, el mensaje lo nombra.
|
|
|
|
```
|
|
Alguien más lo tiene abierto al entrar, si aplica
|
|
No se ha guardado — María lo cambió al guardar, si aplica
|
|
```
|
|
|
|
Aplicado a **las dieciséis pantallas de edición del panel**, no elegido recurso
|
|
por recurso: una colisión es cosa de dos personas y un registro, no de qué sea
|
|
el registro.
|
|
|
|
Tres decisiones:
|
|
|
|
**Quién ha «entrado» se olvida solo a los 10 minutos** sin interacción. No hay
|
|
forma de saber que alguien cerró la pestaña, así que la presencia es en
|
|
realidad «esto se abrió hace poco y alguien sigue tecleando en ello» — la
|
|
forma correcta de plantear el problema: importa que dos personas estén
|
|
editando a la vez, no un recuento exacto.
|
|
|
|
**La comprobación es de segundo entero**, porque así se guarda `updated_at`
|
|
aquí. Dos guardados dentro del mismo segundo son una carrera real incluso en
|
|
producción; el aviso es honesto en que no la detecta.
|
|
|
|
**El valor de referencia viaja bloqueado.** Livewire solo conserva entre
|
|
peticiones las propiedades públicas de un componente — una protegida se
|
|
reiniciaría a `null` en cada guardado y desactivaría el aviso en silencio, que
|
|
es exactamente el fallo que apareció al escribir esto—, y pública a secas
|
|
podría venir manipulada por la propia petición. Va marcada `#[Locked]`, igual
|
|
que el registro que edita.
|
|
|
|
## Historial de cambios
|
|
|
|
Cada modelo de contenido guarda quién cambió qué, con el valor de antes al lado
|
|
del de después. Solo lo que cambió: guardar registros enteros haría el historial
|
|
más grande que el contenido que describe.
|
|
|
|
Lo que más se echaba en falta es **quién publicó**. `pages.publish` existe como
|
|
permiso aparte precisamente porque poner algo en la web es un acto mayor que
|
|
escribirlo, y no quedaba constancia; ahora publicar y despublicar salen con
|
|
nombre propio en la lista, distintos de un «editado» cualquiera.
|
|
|
|
Se ve en la pestaña **Historial** de cada ficha, con el diff campo por campo y
|
|
un botón para volver a esos valores. Restaurar también queda registrado, así que
|
|
nunca se pierde el camino de vuelta.
|
|
|
|
Un cambio hecho por el scheduler o por un comando se registra **sin usuario** y
|
|
lo dice, en vez de echarle la culpa al último que entró.
|
|
|
|
## Salud del sitio
|
|
|
|
La web está en otro repositorio y en otra máquina, así que sus fallos no se ven
|
|
editando: se ven visitándola, normalmente tarde y normalmente los ve un
|
|
visitante. **Administración → Salud del sitio** los junta en una página, con un
|
|
contador en la barra lateral.
|
|
|
|
Trece comprobaciones, ordenadas por gravedad:
|
|
|
|
| | Comprueba | Por qué importa |
|
|
|---|---|---|
|
|
| 🔴 | Slugs que faltan | Traducido a un idioma sin slug en él: esa URL no existe o da 404, y el `hreflang` sale mal. |
|
|
| 🔴 | Slugs repetidos | Dos registros en la misma URL. La regla lo impide al escribir, pero un slug vive dentro de un JSON y **nunca puede ser una restricción de base de datos**: un seeder o una importación se cuelan. |
|
|
| 🔴 | Imágenes rotas | El archivo ya no está en disco. La API sigue publicando la URL porque no tiene motivo para dudar. |
|
|
| 🔴 | Gente esperando respuesta | Alguien escribió hace más de una semana y sigue sin contestación. |
|
|
| 🔴 | URLs que nadie encuentra | Direcciones que dan 404 y se piden más de una vez. En rojo cuando el enlace roto sale de nuestra propia web; en amarillo cuando es de fuera. |
|
|
| 🟡 | Contenido sin traducir | Publicado pero ausente en algún idioma. |
|
|
| 🟡 | Traducciones desactualizadas | El original cambió después de traducirse. La traducción existe, así que nada más lo avisa. |
|
|
| 🟡 | Imágenes sin describir | Sin `alt`. La API manda `alt: ""`, que es la forma correcta de decir «decorativa», así que el frontend no distingue el blanco deliberado del olvidado. Esto sí. |
|
|
| 🟡 | Fichas incompletas | Sin meta descripción o sin portada: nada que enseñar en un buscador ni al pegar el enlace en un chat. |
|
|
| 🟡 | Revisiones sin contestar | Alguien pidió revisión hace más de una semana. Antes estaba atascado y lo sabía; ahora espera y cree que algo está pasando. |
|
|
| 🟡 | Convocatorias vencidas | Abiertas con la fecha pasada. Ya no salen en la web, pero inflan el contador del panel. |
|
|
| 🟡 | Imágenes sin procesar | Más de una hora en cola: no hay worker ni scheduler. |
|
|
| 🟡 | Repositorios que no sincronizan | La web sigue enseñando los últimos números, que ya no son ciertos. |
|
|
|
|
Cada resultado trae **ejemplos, no solo un número**: «12 páginas» es una
|
|
estadística, «12, empezando por estas» es algo sobre lo que actuar.
|
|
|
|
**Los checks se ocultan según permisos.** Contar solicitudes es saber que
|
|
existen, así que quién puede verlo es la misma pregunta que quién puede leer el
|
|
buzón; el `viewer` no ve ese check ni el de repositorios. El comando, en cambio,
|
|
los ejecuta todos: un despliegue no es una persona a quien esconderle nada.
|
|
|
|
```bash
|
|
php artisan site:check # falla si hay avisos o fallos
|
|
php artisan site:check --fail-on=error # falla solo con lo que rompe la web
|
|
php artisan site:check --fail-on=never # solo informa
|
|
php artisan site:check --json
|
|
```
|
|
|
|
Sale con código distinto de cero, así que un despliegue puede negarse a
|
|
terminar sobre una web que ya está rota.
|
|
|
|
**El contador de la barra lateral está cacheado cinco minutos**, porque se
|
|
dibuja en cada página del panel y ejecutar diez comprobaciones para pintar un
|
|
número haría el panel entero más lento. Abrir la página lo invalida: es el
|
|
momento en que sí queremos respuestas frescas.
|
|
|
|
Añadir una comprobación es escribir una clase que implemente
|
|
[`Check`](app/Support/Health/Check.php) y listarla en `HealthReport::CHECKS`.
|
|
|
|
### Herramientas del día a día
|
|
|
|
**Dashboard** — arriba del todo, lo que está esperando a alguien: gente sin
|
|
respuesta, contenido que caduca, servicios sin obra detrás. Son las mismas
|
|
comprobaciones de salud en el sitio donde todo el mundo aterriza, en lugar de
|
|
contadores que no cambian nunca.
|
|
|
|
**Papelera** (Administración → Papelera) — todo lo borrado junto, con el botón
|
|
de deshacer al lado. Antes recuperar algo exigía acordarse de en qué recurso
|
|
estaba y activar un filtro, que es mucho pedirle a quien acaba de borrar lo que
|
|
no era.
|
|
|
|
**Vista previa** — un enlace firmado por registro y por idioma, que caduca en 8
|
|
horas y **solo abre ese registro**. El `SITE_PREVIEW_TOKEN` compartido sigue
|
|
funcionando, pero es un secreto único que hay que pegar en un curl; esto se
|
|
puede mandar por chat sin dar acceso a todos los borradores del sitio.
|
|
|
|
**Duplicar** — en páginas, proyectos, servicios y convocatorias, en la tabla y
|
|
en el formulario, y por API (`POST /api/v1/{tipo}/{id}/duplicate`). La copia es
|
|
un **borrador**: mismo contenido, misma tecnología, mismo equipo con sus roles,
|
|
pero sin publicar, fuera de cualquier revisión, con el título marcado
|
|
«(copia)» en cada idioma y un slug libre (`hola-copia`, `hola-copia-2`…). Se
|
|
abre en cuanto se crea. Antes la copia salía sin slug —un registro sin
|
|
dirección, que la salud del sitio marcaba— y conservaba el estado: duplicar una
|
|
página publicada daba otra «publicada» sin URL.
|
|
|
|
**Próximos vencimientos** — en el dashboard, las convocatorias que cierran y
|
|
el contenido que sale de la web solo (`unpublish_at`) en las próximas dos
|
|
semanas, a quien puede editarlos. Y `content:expiring`, cada día a las 8, avisa
|
|
en la campana del panel a esas mismas personas **una semana y un día antes**
|
|
(`site.expiry_reminders`): dos avisos por cosa, no uno cada mañana durante
|
|
quince días. Nada de esto es un error —las fechas se pusieron a propósito— y
|
|
por eso no está en la salud del sitio; pero una convocatoria que cierra el
|
|
viernes sin que nadie haya contestado al buzón se ve mejor con una semana de
|
|
margen.
|
|
|
|
**Vista previa en el formulario** — el mismo botón de vista previa está también
|
|
arriba de la edición de páginas, proyectos, servicios y convocatorias: se mira
|
|
en la web real sin salir de lo que se está escribiendo.
|
|
|
|
**Búsqueda global** — los doce recursos, desde la barra de arriba.
|
|
|
|
### Versión de contenido
|
|
|
|
`/site` devuelve `content_version`, un número que sube cuando cambia algo
|
|
**publicado**. Úsalo como parte de la clave de caché del frontend y todo lo que
|
|
hay por debajo caduca en el momento en que la web cambió de verdad, en vez de cuando
|
|
lo dice un temporizador. Sin webhook de purga, sin secreto compartido y sin nada
|
|
que desplegar.
|
|
|
|
Editar un borrador no lo mueve: nadie lo está viendo. Despublicar sí, porque
|
|
desaparecer es tan visible como aparecer.
|
|
|
|
### Limpieza
|
|
|
|
Cinco cosas se acumulan para siempre y ninguna se ve: el historial, la
|
|
papelera, los archivos de imagen cuyo registro ya no existe y las dos que
|
|
reporta el frontend, los 404 y las cifras de lectura. Por separado son insignificantes; en un par de años son la
|
|
mayor parte de la base de datos y del disco.
|
|
|
|
```bash
|
|
php artisan content:prune --dry-run # dice qué se iría
|
|
php artisan content:prune # historial > 1 año y papelera > 90 días
|
|
php artisan content:prune --media # además, imágenes que no usa nadie
|
|
```
|
|
|
|
Lo que reporta el frontend —los 404 y las cifras de lectura— es lo único que
|
|
crece sin que nadie del colectivo haga nada, así que se limpia por defecto en
|
|
vez de detrás de un flag: de los 404 solo se van los de una sola visita que
|
|
llevan 30 días sin repetirse (`--missing-days`), y de las lecturas lo anterior a
|
|
400 días (`--views-days`).
|
|
|
|
Es deliberadamente conservador: **nunca corre solo** —no está en el scheduler—,
|
|
las ventanas son generosas, y del historial de cada registro **siempre se
|
|
conservan la primera y la última entrada**, que son los dos datos que alguien
|
|
pregunta años después: cuándo apareció y quién lo tocó por última vez.
|
|
|
|
Los archivos van aparte con `--media` porque borrar uno es lo único aquí que no
|
|
se arregla restaurando una fila. Una imagen de algo que está en la papelera **no
|
|
se toca**: se puede recuperar, y recuperarlo con la imagen rota sería peor.
|
|
|
|
## Modo mantenimiento
|
|
|
|
Pone en mantenimiento **solo la web pública**. Deliberadamente *no* es
|
|
`artisan down`, que tumbaría también el panel justo cuando hace falta para
|
|
arreglar lo que se ha roto.
|
|
|
|
Mientras está activo:
|
|
|
|
- La API pública responde **503** con el aviso traducido, `Retry-After` y
|
|
`Cache-Control: no-store` (un 503 cacheado sobreviviría al mantenimiento).
|
|
- El panel y la API de administración **siguen funcionando**: se puede seguir
|
|
editando y los repositorios se siguen sincronizando.
|
|
- El equipo ve la web real usando el token de preview o desde una IP de
|
|
`SITE_TRUSTED_IPS`, así que se puede comprobar el arreglo antes de levantarlo.
|
|
|
|
Desde el panel: **Administración → Modo mantenimiento**, con el aviso editable
|
|
en los tres idiomas y una hora de levantado automático opcional (para que un
|
|
mantenimiento olvidado no deje la web caída indefinidamente). Mientras está
|
|
activo, la barra lateral lo marca en rojo.
|
|
|
|
Desde la CLI, para scripts de despliegue:
|
|
|
|
```bash
|
|
php artisan site:maintenance --message="Volvemos en un rato" --until="+2 hours"
|
|
php artisan site:maintenance --status
|
|
php artisan site:maintenance --up
|
|
```
|
|
|
|
El cuerpo del 503 es lo que el frontend renderiza como página de mantenimiento:
|
|
|
|
```json
|
|
{
|
|
"message": "Estamos migrando el Gitea. Volvemos en un rato.",
|
|
"maintenance": true,
|
|
"locale": "es",
|
|
"since": "2026-08-13T10:01:13+00:00",
|
|
"until": "2026-08-13T12:01:13+00:00"
|
|
}
|
|
```
|
|
|
|
Activarlo es un permiso propio (`settings.maintenance`), separado de editar
|
|
ajustes: los editores ven el estado pero no pueden tumbar la web.
|
|
|
|
## API REST de administración
|
|
|
|
Documentada junto con la pública en [`public/openapi.yaml`](public/openapi.yaml)
|
|
(OpenAPI 3.0), navegable en **`/docs/api`**.
|
|
|
|
```
|
|
POST /api/v1/auth/token canjear credenciales por un token
|
|
GET /api/v1/me quién soy + mis permisos efectivos
|
|
GET /api/v1/pages filtros: status, category_id, search,
|
|
translated_in, trashed, sort, per_page
|
|
POST /api/v1/pages
|
|
GET /api/v1/pages/{page}
|
|
PATCH /api/v1/pages/{page}
|
|
DELETE /api/v1/pages/{page}
|
|
POST /api/v1/pages/{page}/restore
|
|
/api/v1/categories CRUD completo
|
|
GET /api/v1/users PATCH para roles, is_active, locale
|
|
GET /api/v1/roles solo lectura
|
|
GET /api/v1/roles/catalogue catálogo de permisos agrupado
|
|
/api/v1/projects CRUD + restore; relaciones por ids:
|
|
technology_ids, service_ids, members[{id, role}]
|
|
/api/v1/members CRUD + restore; links (etiqueta → URL), technology_ids
|
|
/api/v1/services CRUD + restore; technology_ids, project_ids
|
|
/api/v1/technologies CRUD completo; filtros: category,
|
|
radar_ring (none = fuera del radar), search
|
|
/api/v1/clients CRUD + restore; filtros: is_public, search
|
|
/api/v1/testimonials CRUD + restore; filtros: is_public, client_id,
|
|
project_id, service_id, search
|
|
/api/v1/talks CRUD (sin papelera, como en el panel)
|
|
/api/v1/calls CRUD; technology_ids; filtros: status,
|
|
project_id, search
|
|
GET /api/v1/applications el buzón; filtros: status (sin él, sin spam),
|
|
kind, call_id, search, trashed
|
|
PATCH /api/v1/applications/{id} solo status y notes
|
|
DELETE /api/v1/applications/{id} + POST …/restore
|
|
/api/v1/redirects CRUD; from/to normalizados, únicos por idioma
|
|
GET /api/v1/missing-urls las 404 que avisó el frontend (pending por defecto)
|
|
PATCH /api/v1/missing-urls/{id} pending ⇄ ignored
|
|
POST /api/v1/missing-urls/{id}/redirect crear la redirección que la resuelve
|
|
/api/v1/repositories CRUD (solo lo que no viene del proveedor)
|
|
POST /api/v1/repositories/{id}/sync encola una sincronización (202)
|
|
GET /api/v1/settings ajustes; PATCH /settings/{clave} sustituye el valor
|
|
GET /api/v1/maintenance modo mantenimiento de la web pública
|
|
PUT /api/v1/maintenance {enabled, message, until}
|
|
POST /api/v1/media subir una imagen (fichero o URL)
|
|
GET /api/v1/media la biblioteca; filtros: for, mime, search,
|
|
pending, failed, orphans; cada una dice si
|
|
algo la usa (referenced)
|
|
DELETE /api/v1/media/{id} solo si nada la usa (409 si no), con sus versiones
|
|
POST /api/v1/media/{id}/reprocess volver a derivar versiones (cola)
|
|
```
|
|
|
|
Publicar sigue siendo un permiso aparte, como en el panel (donde esos campos
|
|
salen desactivados): sin `projects.publish` o `services.publish`, la API rechaza
|
|
`published_at`, `unpublish_at` e `is_published`, así que quien puede escribir
|
|
algo no puede sacarlo a la web solo. Las relaciones enviadas sustituyen a las
|
|
que había, y las no enviadas no se tocan; todo se guarda en una transacción.
|
|
|
|
### Historial, revisión y vista previa
|
|
|
|
```
|
|
GET /api/v1/{tipo}/{id}/revisions historial (páginas, categorías, proyectos,
|
|
miembros, servicios, clientes, testimonios,
|
|
charlas, tecnologías, convocatorias)
|
|
POST /api/v1/revisions/{id}/restore deshacer lo que cambió una revisión
|
|
GET /api/v1/reviews lo que espera mi revisión
|
|
GET /api/v1/{tipo}/{id}/reviews el hilo (pages, projects, services)
|
|
POST /api/v1/{tipo}/{id}/comments comentar
|
|
POST /api/v1/{tipo}/{id}/review pedir revisión {reviewer_id?, note?}
|
|
POST /api/v1/{tipo}/{id}/return devolver con motivo {note}
|
|
POST /api/v1/{tipo}/{id}/publish aprobar: ya, o programado con {at}
|
|
POST /api/v1/{tipo}/{id}/unpublish
|
|
POST /api/v1/{tipo}/{id}/preview enlaces de vista previa por idioma
|
|
GET /api/v1/health la pantalla de salud del sitio
|
|
GET /api/v1/translations/pending lo que falta traducir (missing) o se quedó
|
|
atrás (stale); filtros: locale, type, published
|
|
```
|
|
|
|
Lo mismo que los botones del panel, con las mismas reglas: pedir revisión es
|
|
para quien escribe sin `{tipo}.publish`, devolver y publicar son para quien lo
|
|
tiene, y deshacer una revisión que tocó la publicación también lo pide.
|
|
Programar una publicación responde la revisión en ese momento, no cuando llega
|
|
la fecha: la decisión ya está tomada. Los enlaces de vista previa son los del
|
|
panel —un registro, un idioma, unas horas— y [`AdminContent`](app/Support/AdminContent.php)
|
|
es la tabla de qué tipo admite cada cosa, la que leen las rutas, los
|
|
controladores y el historial.
|
|
|
|
El historial ahora llama «publicado» solo a lo que publica de verdad: cambiar
|
|
el `status` de un proyecto (activo, en pausa) salía como publicación, y activar
|
|
un servicio no.
|
|
|
|
Los ajustes siguen los permisos del panel: `settings.view` para leer,
|
|
`settings.update` para cambiar y `settings.maintenance` para `site.maintenance`
|
|
y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo
|
|
rechaza: puesto a mano, el frontend serviría páginas viejas o las pediría de
|
|
nuevo para nada. Sincronizar un repositorio pide `repositories.sync`, porque
|
|
gasta un límite del proveedor que comparte toda la aplicación, y va a la cola
|
|
en vez de hacer esperar a quien llama.
|
|
|
|
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
|
|
cliente en vez de comprobar nombres de rol.
|
|
|
|
Quien no tenga `pages.publish` solo ve páginas publicadas en el listado.
|
|
|
|
### Tokens para máquinas
|
|
|
|
```
|
|
GET /api/v1/tokens mis tokens (all=1 para un super_admin)
|
|
POST /api/v1/tokens {name, abilities, expires_at?} → el token, una vez
|
|
DELETE /api/v1/tokens/{id} revocar
|
|
```
|
|
|
|
Para scripts, CI o integraciones que no deberían usar la contraseña de nadie.
|
|
Una persona lo crea con los permisos que le da (`pages.update`, o `pages.*`
|
|
para todo lo de páginas) y nunca más de los que ella tiene; caduca a los 90
|
|
días si no se dice otra cosa, y como mucho al año. Se guarda solo su hash, y el
|
|
token en claro (`pmt_…`) sale una única vez, en la respuesta que lo crea.
|
|
|
|
Actúa como esa persona **dentro** de sus permisos, y se comprueba en cada
|
|
petición, así que quitarle un rol recorta sus tokens al momento; desactivarla
|
|
los apaga. El guard lo reconoce por el prefijo y **no lo manda nunca al
|
|
servidor de identidad**. Un token no puede crear ni revocar tokens: si no, uno
|
|
filtrado sería una forma de no caducar nunca.
|
|
|
|
Para que eso funcione, la comprobación de permisos es ahora una sola regla en
|
|
`AuthServiceProvider` y la de spatie está apagada
|
|
(`register_permission_check_method => false`): la de spatie respondía antes y
|
|
concedía el permiso sin preguntar al token.
|
|
|
|
### Webhooks salientes
|
|
|
|
```
|
|
/api/v1/webhooks CRUD (permiso webhooks.*, solo admin)
|
|
GET /api/v1/webhooks/{id}/deliveries qué se envió y cómo fue
|
|
POST /api/v1/webhooks/{id}/ping envío de prueba
|
|
POST /api/v1/webhook-deliveries/{id}/redeliver
|
|
```
|
|
|
|
Avisan a otro sistema de `application.received`, `content.published`,
|
|
`content.unpublished`, `review.requested` y `review.returned` (o `*`). Salen de
|
|
los propios registros —el historial ya distingue publicar de editar, venga del
|
|
panel, de la API o de un lote— y no de los controladores, así que no hay forma
|
|
de publicar algo sin que se avise.
|
|
|
|
Cada envío es un POST JSON `{id, event, created_at, data}` firmado: la cabecera
|
|
`X-Webhook-Signature` es `sha256=` más el HMAC-SHA256 de `{timestamp}.{body}`
|
|
con el secreto, y `X-Webhook-Timestamp` va aparte para que un envío capturado
|
|
no se pueda repetir más tarde. El secreto se genera solo (o se rota con
|
|
`rotate_secret`), se guarda cifrado y sale una única vez.
|
|
|
|
Las mismas defensas que el descargador de imágenes, porque la petición sale
|
|
desde dentro del stack: solo HTTPS, solo direcciones públicas —comprobado al
|
|
guardar y **otra vez en cada envío**, fijando la conexión a la dirección
|
|
comprobada—, sin seguir redirecciones. Cinco reintentos en unas dos horas y
|
|
media; veinte fallos seguidos lo apagan hasta que alguien lo vuelve a activar.
|
|
Los datos de quien escribe al buzón (nombre, correo, mensaje) solo viajan a los
|
|
webhooks con `include_personal_data`.
|
|
|
|
### Registro de auditoría
|
|
|
|
`GET /api/v1/audit` (permiso `audit.view_any`, solo admin): quién hizo qué,
|
|
cuándo y **por dónde entró** —`panel`, `api`, `token` (con qué token),
|
|
`console`— y desde qué IP. Filtros: `user_id`, `action` (exacta o un prefijo
|
|
como `pages.`), `subject_type`, `subject_id`, `via`, `from`, `to`.
|
|
|
|
El historial de cada ficha contesta «qué le pasó a esto»; esto contesta «qué
|
|
ha estado haciendo esta persona», en todo, incluido lo que no tiene historial
|
|
propio: roles (los eventos de spatie están activados para eso), ajustes,
|
|
tokens, webhooks, medios, el buzón. Lo escribe
|
|
[`Auditor`](app/Support/Audit/Auditor.php) desde los eventos de los modelos,
|
|
así que no depende de que cada controlador se acuerde.
|
|
|
|
Solo lo que hace una persona: una sincronización o una publicación programada
|
|
son la aplicación trabajando y enterrarían lo que se viene a buscar, y el
|
|
`content.version` que sube con cada edición tampoco cuenta. Los secretos
|
|
(contraseñas, hashes de token, secretos de webhook) salen como `••••`, y los
|
|
valores largos se recortan: el detalle completo está en el historial de la
|
|
ficha. Como nombra a personas y sus direcciones, se borra pasados
|
|
`AUDIT_RETENTION_DAYS` (365 por defecto) con `model:prune`, programado a
|
|
diario.
|
|
|
|
### Lotes e importación
|
|
|
|
```
|
|
POST /api/v1/batch hasta 50 operaciones {method, path, body}; atomic
|
|
POST /api/v1/import/{tipo} filas JSON (rows) o CSV (file); match, atomic, dry_run
|
|
```
|
|
|
|
Cada operación de un lote pasa por el kernel HTTP como una petición propia con
|
|
las credenciales de quien llama: se valida, se autoriza, cuenta para el límite
|
|
de peticiones y responde igual que si llegara sola, así que un lote no puede
|
|
hacer nada que sus operaciones no pudieran. [`BatchRunner`](app/Support/Batch/BatchRunner.php)
|
|
no sabe qué es una página, y es justo lo que lo hace seguro. Con `atomic` va en
|
|
una transacción y se para en el primer fallo deshaciéndolo todo; lo que ya salió
|
|
de la base (un trabajo en cola, un aviso) no se deshace.
|
|
|
|
La importación convierte filas en esas mismas llamadas: con `id`, o con el
|
|
campo de `match` (`slug`, `name`, `full_name`) apuntando a una ficha que existe,
|
|
actualiza; si no, crea. En CSV, `title.es` rellena el título en castellano, una
|
|
lista se escribe `1|2|3`, `true`/`false`/`null` son lo que dicen y una celda
|
|
vacía se omite en vez de borrar lo que hubiera. Por defecto es todo o nada, y
|
|
`dry_run` lo comprueba todo sin guardar nada.
|
|
|
|
### Búsqueda
|
|
|
|
`GET /api/public/v1/search?q=…` busca en lo que la web enseña —páginas,
|
|
proyectos y servicios publicados, convocatorias abiertas, miembros públicos y
|
|
tecnologías en uso— en el idioma de la petición, lo mejor primero, con el `path`
|
|
de cada resultado en la web.
|
|
|
|
En Postgres es **búsqueda de texto completo**: las palabras se reducen a su raíz
|
|
en el idioma que se busca («proyectos» encuentra «proyecto»; el catalán, sin
|
|
diccionario en un Postgres de serie, se compara palabra a palabra), el título
|
|
pesa más que el cuerpo y las etiquetas del texto enriquecido no cuentan como
|
|
palabras. En cualquier otra base —el SQLite de los tests— cae a comparar las
|
|
palabras tal cual: encuentra menos y ordena a grandes rasgos, pero responde con
|
|
la misma forma. Los tests cubren esa segunda rama; la de Postgres no tiene base
|
|
contra la que correr en la suite.
|
|
|
|
## Documentación navegable
|
|
|
|
```
|
|
/docs/api Swagger UI con las dos APIs
|
|
/openapi.yaml la especificación en crudo
|
|
/api-types.d.ts los tipos TypeScript, generados de la anterior
|
|
```
|
|
|
|
En el panel está en **Administración → Documentación de la API**, y abre en una
|
|
pestaña nueva: ir a mirar la referencia no debería tirar por la borda lo que
|
|
tuvieras a medio escribir.
|
|
|
|
**Sin autenticación, a propósito**: quien más la necesita escribe el frontend y
|
|
no tiene por qué tener cuenta en el panel, y la especificación describe
|
|
endpoints, no secretos. Para reservarla al colectivo, envuelve la ruta en el
|
|
middleware `auth` y saca `openapi.yaml` de `public/`.
|
|
|
|
Swagger UI está **autoalojado** en `public/vendor/swagger-ui/`, no servido desde
|
|
un CDN: la documentación tiene que funcionar en un portátil sin internet, y una
|
|
etiqueta `<script>` apuntando al servidor de otro es una dependencia que esta
|
|
aplicación no tiene en ningún otro sitio.
|
|
|
|
El «try it out» apunta al host que estás mirando, no al `APP_URL` de la
|
|
configuración, así que la misma página funciona en local, en staging y en
|
|
producción. Contra la API pública funciona tal cual, porque no lleva
|
|
autenticación.
|
|
|
|
Tres tests la cubren: que la página abre sin sesión, que no depende de ningún
|
|
CDN y que **todo endpoint documentado existe como ruta** — una documentación que
|
|
describe un endpoint que nadie implementó es peor que ninguna, porque se cree.
|
|
|
|
### Que la especificación no mienta
|
|
|
|
Un documento que describe una API es útil mientras es verdad, y **nada avisa
|
|
cuando deja de serlo**. Renombrar un campo en un `PublicApi/*Resource` es una
|
|
línea que pasa todos los demás tests, se despliega, y rompe una página en otra
|
|
máquina y otro repositorio — escrita contra un documento que sigue describiendo
|
|
el nombre viejo. Con el framework del frontend sin decidir, quien lo escriba va
|
|
a leer ese documento y no este código.
|
|
|
|
Dos cosas lo sujetan:
|
|
|
|
**Dos tests de contrato**, uno por mitad de la API, comparten la comparación en
|
|
sí ([`VerifiesOpenApiContract`](tests/VerifiesOpenApiContract.php)) porque la
|
|
pregunta es la misma dos veces: si la forma que el documento promete es la que
|
|
la API manda. Lo que cambia es todo lo que rodea la pregunta, no la
|
|
comparación.
|
|
|
|
[`PublicApiContractTest`](tests/Feature/Api/PublicApiContractTest.php) llama a
|
|
cada endpoint público con datos sembrados y compara la respuesta con el esquema
|
|
del `openapi.yaml`, clave a clave. La regla es asimétrica a propósito: **todo lo
|
|
que la API manda tiene que estar documentado** —siempre—, y lo que el documento
|
|
promete tiene que llegar en los endpoints de detalle, salvo lo que está marcado
|
|
`nullable`, que es como este documento dice «puede no estar».
|
|
|
|
Al escribirlo aparecieron cuatro desajustes que llevaban ahí un tiempo:
|
|
`meta_title` y `meta_description` sin documentar en proyectos y servicios,
|
|
`updated_at` sin documentar en servicios, el `logo` de las tecnologías (añadido
|
|
después de escribir esa parte del documento) y un `featured_image_alt` que las
|
|
páginas mandaban **por duplicado**, al nivel de arriba y dentro de
|
|
`featured_image.alt`. Los tres primeros eran el documento con retraso; el cuarto
|
|
era la API: el alt vive dentro de la imagen, que es donde lo necesita un `<img>`,
|
|
y dos nombres para el mismo texto es cómo un frontend acaba leyendo el que
|
|
resulta ser el accidental.
|
|
|
|
[`AdminApiContractTest`](tests/Feature/Api/AdminApiContractTest.php) hace lo
|
|
mismo con `/v1/*`, autenticado como `super_admin` para que un permiso de menos
|
|
nunca se confunda con una forma equivocada, y añade una comprobación que la
|
|
pública no necesita: el **sobre de paginación** (`links`, `meta`) de cada
|
|
listado contra lo documentado, y que `/v1/roles` —el único listado que Laravel
|
|
no pagina— no lleve ninguno de los dos. Escribirlo encontró cuatro desajustes
|
|
más: `featured_image_alt` sin documentar en absoluto en la respuesta de página
|
|
(la petición de escritura sí lo tenía, con otra forma), `permissions` de
|
|
usuario documentado solo en prosa en vez de con `nullable: true`, y el sobre de
|
|
paginación real de Laravel —que manda `from`, `to`, `path` y un `links` de
|
|
primer nivel— documentado con la mitad de esas claves y sin ese `links` en
|
|
absoluto.
|
|
|
|
**Tipos generados** para el otro lado:
|
|
|
|
```bash
|
|
php artisan api:types # escribe public/api-types.d.ts
|
|
php artisan api:types --check # falla si el fichero se quedó atrás
|
|
```
|
|
|
|
Salen los esquemas públicos y un mapa de cada ruta a lo que devuelve:
|
|
|
|
```ts
|
|
export interface PublicApiPayloads {
|
|
'/public/v1/projects': PublicProject[];
|
|
'/public/v1/projects/{slug}': PublicProject;
|
|
...
|
|
}
|
|
```
|
|
|
|
Va **commiteado** —quien lo necesita debería poder leerlo sin ejecutar nada— y
|
|
`--check` es lo que impide que se quede viejo. Las descripciones del documento
|
|
viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su
|
|
prosa, y un tipo a secas no la conserva.
|
|
|
|
## Servidor MCP
|
|
|
|
[`mcp/`](mcp/) es un servidor MCP (Node ≥ 20) que da a Claude —o a cualquier
|
|
cliente MCP— las dos APIs como herramientas: `public_projects_index`,
|
|
`admin_pages_update`, `admin_roles_catalogue`… Una por operación documentada,
|
|
con el nombre de la ruta de Laravel en la que acaba.
|
|
|
|
**No hay ninguna escrita a mano.** Lee el `openapi.yaml` que sirve la
|
|
aplicación a la que apunta y las genera de ahí, así que describe la API con la
|
|
que está hablando —local, staging o producción— y no la de este checkout. Por lo
|
|
mismo que los tipos: el documento lo sujetan los tests de contrato, y una
|
|
segunda descripción de la API escrita a mano no la sujetaría nada. La
|
|
introducción del documento (las dos APIs, idiomas, campos traducibles) viaja
|
|
como instrucciones del servidor.
|
|
|
|
### Desplegado (HTTP)
|
|
|
|
Va en el stack como un contenedor más, `landing-mcp`, que habla con
|
|
`landing-app` por la red interna. En Dokploy, añádele un dominio en la pestaña
|
|
«Domains» apuntando al puerto 3000; el endpoint es `https://<dominio>/mcp`
|
|
(Streamable HTTP, sin estado).
|
|
|
|
**No tiene credenciales propias.** Cada petición trae las de quien la hace, y
|
|
la API aplica los permisos de esa persona, igual que en el panel:
|
|
|
|
```bash
|
|
claude mcp add --transport http landing https://mcp.example.org/mcp \
|
|
--header "Authorization: Basic $(printf 'tu@email.com:contraseña' | base64)"
|
|
```
|
|
|
|
- **`Basic`** con el email y la contraseña de Pireon: el servidor hace login la
|
|
primera vez, guarda la sesión en memoria (indexada por un hash, nunca en
|
|
disco ni en logs), refresca el token antes de que caduque y la olvida tras
|
|
12 horas sin uso. Es lo recomendable, porque los tokens de Pireon caducan.
|
|
- **`Bearer`** con un token de Pireon: funciona hasta que caduca. Se valida
|
|
contra `GET /v1/me` y se revalida cada minuto, como hace la propia app.
|
|
- **Sin cabecera**: solo la API pública, que ya es pública.
|
|
`LANDING_MCP_REQUIRE_AUTH=true` lo cierra.
|
|
|
|
Unas credenciales rechazadas se recuerdan un minuto, sin volver a preguntar a
|
|
la app. Por eso mismo `POST /v1/auth/token` limita **por cuenta y dirección**
|
|
(6/min) y no solo por dirección (con un techo de 30/min por dirección): todos
|
|
los logins del MCP salen del mismo contenedor, y un límite por IP haría que la
|
|
contraseña mal escrita de uno —o los intentos de un extraño— dejaran fuera a
|
|
todos los demás.
|
|
|
|
El documento se relee cada minuto, así que un endpoint nuevo desplegado en la
|
|
app aparece como herramienta sin redesplegar el MCP; si la app está caída, se
|
|
sigue usando el último que cargó.
|
|
|
|
`LANDING_MCP_PREVIEW_TOKEN` está vacío a propósito: con el valor de
|
|
`SITE_PREVIEW_TOKEN`, cualquiera que pueda iniciar sesión ve todos los
|
|
borradores por las herramientas públicas, sea cual sea su rol. Nunca se manda
|
|
a quien llega sin credenciales.
|
|
|
|
### En local (stdio)
|
|
|
|
```bash
|
|
cd mcp && npm install
|
|
```
|
|
|
|
En este repo ya está registrado en [`.mcp.json`](.mcp.json), que lo lanza por
|
|
stdio con la configuración del entorno. Aquí sí las credenciales van en
|
|
variables, porque quien lo lanza es quien inicia sesión:
|
|
|
|
```
|
|
LANDING_URL raíz de la aplicación (por defecto http://localhost:8000)
|
|
LANDING_USERNAME credenciales de Pireon para la API de administración;
|
|
LANDING_PASSWORD el token se pide y se refresca solo
|
|
LANDING_TOKEN o un bearer token fijo en vez de credenciales —
|
|
lo natural es un token de máquina (pmt_…) con
|
|
solo los permisos que el MCP necesita
|
|
LANDING_PREVIEW_TOKEN opcional: las herramientas públicas ven borradores
|
|
LANDING_OPENAPI opcional: ruta o URL de la especificación
|
|
```
|
|
|
|
`node mcp/server.mjs --http` arranca el modo HTTP en local (en `127.0.0.1:3000`
|
|
salvo `LANDING_MCP_HOST`/`LANDING_MCP_PORT`), para probarlo sin Docker.
|
|
|
|
### En los dos modos
|
|
|
|
- **Sin credenciales solo aparece la mitad pública.** Ofrecer herramientas que
|
|
solo pueden fallar le cuesta al modelo una llamada cada una para descubrirlo.
|
|
- **Fuera a propósito**: `POST /v1/auth/token` (es cosa del cliente), la
|
|
telemetría (la manda el frontend con su propio secreto) y los dos formularios
|
|
del buzón — una llamada a `contact` o `apply` mete una fila en el buzón y
|
|
avisa a gente real.
|
|
- Las cabeceras no se exponen como parámetros: son secretos, y salen de la
|
|
configuración, no del modelo.
|
|
- **Subir imágenes** (`admin_media_store`): en local el modelo pasa una ruta de
|
|
tu disco (`file_path`) y el servidor la lee y la sube como multipart —en
|
|
base64 una foto normal serían cientos de miles de tokens—. **Desplegado no hay
|
|
`file_path`**: el servidor leería su propio disco a petición de cualquiera, así
|
|
que solo se ofrece `body.url`, que descarga la API con las protecciones de
|
|
[Subir por la API](#subir-por-la-api).
|
|
|
|
`npm test` (en `mcp/`, y en CI) genera las herramientas desde el
|
|
`openapi.yaml` de este repo —un cambio en el documento que haga chocar dos
|
|
nombres rompe el build aquí, no la sesión de alguien— y prueba el modo HTTP de
|
|
punta a punta contra una app simulada.
|
|
|
|
## Comandos
|
|
|
|
```bash
|
|
php artisan permissions:sync # sincroniza el catálogo de permisos
|
|
php artisan user:role email@x.com admin # concede un rol
|
|
php artisan user:role email@x.com admin --revoke
|
|
php artisan user:role email@x.com admin --only # sustituye todos sus roles
|
|
|
|
php artisan repos:sync # refresca los repositorios
|
|
php artisan repos:sync --sync # en primer plano, con salida
|
|
|
|
php artisan media:process # deriva lo que falte de las imágenes
|
|
php artisan media:process --all # reprocesa todas
|
|
|
|
php artisan content:schedule # cierra convocatorias vencidas
|
|
php artisan content:expiring --dry-run # a quién se avisaría de lo que vence
|
|
php artisan content:prune --dry-run # qué se tiraría
|
|
php artisan content:prune --media # tira también imágenes huérfanas
|
|
|
|
php artisan site:check # ¿está la web bien?
|
|
php artisan site:check --fail-on=error # para scripts de despliegue
|
|
|
|
php artisan api:types # tipos TS para el frontend
|
|
php artisan api:types --check # falla si se quedaron atrás
|
|
|
|
php artisan site:maintenance --status # ¿está la web pública en pie?
|
|
php artisan site:maintenance --until="+2 hours"
|
|
php artisan site:maintenance --up
|
|
```
|
|
|
|
## Tests
|
|
|
|
```bash
|
|
php artisan test # 391 tests, 1121 aserciones
|
|
```
|
|
|
|
En cada push y cada pull request, [`.github/workflows/tests.yml`](.github/workflows/tests.yml)
|
|
corre tres cosas: `pint --test`, esta suite contra SQLite (la vuelta rápida) y
|
|
`docker compose -f docker-compose.test.yml run --rm test` — el mismo comando de
|
|
más abajo, no una reimplementación en YAML — contra Postgres (la que de verdad
|
|
importa). Reutiliza el fichero de Docker en vez de describir el entorno dos
|
|
veces: un cambio en las extensiones de PHP o en la versión de Postgres se
|
|
corrige en un solo sitio.
|
|
|
|
Cubren el acceso con email y contraseña (credenciales incorrectas
|
|
indistinguibles de una dirección desconocida, cuenta desactivada, pendiente de
|
|
aprobación o sin rol, límite de intentos y rotación de sesión), el flujo PKCE
|
|
completo que sigue disponible, el guard de la API, la separación entre
|
|
identidad remota y permisos locales, la negociación de idioma, la fusión de
|
|
traducciones parciales, el acceso al panel, el contrato de la API pública
|
|
(`alternates`, caché, 304, preview), el buzón (convocatoria cerrada, honeypot,
|
|
deduplicación, consentimiento, límite por email, aviso a quien puede leerlo), el
|
|
procesado de imágenes (dimensiones, versiones, no ampliar, vectores, srcset y
|
|
que un listado no cueste una consulta por imagen), las galerías (orden, pie
|
|
frente a alt, borrado en cascada), la vitrina (servicios solo con obra
|
|
publicada detrás, logos de quien dio permiso, citas marcadas como traducidas,
|
|
títulos de charla sin traducir), las comprobaciones de salud (cada fallo que
|
|
detectan, los permisos que ocultan resultados y los códigos de salida), las
|
|
redirecciones (que se crean solas, que no encadenan ni dan vueltas y que la web
|
|
las recibe de una vez), los 404 que reporta el frontend (que el endpoint no se
|
|
abre sin secreto, que cuenta en vez de acumular filas, que normaliza igual que
|
|
una redirección, que tira los escaneos y que crear la regla cierra el aviso), la
|
|
revisión (que se puede pedir sin permiso de publicar, a quién avisa, que
|
|
devolver exige motivo, que publicar la cierra y programar no) y sus acciones en
|
|
lote, las ediciones simultáneas (que el segundo guardado se rechaza, que
|
|
guardarse a uno mismo no cuenta como conflicto y que la presencia se olvida
|
|
sola), las cifras de lectura (que suman en una fila por día, que cada idioma
|
|
cuenta aparte y que en la tabla no cabe nada de un visitante), el contrato de
|
|
la API contra su especificación campo a campo y los tipos generados de ella,
|
|
las traducciones que se quedaron atrás (que el slug no cuenta, que una vacía
|
|
no cuenta y que ponerse al día lo limpia), las sesiones activas (que no se
|
|
puede cerrar la propia) y el aviso de títulos parecidos, el historial (quién
|
|
publicó, el diff y restaurar), la
|
|
limpieza (qué se conserva siempre y qué nunca se borra sin pedirlo), las
|
|
ventanas de publicación (que se respetan sin nada corriendo, que preview las
|
|
salta y que el cierre de convocatorias es idempotente), la sincronización con
|
|
Gitea y el modo mantenimiento.
|
|
|
|
Ningún test toca la red: `tests/FakesPireon.php` simula el servidor de
|
|
identidad y las llamadas a GitHub se falsean con `Http::fake()`.
|
|
|
|
Otro abre **todas las pantallas del panel** —cada listado de recurso y cada
|
|
página propia— porque una pantalla que nadie ha visitado es una pantalla que
|
|
nadie ha probado: así es como `/admin/settings` llevaba meses roto sin que la
|
|
suite se enterara.
|
|
|
|
Un test comprueba que los tres idiomas llevan **las mismas claves** y que
|
|
ninguna está vacía, además de que toda clave que usa el código exista. Una
|
|
traducción que falta no rompe nada: se pinta la clave cruda
|
|
(`panel.widgets.stats.projects`) en una pantalla que nadie está mirando en ese
|
|
idioma, así que solo la encuentra quien la usa.
|
|
|
|
Un detalle a tener presente al escribir tests nuevos: `Symfony\Request::create()`
|
|
inyecta `Accept-Language: en-us` por defecto, y la API respeta la negociación de
|
|
idioma. Si un test espera castellano tiene que pedirlo explícitamente
|
|
(`Accept-Language: es` o `?lang=es`), como hace `PublicApiTest`.
|
|
|
|
## Accesibilidad del propio panel
|
|
|
|
El texto alternativo de la web pública tiene su sección propia; esto es sobre
|
|
la herramienta, no sobre lo que publica. Filament ya se ocupa de lo suyo —
|
|
contraste, roles ARIA, navegación por teclado entre pestañas— y no se ha
|
|
tocado nada de eso. Lo que se ha revisado son las **pocas vistas Blade
|
|
escritas a mano** en `resources/views/filament/`, donde nada garantiza esas
|
|
cosas por defecto.
|
|
|
|
Dos reglas, aplicadas donde tocaba:
|
|
|
|
**Un icono junto a un texto que ya dice lo mismo lleva `aria-hidden="true"`.**
|
|
El aviso de mantenimiento, el de salud del sitio: el icono de aviso al lado de
|
|
«La web pública está en mantenimiento» no añade nada que un lector de
|
|
pantalla necesite oír dos veces.
|
|
|
|
**Un icono que es la única señal de algo lleva su propio `aria-label`.** El de
|
|
cada comprobación de salud es el único caso: la gravedad —fallo o aviso— no
|
|
está escrita en ningún sitio, solo se ve en la forma y el color del icono.
|
|
Ocultarlo habría sido perder esa información, no limpiarla.
|
|
|
|
De paso, los enlaces de vista previa que abren en pestaña nueva llevan
|
|
`rel="noopener"` (la página de destino, en otro origen, no debe quedarse con
|
|
una referencia a esta) y el campo de solo lectura que copia la URL tiene su
|
|
propio `aria-label`, porque el texto que lo identifica visualmente es un
|
|
párrafo aparte sin relación programática con él.
|
|
|
|
## Estructura
|
|
|
|
```
|
|
docker-compose.yml stack de producción (landing-app, worker, scheduler, landing-mcp, postgres)
|
|
docker-compose.test.yml la suite contra Postgres, desechable
|
|
Dockerfile imagen única para los tres contenedores PHP
|
|
docker/ Caddyfile, php.ini y entrypoint
|
|
|
|
app/
|
|
Auth/PireonTokenGuard.php validación de bearer tokens
|
|
Services/Pireon/ cliente OAuth (PKCE, grants, introspección)
|
|
Services/Git/ drivers de repositorios (Gitea + GitHub)
|
|
Services/Media/ImageProcessor.php dimensiones, versiones y placeholder
|
|
Actions/Auth/SyncPireonUser.php identidad remota → usuario local
|
|
Jobs/SyncRepository.php refresco de un repositorio
|
|
Support/Permissions.php catálogo de permisos (fuente única)
|
|
Support/TranslatablePayload.php contrato JSON ↔ traducciones
|
|
Support/SiteUrls.php rutas públicas y alternates hreflang
|
|
Support/ImagePayload.php forma de una imagen en la API
|
|
Support/Maintenance.php mantenimiento de la web pública
|
|
Support/Health/ comprobaciones de salud del sitio
|
|
Policies/ autorización compartida panel + API
|
|
Filament/ panel: recursos, login, switcher de idioma
|
|
Http/Controllers/Api/V1/ API de administración
|
|
Http/Controllers/Api/PublicApi/V1/ API pública para el frontend
|
|
Notifications/ApplicationReceived aviso al panel de que alguien escribió
|
|
Http/Middleware/CachePublicResponse ETag, s-maxage, stale-while-revalidate
|
|
Http/Middleware/AllowPreview secreto de preview
|
|
Http/Middleware/VerifyIngestToken secreto de los reportes del frontend
|
|
Http/Middleware/EnsureSiteIsAvailable 503 mientras hay mantenimiento
|
|
config/pireon.php servidor OAuth
|
|
config/locales.php idiomas soportados
|
|
config/site.php frontend público, preview, caché, rate limit, telemetría
|
|
config/media.php disco, anchos y formatos de imagen
|
|
config/git.php proveedores de git y etiquetas de issues
|
|
public/openapi.yaml especificación de ambas APIs
|
|
mcp/ servidor MCP (stdio y HTTP) generado desde la anterior
|
|
```
|
|
|
|
## Despliegue con Docker
|
|
|
|
Cuatro contenedores y una base de datos:
|
|
|
|
```bash
|
|
cp .env.production.example .env.production
|
|
# Ahora edita .env.production: al menos DB_PASSWORD tiene que tener un valor
|
|
# real, porque docker-compose.yml no arranca sin él — ni siquiera para el
|
|
# comando siguiente, que solo interpola el fichero entero antes de ejecutar
|
|
# nada. APP_KEY puede quedarse vacío por ahora, se rellena con este paso:
|
|
|
|
docker compose --env-file .env.production run --rm --no-deps landing-app php artisan key:generate --show # pégalo en APP_KEY
|
|
docker compose --env-file .env.production up -d --build
|
|
|
|
# el primer administrador, igual que en local
|
|
docker compose exec landing-app php artisan user:role tu@email.com super_admin
|
|
```
|
|
|
|
Todas las variables de `docker-compose.yml` llegan por interpolación `${VAR}`,
|
|
no por `env_file:` — así que esto funciona igual con `--env-file`, con
|
|
variables ya exportadas en el shell, o con el panel de "Environment
|
|
Variables" de una plataforma como Dokploy o Coolify, que las inyecta como
|
|
variables de entorno reales del proceso que lanza `docker compose`. Ninguna
|
|
de las tres formas necesita que exista un fichero `.env.production` en el
|
|
servidor.
|
|
|
|
| Contenedor | Qué hace |
|
|
|---|---|
|
|
| `landing-app` | FrankenPHP sirviendo el panel y la API |
|
|
| `worker` | procesa imágenes y manda los avisos del buzón |
|
|
| `scheduler` | cierra convocatorias, barre imágenes pendientes, sincroniza repos |
|
|
| `postgres` | la base de datos |
|
|
|
|
**No hay Redis.** Postgres lleva la caché, las sesiones y la cola: para un sitio
|
|
de este tamaño sobra, y es una cosa menos que ejecutar, respaldar y olvidarse de
|
|
asegurar. Cambiarlo más adelante son tres variables de entorno.
|
|
|
|
**El worker no es opcional.** Sin él las imágenes se sirven sin dimensiones ni
|
|
`srcset` y la campana del panel no suena nunca. La web sigue funcionando, que es
|
|
justo por lo que la ausencia pasa desapercibida.
|
|
|
|
**Solo `app` migra.** `RUN_MIGRATIONS=true` está puesto ahí y en ningún otro
|
|
sitio, para que un despliegue las ejecute una vez y no una por contenedor. Con
|
|
`--isolated`, así que dos arranques simultáneos no se pisan.
|
|
|
|
**El volumen `storage` es lo único irrecuperable** del stack: contiene las
|
|
imágenes subidas. Todo lo demás se reconstruye desde el repositorio y la base de
|
|
datos. Respáldalo junto con `pgdata`.
|
|
|
|
**El primer arranque contra una base vacía tiene una trampa** que ya está
|
|
resuelta y conviene no reintroducir: `migrate --isolated` toma un candado en la
|
|
caché, y con `CACHE_STORE=database` esa tabla la crean las propias migraciones.
|
|
El entrypoint fuerza `CACHE_STORE=file` solo para ese comando — el valor de
|
|
`--isolated` es un código de salida, no un almacén.
|
|
|
|
**Postgres no se publica.** Nada fuera del stack tiene por qué llegar a él; para
|
|
entrar, `docker compose exec postgres psql -U pireon`.
|
|
|
|
**La imagen usa PHP 8.4**, no el 8.3 que sugiere `composer.json`: el lock
|
|
resolvió Symfony 8, que exige `>=8.4.1`. Si algún día tocas ese constraint,
|
|
acuérdate de la imagen — construir sobre 8.3 falla en `composer install`, que
|
|
es mucho mejor sitio para enterarse que un despliegue.
|
|
|
|
La imagen **no lleva Node**: la única directiva `@vite` que queda está en la
|
|
página de bienvenida de Laravel, a la que esta aplicación redirige. Los assets
|
|
de Filament se publican en tiempo de construcción, así que el contenedor en
|
|
marcha nunca escribe en `public/`.
|
|
|
|
### Lo que se rompió al pasar a Postgres
|
|
|
|
Este proyecto vivió sobre SQLite hasta que existió este stack, y ejecutarlo de
|
|
verdad contra Postgres sacó cuatro cosas que ninguna revisión de código habría
|
|
visto:
|
|
|
|
**Las columnas JSON tuvieron que pasar a `jsonb`** (48 de ellas). Postgres no
|
|
sabe comparar un `json` para igualdad, así que cualquier `select distinct` que
|
|
tocara una fallaba — y los selectores de relación de Filament emiten justo eso.
|
|
En MySQL sigue siendo `json` y en SQLite sigue siendo `text`, así que no cambia
|
|
nada más.
|
|
|
|
**`notifications.data` también.** La campana de Filament la consulta por ruta
|
|
JSON (`data->>'format'`), y el stub estándar de Laravel la declara como `text`:
|
|
en Postgres ese operador no existe.
|
|
|
|
**`jsonb` no conserva el orden de las claves.** Es inofensivo aquí —toda columna
|
|
JSON es un mapa cuyo orden no significa nada, y las listas sí mantienen su
|
|
orden—, pero cuatro tests comparaban con `assertSame`, que exige también el
|
|
orden.
|
|
|
|
**Y un bug real:** el historial se ordenaba solo por `created_at`, y dos
|
|
revisiones del mismo registro caen rutinariamente en el mismo segundo —crear y
|
|
editar seguido—. Cuál era «la última» quedaba a criterio del motor: SQLite
|
|
devolvía una y Postgres otra. Ahora desempata por `id`.
|
|
|
|
### Antes de confiar en él: probar contra Postgres
|
|
|
|
Este proyecto guarda traducciones, slugs y variantes de imagen en **columnas
|
|
JSON** y las consulta por ruta (`where("slug->es", …)`) en unos cuarenta sitios.
|
|
Hasta ahora eso solo se había ejecutado sobre SQLite, y los tres motores
|
|
coinciden en la sintaxis pero no siempre en el comportamiento.
|
|
|
|
```bash
|
|
docker compose -f docker-compose.test.yml run --rm test
|
|
```
|
|
|
|
Levanta un Postgres en memoria, corre la suite entera contra él y lo tira. Es la
|
|
comprobación que convierte «debería funcionar» en «funciona», y conviene hacerla
|
|
antes del primer despliegue. **391 tests en verde contra Postgres 17.**
|
|
|
|
Usa una etapa propia de la imagen: la de producción se construye con `--no-dev`
|
|
y ahí no está ni el runner de tests.
|
|
|
|
## Notas de despliegue
|
|
|
|
- `PIREON_REDIRECT_URI` debe coincidir **exactamente** con la registrada en el
|
|
servidor de identidad, incluido el esquema.
|
|
- En producción las URLs se fuerzan a HTTPS (`AppServiceProvider`).
|
|
- Ejecuta `php artisan permissions:sync` en cada despliegue que toque el
|
|
catálogo de permisos.
|
|
- El seeder de contenido de ejemplo no se ejecuta en producción.
|
|
- La sincronización de repositorios necesita el scheduler
|
|
(`php artisan schedule:work` o una entrada de cron) y un worker de colas si
|
|
usas el botón de sincronización masiva.
|
|
- El procesado de imágenes va por cola. Sin worker las imágenes se sirven igual
|
|
pero sin dimensiones ni `srcset`, y el scheduler las recupera cada diez
|
|
minutos.
|
|
- `php artisan storage:link` una vez, si `MEDIA_DISK=public`.
|
|
- `GITEA_URL` debe ser la raíz de la instancia, **sin** `/api/v1`.
|
|
- El modo mantenimiento vive en la tabla `settings` y se cachea: no hace falta
|
|
desplegar para activarlo, pero sí un almacén de caché compartido si tienes
|
|
varias instancias.
|
|
- Añade la IP del servidor del frontend a `SITE_TRUSTED_IPS`, o el rate limit de
|
|
la API pública se aplicará a toda la web como si fuera un solo cliente.
|
|
- `SITE_URL` debe apuntar al dominio público real: de ahí salen los
|
|
`alternates` absolutos que el frontend usa para los `hreflang`. También es
|
|
con lo que se compara la procedencia de un 404 para saber si el enlace roto
|
|
es nuestro.
|
|
- `SITE_INGEST_TOKEN` vacío deja el reporte de 404 apagado, que es un valor por
|
|
defecto seguro y también una funcionalidad que no está funcionando. Si la
|
|
lista de **URLs no encontradas** lleva semanas vacía, empieza por ahí.
|
|
- Las notificaciones del buzón **no van por cola**, a propósito: un despliegue
|
|
sin worker dejaría los avisos sin llegar, que es justo lo que un buzón no
|
|
puede permitirse. Si activas `SITE_SUBMISSION_MAIL=true` el mailer pasa a
|
|
estar en el camino de una petición pública (un fallo se registra y no rompe el
|
|
envío, pero lo ralentiza).
|
|
- Con `APP_ENV=production` (lo que trae `.env.production.example`), el panel
|
|
lleva una franja roja arriba del todo en cada pantalla, siempre — no hay
|
|
forma de apagarla salvo cambiar el entorno. Existe por el error que de verdad
|
|
pasa: editar producción pensando que es un clon local, nunca al revés. Un
|
|
`APP_ENV` que no sea ni `local` ni `production` (`staging`, por ejemplo) sale
|
|
en ámbar con su propio nombre; `local` no enseña nada.
|