CI (.github/workflows/tests.yml) reuses docker-compose.test.yml verbatim for the Postgres job rather than re-describing that environment in YAML, so the Docker image stays the single source of truth for it. Fixes a real mismatch found while wiring PHP versions into the workflow: composer.json declared "php": "^8.3", but the locked Symfony packages require >=8.4.1 — installing on 8.3 would fail at `composer install`, exactly what the Dockerfile's own comment already warned about. Bumped the constraint and resynced composer.lock's content-hash (no package versions changed). The pre-commit hook (.githooks/pre-commit, activated via `git config core.hooksPath` in post-autoload-dump) runs `pint --dirty --test` and refuses to commit unformatted files rather than silently rewriting them. The accessibility pass only touches this app's own hand-written Blade views, not Filament's components: aria-hidden on icons that repeat adjacent text, an aria-label on the one icon that is the only signal of a health check's severity, rel="noopener" and a labelled announcement on preview links that open in a new tab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
78 KiB
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 — 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:
node docs/build-pdf.mjs
Puesta en marcha
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
(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:
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:
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,
/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),
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 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):
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.
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
publishsueltos, y el que hace que el paso de 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 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.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 hayapplications.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.
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), 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):
?lang=ca- sesión (lo que eligió en el selector)
users.locale- cabecera
Accept-Language locales.default
En la API los campos traducibles se devuelven resueltos a un idioma. Con
?with=translations llegan todos:
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);
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,
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:
"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. 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.
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.
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.
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, 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.
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).
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:
- Los contadores se llaman
stars_count/forks_count, nostargazers_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, noBearer. - 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 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 y 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:
{
"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
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
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.
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
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
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.
editortiene todas las habilidades de contenido,publishincluida, así que a un editor los botones no le salen: no los necesita. Después de actualizar,php artisan permissions:synccrea 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.
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 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. Copia todo menos el slug y las fechas, porque dos registros no pueden responder en la misma URL.
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.
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-AfteryCache-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:
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:
{
"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
(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
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.
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:
Un test de contrato (PublicApiContractTest)
que 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.
Tipos generados para el otro lado:
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:
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.
Comandos
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: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
php artisan test # 391 tests, 1121 aserciones
En cada push y cada pull request, .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 (app, worker, scheduler, 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
Despliegue con Docker
Cuatro contenedores y una base de datos:
cp .env.production.example .env.production
docker compose run --rm --no-deps 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 app php artisan user:role tu@email.com super_admin
| Contenedor | Qué hace |
|---|---|
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.
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_URIdebe 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:syncen 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:worko 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:linkuna vez, siMEDIA_DISK=public.GITEA_URLdebe ser la raíz de la instancia, sin/api/v1.- El modo mantenimiento vive en la tabla
settingsy 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_URLdebe apuntar al dominio público real: de ahí salen losalternatesabsolutos que el frontend usa para loshreflang. También es con lo que se compara la procedencia de un 404 para saber si el enlace roto es nuestro.SITE_INGEST_TOKENvací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=trueel 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. UnAPP_ENVque no sea nilocalniproduction(staging, por ejemplo) sale en ámbar con su propio nombre;localno enseña nada.