Compare commits

..

26 Commits

Author SHA1 Message Date
c0825d94d4 Pass the new Pireon variables through Compose
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
The production stack hands the containers only the variables it lists,
and PIREON_REDIRECT_CLIENT_ID, its secret, PIREON_REDIRECT_LOGIN and the
two webhook secrets were not among them: set in Dokploy, they never
reached the application, so «Entrar con Pireon» never showed up.

Compose also passes an unset variable as an empty one, so an empty
PIREON_REDIRECT_URI now falls back to APP_URL/auth/callback (the redirect
flow had none otherwise), and an empty PIREON_REDIRECT_LOGIN no longer
hides the button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:27:18 +02:00
d10ce1ca57 Take Pireon's webhooks: a deactivation or a revocation counts at once
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Until now, an account switched off or a token revoked at the identity
server kept working here: bearer tokens until the introspection cache
ran out, panel sessions until they ended, and machine tokens for good,
as they only look at the local account.

POST /api/v1/webhooks/pireon takes user.deactivated (the account goes
inactive, which stops its machine tokens and the panel; its sessions
end and its cached tokens are forgotten) and token.revoked (sessions
and cached tokens go). Pireon signs each delivery with the secret of
the client that registered the webhook, so either PIREON_WEBHOOK_SECRET
(password client) or PIREON_REDIRECT_WEBHOOK_SECRET («Entrar con
Pireon») verifies it; with neither, the endpoint refuses everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:01:57 +02:00
ec4f687ca8 Offer «Entrar con Pireon» on the panel's login page
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Under the e-mail and password form, a button takes the redirect flow
(Authorization Code + PKCE), so the password is only ever typed at the
identity server. The routes for it were already there; the page had no
way to reach them.

A Pireon client allows one grant type and the panel's is a password
client, so the redirect flow can use its own: PIREON_REDIRECT_CLIENT_ID
and PIREON_REDIRECT_CLIENT_SECRET (the main client when they are empty).
The button shows up once that client is set, or with
PIREON_REDIRECT_LOGIN=true. Each session renews its token with the
client that issued it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:46:07 +02:00
3c119d425e Open a TaskStream board for a project that has none yet
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
The project form gets «Crear en TaskStream» while the project has no
board, and `taskstream: true` on a PATCH does the same through the API.
A refusal leaves the edit unsaved; a project with a board keeps it, and
`taskstream_key` without the flag is still ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:21:38 +02:00
7e7d2847fe Point the public site to each project's TaskStream board
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
A project with a board now carries its key, its address and the address
of the board's public showcase in the public API, so the website can show
its roadmap, releases and tickets for newcomers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:13:56 +02:00
b32d6bf001 Open a TaskStream board from the panel's project form
Some checks failed
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Tests / lint (push) Has been cancelled
A "TaskStream" section on the create form (toggle and optional key,
suggested from the slug) opens the board before saving, as the person
signed in, with the Pireon token of their session. A refusal shows on the
key field and a TaskStream outage as a notification; either way nothing is
saved. The created notification carries the link, and the edit page has
an "Open in TaskStream" button.

Documented in the user manual (and its PDF) and the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:01:55 +02:00
cdfc8763a5 Document TaskStream boards in the README and .env.example
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:20:23 +02:00
d30dd971d7 Open a TaskStream board when a project is created through the API
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
`taskstream: true` on POST /v1/projects also creates the project in
TaskStream, public, as the caller (their Pireon token is passed on), and
the answer carries `taskstream_key` and `taskstream_url`. The key is
`taskstream_key` or one made from the default-language slug.

The board is opened before the project is saved: if TaskStream refuses
it, nothing is created here and the reason comes back as a validation
error (502 when TaskStream does not answer). Machine tokens need the
server's own TASKSTREAM_TOKEN. Updates ignore the flag, and duplicates do
not inherit the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:12:36 +02:00
9390db18c2 Send a webhook for every change a person makes to a record
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Events are {resource}.created, .updated, .deleted and .restored for content,
the inbox, redirects, repositories and media, taken from the audit log so the
two agree on what happened. Subscriptions take wildcards. Users, tokens,
webhooks and settings are never sent, nor what the application does by
itself; the inbox's own words only with personal data enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:32:54 +02:00
d4cf4a66b7 Manage webhooks from the panel
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Create one and see its secret once, send a test, change the secret, and read
each delivery with what the other end answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:44:11 +02:00
d80ed1a3e8 Name dynamic translation keys the way the catalogue test reads them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:31:47 +02:00
9f970808ca Tag blog posts and sign them with a member
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Tags are named in every language and made from the page form; the byline is
the member who signs a post on the site. The public API sends both, filters
posts by tag and lists the tags in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:01:18 +02:00
6fc600e6a1 Add a translation assistant to the panel
Each field's original sits beside the field to write, and a translation that
fell behind shows what the original said when it was translated and what it
says now, word by word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:53:31 +02:00
76d022053b Answer the inbox from the panel, with templates
A reply starts from a template filled in the language the person wrote in,
is sent by the application when it has a mailer that delivers or handed to
the replier's mail program otherwise, and is recorded on the submission.
The same is available through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:45:47 +02:00
697d333ad4 Add a statistics page to the panel
The most read, reads per language with a daily chart, and published content
nobody opened, each compared with the period before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:19:19 +02:00
8d0f81f9c5 Add an editorial calendar to the panel
A month of publications, scheduled dates, content coming off the site, and
calls opening and closing, each linked to its form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:17:46 +02:00
773d3901c6 Warn about calls closing and content coming off the site
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>
2026-10-01 10:16:24 +02:00
366568b793 Duplicate records as drafts, and preview them from the form
The copy keeps the content, stack and team, but is unpublished, out of any
review, and gets a title marked as a copy and a free slug. It replaces
Filament's replicate action, which left the copy without a slug and still
published. The same copy is available through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:14:51 +02:00
70521d4bc9 Tell scheduling apart from unpublishing in the history
Giving a record a future date was recorded as "unpublished", and announced
to webhooks as such. The history now compares before and after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:09:03 +02:00
1fe1e67773 Keep an audit log of who did what, and how they came in
Written from model events, spatie's role events and panel sign-ins, only for
what a person did, with secrets masked; pruned after a year.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:08:01 +02:00
9bdbf834f6 Send signed outgoing webhooks
Fed by the records themselves, delivered only to public HTTPS addresses
checked at every attempt, retried, and switched off after repeated failures.
A submitter's details travel only to webhooks that opted into them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:42:39 +02:00
f93775e549 Issue machine tokens with limited permissions and an expiry
A token acts as the person who made it, within the permissions it was given
and those they still hold, checked on every request. Permission checks now go
through one gate rule, since spatie's answered first and never asked the
token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:21:37 +02:00
0119004f2c Run batches and import JSON or CSV through the API
Each operation is dispatched as a request of its own, so a batch is held to
the same validation and permissions as the calls it contains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:58:36 +02:00
6b3517743a Search the public site through the API
Full-text on Postgres, stemmed in the language searched; word matching
elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:53:34 +02:00
68c5bd4274 Report site health and pending translations through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:32:45 +02:00
5b46436641 Review, publish, preview and undo changes through the API
Revisions now call publishing only what publishes: a project's status is
whether the work is active, and a service is published by its flag.

The fake identity server answers by token, so a test can sign in a second
person.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:29:48 +02:00
178 changed files with 13856 additions and 136 deletions

View File

@@ -76,6 +76,21 @@ PIREON_CLIENT_SECRET=
PIREON_REDIRECT_URI="${APP_URL}/auth/callback"
PIREON_SCOPES=account:read
# «Entrar con Pireon» (Authorization Code + PKCE) under the login form. A Pireon
# client allows one grant type: the one above is the password client, so
# register another for authorization_code with the redirect URI above and put
# it here; the button shows up once it is set. PIREON_REDIRECT_LOGIN=true shows
# it with the client above instead (when that one allows authorization_code).
PIREON_REDIRECT_CLIENT_ID=
PIREON_REDIRECT_CLIENT_SECRET=
# PIREON_REDIRECT_LOGIN=
# Webhooks at ${APP_URL}/api/v1/webhooks/pireon (user.deactivated, token.revoked):
# register it on each client and paste the secret each one returns. Empty: the
# endpoint refuses everything and a revocation waits for the cache below.
PIREON_WEBHOOK_SECRET=
PIREON_REDIRECT_WEBHOOK_SECRET=
# Grants. The panel's sign-in form uses the password grant, so turning this off
# locks everyone out of the panel as well as out of POST /api/v1/auth/token.
PIREON_PASSWORD_GRANT=true
@@ -186,3 +201,18 @@ GIT_SYNC_CRON=hourly
GIT_SYNC_TTL=55
GIT_MAX_ISSUES=25
GIT_HTTP_TIMEOUT=15
###############################################################################
# TaskStream — the issue tracker
###############################################################################
# `taskstream: true` on POST /v1/projects opens a public board there too.
# Both default to production.
TASKSTREAM_API_URL=https://api.taskstream.pireon.dev
TASKSTREAM_WEB_URL=https://taskstream.pireon.dev
# A Pireon personal access token (oauth.pireon.dev/tokens), only used when the
# caller signs in with a machine token (pmt_…), which TaskStream cannot read.
# People with a Pireon token have theirs passed on instead. Empty: machine
# tokens cannot ask for a board.
TASKSTREAM_TOKEN=
TASKSTREAM_HTTP_TIMEOUT=10

350
README.md
View File

@@ -70,9 +70,20 @@ al remoto por si alguien comitea con `--no-verify`.
En `https://oauth.pireon.dev` crea un cliente OAuth con:
- **Grant types**: `password` — es el que usa el formulario de acceso del panel
Para ofrecer también **«Entrar con Pireon»** (redirección con Authorization
Code + PKCE, donde la contraseña solo se escribe en el servidor de identidad),
crea un segundo cliente, porque cada cliente admite un único grant:
- **Grant types**: `authorization_code`
- **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
`http://localhost:8000/auth/callback`)
y pon sus credenciales en `PIREON_REDIRECT_CLIENT_ID` y
`PIREON_REDIRECT_CLIENT_SECRET`. El botón aparece bajo el formulario en cuanto
están; `PIREON_REDIRECT_LOGIN` lo fuerza (`true`, si el cliente principal
admite los dos grants) o lo quita (`false`). Cada sesión renueva su token con el
cliente que lo emitió.
Copia las credenciales al `.env`:
@@ -187,6 +198,27 @@ curl -X POST http://localhost:8000/api/v1/auth/token \
Es el mismo grant que usa el formulario del panel, así que
`PIREON_PASSWORD_GRANT=false` desactiva los dos a la vez.
### Webhooks de Pireon
Para que desactivar una cuenta o revocar un token en Pireon surta efecto al
instante, sin esperar al TTL de la caché:
1. Registra `https://tu-dominio/api/v1/webhooks/pireon` con los eventos
`user.deactivated` y `token.revoked` en el cliente de contraseña
(`POST /api/clients/{client}/webhooks` del servidor de identidad) y pon el
secret que devuelve en `PIREON_WEBHOOK_SECRET`.
2. Si usas el cliente de «Entrar con Pireon», registra el mismo webhook en él y
pon su secret en `PIREON_REDIRECT_WEBHOOK_SECRET`. Pireon firma cada aviso
con el secret del cliente que lo registró, y una revocación solo la avisa el
cliente que emitió el token.
`user.deactivated` desactiva la cuenta aquí (lo que corta también sus tokens de
máquina y el panel), cierra sus sesiones del panel (con `SESSION_DRIVER=database`)
y olvida sus tokens cacheados; vuelve a estar activa al entrar de nuevo cuando
Pireon la reactive. `token.revoked` cierra sus sesiones y olvida sus tokens
cacheados. La firma (`X-Webhook-Signature`, HMAC-SHA256 del cuerpo) vale con
cualquiera de los dos secrets; sin ninguno, el endpoint lo rechaza todo.
## Permisos
El catálogo vive en un único sitio: [`app/Support/Permissions.php`](app/Support/Permissions.php).
@@ -525,6 +557,58 @@ 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.
## Tableros en TaskStream
Al crear un proyecto, la casilla «Crear también en TaskStream» del panel o
`taskstream: true` en la API le abren también un tablero en
[TaskStream](https://taskstream.pireon.dev), el gestor de tareas del colectivo.
El panel avisa con el enlace y, al editar, ofrece «Abrir en TaskStream»; la API
lo devuelve en la respuesta:
```json
POST /api/v1/projects
{ "title": { "es": "Catálogo abierto" }, "slug": { "es": "catalogo" }, "taskstream": true }
→ 201 { "data": { …, "taskstream_key": "CATALOGO", "taskstream_url": "https://taskstream.pireon.dev/p/CATALOGO" } }
```
- **Público** siempre: es el tablero de un proyecto que se enseña.
- **Clave**: la de `taskstream_key` (de 2 a 10 letras o cifras, empezando por
letra) o, si no se indica, una sacada del slug en el idioma por defecto: de
una palabra, sus diez primeros caracteres (`catalogo` → `CATALOGO`); de
varias, sus iniciales (`mi-web-nueva` → `MWN`).
- **Primero TaskStream**: si lo rechaza (una clave ya usada, por ejemplo), aquí
no se crea nada y el motivo vuelve como error de validación (422); si no
responde, 502. Así no quedan proyectos a medias ni webhooks de algo que no
existe.
- **A nombre de quien lo pide**: TaskStream acepta los mismos tokens de Pireon
que esta API, y se le pasa el de quien llama (en el panel, el de su sesión). Un token de máquina (`pmt_…`)
allí no significa nada, así que esas peticiones usan `TASKSTREAM_TOKEN`; sin
él, no pueden pedir tablero.
- **Después**: un proyecto que se creó sin tablero puede tenerlo luego, con
«Crear en TaskStream» en su ficha o `taskstream: true` en un `PATCH` (si
TaskStream lo rechaza, la edición tampoco se guarda). Uno que ya tiene tablero
lo conserva, y `taskstream_key` sin la casilla se ignora, así que un cliente
puede devolver tal cual lo que leyó. Un proyecto duplicado no hereda el
tablero del original.
```dotenv
TASKSTREAM_API_URL=https://api.taskstream.pireon.dev # por defecto
TASKSTREAM_WEB_URL=https://taskstream.pireon.dev # de aquí sale el enlace
TASKSTREAM_TOKEN= # token personal de Pireon, solo para tokens de máquina
TASKSTREAM_HTTP_TIMEOUT=10
```
El cliente está en [`TaskStreamClient`](app/Services/TaskStream/TaskStreamClient.php)
y la lógica (clave, token, errores) en
[`OpenInTaskStream`](app/Actions/Projects/OpenInTaskStream.php).
En la API pública, un proyecto con tablero trae `taskstream` (`key`, `url`,
`web_url` y `showcase_url`). `showcase_url` es público en TaskStream y devuelve
su hoja de ruta, sus versiones, el sprint en curso y los tickets etiquetados
`good-first-issue`, para que la web los enseñe en la página del proyecto.
## API pública (para el frontend)
Sin autenticación y de solo lectura. Todo lo que devuelve ya está publicado en
@@ -663,6 +747,22 @@ 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.
## Etiquetas y firma del blog
Las entradas del blog (páginas de la categoría `blog`) llevan **etiquetas**
—«angular», «accesibilidad»— con nombre y slug en cada idioma, que se eligen en
el formulario de la página y se crean desde ahí mismo si no existen (Contenido →
Etiquetas para ordenarlas; las gestiona quien gestiona las categorías). Y una
**firma**: el miembro que la firma en la web, con enlace a su perfil, que no
tiene por qué ser la cuenta que la escribió en el panel (`author_id`, que se
queda dentro).
La API pública manda `tags` (solo las que tienen nombre en ese idioma) y
`byline` (solo si el miembro tiene perfil público), acepta `?tag=` en
`/public/v1/pages` y tiene `/public/v1/tags` con cuántas entradas publicadas
lleva cada una. La de administración, `tag_ids` y `byline_member_id` en las
páginas y `/api/v1/tags`.
## La vitrina: servicios, clientes, testimonios y charlas
Cuatro tipos de contenido con los que se compone una landing o un portfolio.
@@ -941,6 +1041,22 @@ 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.
### El asistente de traducción
Contenido → Traducciones lista lo que falta escribir en un idioma (azul) y lo
que se quedó atrás (ámbar, con desde cuándo), con un botón por idioma. Desde la
edición de una página, proyecto, servicio o convocatoria, «Traducir» lleva al
mismo sitio.
Al abrir uno, cada campo traducible sale **con el original al lado** y el campo
a escribir enfrente (texto enriquecido donde lo es). Y si la traducción se quedó
atrás, ese campo dice desde cuándo y enseña **qué cambió en el original desde
que se tradujo**, palabra a palabra: lo que se quitó tachado y lo que entró
marcado. El texto «de entonces» sale del historial, como la fecha. Así quien
traduce rehace la frase que cambió en vez de releerlo todo. Solo se guarda el
idioma que se está traduciendo, y el slug se deja al formulario de la ficha:
una dirección no es una traducción.
## Revisión
`pages.publish` lleva separado desde el principio: **poner algo en la web es un
@@ -1156,9 +1272,58 @@ 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.
**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.
**Responder al buzón** — en la ficha de cada solicitud, «Responder» abre un
mensaje con el asunto ya puesto y, si se quiere, una **plantilla** (Colectivo →
Plantillas de respuesta) rellenada en el idioma en que escribió esa persona:
`{name}`, `{subject}` y `{sender}` se sustituyen. Se edita y se envía. Si la
instalación tiene un mailer que entrega de verdad (no `log` ni `array`), sale
desde la aplicación; si no, se abre el programa de correo de quien responde con
el texto ya escrito. En los dos casos **queda registrada** en la ficha —quién,
cuándo, qué— para que la siguiente persona que la abra sepa qué se dijo, y una
solicitud nueva pasa a «contactada» (una ya aceptada o rechazada no cambia). El
botón de siempre, un correo en blanco, sigue al lado. Por API:
`GET /api/v1/applications/{id}/replies`, `POST /api/v1/applications/{id}/reply`
(con `template_id`, o `subject` y `body`) y `GET /api/v1/reply-templates`.
`MAIL_REPLY_TO_ADDRESS` decide a dónde vuelven las contestaciones.
**Calendario editorial** (Contenido → Calendario editorial) — un mes de un
vistazo: lo que se publicó o se publicará (programado), lo que sale de la web,
y las convocatorias que abren y cierran, cada cosa enlazada a su formulario. Las
fechas ya estaban, de una en una en cada ficha; juntas es donde se ve que tres
cosas salen el mismo lunes y nada en quince días. El mes va en la URL
(`?month=2026-10`) para poder mandarlo, y en el móvil pasa a una lista con solo
los días que tienen algo. Cada persona ve los tipos que puede listar.
**Estadísticas** (Contenido → Estadísticas) — de las lecturas que ya manda el
frontend (totales por día, registro e idioma; nada sobre la persona): lo más
leído, las lecturas de cada idioma con su parte del total y una gráfica diaria,
y lo que **está en la web y nadie abrió** en el periodo —o cuesta encontrarlo o
no compensa—. 7, 30 o 90 días, y cada cifra comparada con el periodo anterior
de la misma duración, porque «320 lecturas» dice poco y «320, desde 210» dice
si algo funcionó.
**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.
@@ -1302,6 +1467,39 @@ salen desactivados): sin `projects.publish` o `services.publish`, la API rechaza
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
@@ -1315,6 +1513,143 @@ 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
En el panel, **Administración → Webhooks** (solo con `webhooks.*`): alta con la
URL y los eventos marcados, y al guardar sale **el secreto una única vez**, en
un aviso que no se cierra solo. En la ficha, «Enviar prueba» manda un `ping`,
«Cambiar secreto» genera otro (el anterior deja de valer en el acto) y abajo
están **los envíos**: estado, respuesta, intentos, error, el contenido enviado y
«Reenviar». El menú se marca en rojo si alguno se apagó por fallos. Lo mismo
por API:
```
/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`, y además de
**cualquier cambio que haga una persona en una ficha**: `{tipo}.created`,
`.updated`, `.deleted` y `.restored` para páginas, categorías, etiquetas,
proyectos, miembros, servicios, clientes, testimonios, charlas, tecnologías,
convocatorias, redirecciones, repositorios, medios y el buzón. Se puede
suscribir a uno, a `*`, a todo un tipo (`projects.*`) o a una acción en
cualquiera (`*.deleted`).
Esos eventos salen del [registro de auditoría](#registro-de-auditoría), así
que dicen lo mismo que él —qué ficha, quién, por dónde (panel, API, token) y,
al editar, qué campos cambiaron con su valor anterior y el nuevo— y dejan fuera
lo mismo: lo que hace la aplicación sola y los secretos. Del buzón solo se
cuenta cómo se está atendiendo, salvo con `include_personal_data`. Usuarios,
tokens, webhooks y ajustes no se envían nunca: son la seguridad de la
aplicación, no su contenido. 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
```
@@ -1484,7 +1819,9 @@ 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
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
```
@@ -1529,6 +1866,7 @@ php artisan media:process # deriva lo que falte de las imáge
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

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Actions\Auth;
use App\Auth\PireonTokenGuard;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* What a Pireon webhook changes here.
*
* - `user.deactivated`: the account goes inactive (which also stops its
* machine tokens and the panel), its panel sessions end and its cached
* bearer tokens are forgotten. Signing in again once Pireon reactivates it
* turns it back on, as every sign-in copies the upstream state.
* - `token.revoked`: only token hashes are cached, so every cached token and
* panel session of that person goes; they sign in again.
*
* Anything else, or a person who never signed in here, is ignored. Repeating
* a delivery (one arrives per registered client) changes nothing.
*/
class HandlePireonWebhook
{
public const EVENTS = ['user.deactivated', 'token.revoked'];
/**
* @param array<string, mixed> $data
*/
public function handle(string $event, array $data): void
{
if (! in_array($event, self::EVENTS, true)) {
Log::debug('Unhandled Pireon webhook', ['event' => $event]);
return;
}
$oauthId = $data['user_id'] ?? $data['userId'] ?? ($data['user']['id'] ?? null);
if ($oauthId === null || $oauthId === '') {
Log::warning('Pireon webhook without a user id, ignored', ['event' => $event]);
return;
}
PireonTokenGuard::forgetUser((string) $oauthId);
$user = User::query()->where('oauth_id', $oauthId)->first();
if ($user === null) {
return;
}
if ($event === 'user.deactivated' && $user->is_active) {
$user->forceFill(['is_active' => false])->save();
}
$this->endSessions($user);
}
/**
* Sessions kept in the database can be ended one person at a time. With
* any other driver they cannot, but an inactive account is still turned
* away on its next request (canAccessPanel).
*/
protected function endSessions(User $user): void
{
if (config('session.driver') !== 'database') {
return;
}
DB::connection(config('session.connection'))
->table((string) config('session.table', 'sessions'))
->where('user_id', $user->getKey())
->delete();
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\User;
use App\Services\TaskStream\TaskStreamClient;
use App\Services\TaskStream\TaskStreamException;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Opens a board in TaskStream for a project, new or one without a board
* yet, and points the project at it.
*
* It runs before the project is saved: if TaskStream says no, nothing is
* saved here either, and the caller gets the reason to fix and retry.
* The board is public, like the project it tracks is meant to be.
*/
class OpenInTaskStream
{
/** TaskStream's rule for a project key. */
public const KEY_PATTERN = '/^[A-Z][A-Z0-9]{1,9}$/';
public function __construct(protected TaskStreamClient $client) {}
/**
* @param string|null $bearer The token the caller presented here.
* @param string|null $key The key asked for; derived from the slug when null.
*
* @throws ValidationException when the caller can fix the request
*/
public function handle(Project $project, User $user, ?string $bearer, ?string $key = null): void
{
$locale = (string) config('locales.default');
$key = $key !== null ? Str::upper($key) : $this->keyFrom((string) $project->getTranslation('slug', $locale, false));
if ($key === null) {
throw ValidationException::withMessages(['taskstream_key' => __('rules.taskstream.key_needed')]);
}
try {
$created = $this->client->createProject(
token: $this->token($user, $bearer),
key: $key,
name: Str::limit((string) $project->getTranslation('title', $locale, false), 80, '…'),
description: $project->getTranslation('tagline', $locale, false) ?: null,
public: true,
);
} catch (TaskStreamException $e) {
throw $this->explain($e);
}
$project->forceFill([
'taskstream_project_id' => $created['id'],
'taskstream_key' => $created['key'],
]);
}
/**
* A Pireon token is passed on, so the board belongs to whoever asked. A
* machine token is this application's alone and means nothing there.
*/
protected function token(User $user, ?string $bearer): string
{
$token = $user->currentAccessToken() === null ? $bearer : $this->client->serviceToken();
if (blank($token)) {
throw ValidationException::withMessages(['taskstream' => __('rules.taskstream.machine')]);
}
return $token;
}
/**
* A key in TaskStream's style: one word gives its first ten characters
* ("catalogo" → CATALOGO), several give their initials ("mi-web-nueva"
* → MWN). Null when nothing usable is left.
*/
public function keyFrom(string $slug): ?string
{
$words = array_values(array_filter(preg_split('/[^A-Z0-9]+/', Str::upper(Str::ascii($slug))) ?: []));
$key = count($words) === 1
? $words[0]
: implode('', array_map(fn (string $word) => $word[0], $words));
// It must start with a letter.
$key = substr((string) preg_replace('/^[0-9]+/', '', $key), 0, 10);
return preg_match(self::KEY_PATTERN, $key) === 1 ? $key : null;
}
protected function explain(TaskStreamException $e): ValidationException|HttpException
{
if ($e->isRefusal()) {
return ValidationException::withMessages([
'taskstream_key' => __('rules.taskstream.refused', ['reason' => $e->getMessage()]),
]);
}
if ($e->isUnauthorised()) {
return ValidationException::withMessages(['taskstream' => __('rules.taskstream.unauthorised')]);
}
return new HttpException(502, __('rules.taskstream.unavailable'), $e);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Auth;
use App\Actions\Auth\SyncPireonUser;
use App\Models\ApiToken;
use App\Models\User;
use App\Services\Pireon\PireonClient;
use App\Services\Pireon\PireonException;
@@ -35,7 +36,16 @@ class PireonTokenGuard
{
$token = $request->bearerToken();
if (blank($token) || ! $this->client->isConfigured()) {
if (blank($token)) {
return null;
}
// Ours, not the identity server's: checked here and never sent there.
if (str_starts_with($token, ApiToken::PREFIX)) {
return $this->machine($token);
}
if (! $this->client->isConfigured()) {
return null;
}
@@ -52,6 +62,21 @@ class PireonTokenGuard
return $user->is_active ? $user : null;
}
protected function machine(string $plain): ?User
{
$token = ApiToken::findUsable($plain);
$user = $token?->user;
// A person switched off here takes their tokens with them.
if ($user === null || ! $user->is_active) {
return null;
}
$token->touchLastUsed();
return $user->withAccessToken($token);
}
protected function resolveIdentity(string $token): ?PireonIdentity
{
$key = 'pireon:introspect:'.hash('sha256', $token);
@@ -79,9 +104,33 @@ class PireonTokenGuard
Cache::put($key, $identity->toArray(), $ttl);
// Which cached tokens are whose, so a webhook can drop a person's.
$index = self::indexKey($identity->id);
Cache::put($index, array_values(array_unique([...(array) Cache::get($index, []), $key])), $ttl);
return $identity;
}
/**
* Forget every token of that person checked lately: the next request with
* any of them asks the identity server again.
*/
public static function forgetUser(int|string $oauthId): void
{
$index = self::indexKey($oauthId);
foreach ((array) Cache::get($index, []) as $key) {
Cache::forget((string) $key);
}
Cache::forget($index);
}
protected static function indexKey(int|string $oauthId): string
{
return 'pireon:introspect:user:'.$oauthId;
}
/**
* Mirror the identity locally so policies, ownership checks and foreign
* keys have a real row to work with. Local roles are left untouched.

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use App\Notifications\ExpiringSoon;
use App\Support\Expiring;
use Illuminate\Console\Command;
/**
* Tells editors, once a day, about what closes or comes off the site on one
* of the reminder days ahead (a week and a day, by default) — so each thing
* is mentioned a couple of times rather than every morning for a fortnight.
*/
class NotifyExpiringCommand extends Command
{
protected $signature = 'content:expiring
{--dry-run : List who would be told what, without telling them}';
protected $description = 'Remind editors of calls closing and content coming off the site soon';
public function handle(): int
{
$days = array_map('intval', (array) config('site.expiry_reminders', [7, 1]));
$due = Expiring::within(max($days))
->filter(fn (array $item) => in_array((int) now()->startOfDay()->diffInDays($item['at']->copy()->startOfDay()), $days, true))
->values();
if ($due->isEmpty()) {
$this->components->info(__('console.expiring.none'));
return self::SUCCESS;
}
$told = 0;
User::query()->where('is_active', true)->get()->each(function (User $user) use ($due, &$told): void {
$mine = Expiring::for($due, $user);
if ($mine->isEmpty()) {
return;
}
$this->components->twoColumnDetail($user->name, (string) $mine->count());
if (! $this->option('dry-run')) {
$user->notify(new ExpiringSoon($mine));
}
$told++;
});
$this->components->info(trans_choice('console.expiring.summary', $told, ['count' => $told]));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Filament\Charts;
use App\Support\ViewStats;
use Filament\Widgets\ChartWidget;
/**
* Reads per day, one line per language. Lives outside `Filament/Widgets` so
* the dashboard does not discover it: it belongs to the statistics page,
* which says which period to draw.
*/
class ViewsByLanguageChart extends ChartWidget
{
public int $days = 30;
protected int|string|array $columnSpan = 'full';
protected ?string $maxHeight = '280px';
public function getHeading(): string
{
return __('panel.statistics.daily');
}
protected function getType(): string
{
return 'line';
}
protected function getData(): array
{
$daily = (new ViewStats($this->days))->daily();
$colors = ['#f59e0b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6'];
return [
'labels' => $daily['labels'],
'datasets' => collect($daily['series'])
->values()
->map(fn (array $data, int $i) => [
'label' => config('locales.supported.'.array_keys($daily['series'])[$i].'.native'),
'data' => $data,
'borderColor' => $colors[$i % count($colors)],
'backgroundColor' => $colors[$i % count($colors)],
'tension' => 0.3,
])
->all(),
];
}
}

View File

@@ -10,6 +10,7 @@ use App\Services\Pireon\PireonClient;
use App\Services\Pireon\PireonException;
use App\Services\Pireon\TokenStore;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
use Filament\Actions\Action;
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Filament\Auth\Pages\Login;
use Filament\Facades\Filament;
@@ -27,6 +28,9 @@ use SensitiveParameter;
* usable password column — but the person signing in never sees that. They
* type their credentials here and land in the panel.
*
* «Entrar con Pireon», under the form, takes the redirect flow instead: the
* password is then typed at the identity server only.
*
* The cost of that convenience is stated plainly because it is real: this
* application now handles the password rather than never seeing it. It is sent
* straight to the identity server over the password grant and is never stored,
@@ -53,6 +57,25 @@ class PireonLogin extends Login
return __('pireon.sign_in_intro');
}
/**
* Under «Entrar», «Entrar con Pireon»: the redirect flow, for whoever would
* rather not type their password here (or signs in some other way there).
*/
protected function getFormActions(): array
{
$actions = parent::getFormActions();
if (config('pireon.redirect_login') && PireonClient::forRedirect()->isConfigured()) {
$actions[] = Action::make('pireon')
->label(__('pireon.continue_with_pireon'))
->url(route('auth.redirect'))
->color('gray')
->outlined();
}
return $actions;
}
/**
* Filament's own flow checks the credentials against the local user table.
* There are no local passwords here, so the whole of it is replaced: the
@@ -110,7 +133,7 @@ class PireonLogin extends Login
Auth::login($user, (bool) ($data['remember'] ?? false));
app(TokenStore::class)->put($token);
app(TokenStore::class)->put($token, viaRedirect: false);
if ($user->locale) {
session()->put(config('locales.session_key'), $user->locale);

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Support\EditorialCalendar as Calendar;
use BackedEnum;
use Carbon\CarbonImmutable;
use Filament\Pages\Page as FilamentPage;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Url;
use UnitEnum;
/**
* A month at a glance: what goes live, what comes off, which calls open and
* close. The dates were always there, one form at a time; this is them side
* by side, which is where a clash or an empty fortnight shows.
*/
class EditorialCalendar extends FilamentPage
{
protected string $view = 'filament.pages.editorial-calendar';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCalendarDays;
protected static ?int $navigationSort = 5;
/**
* `2026-10`. In the address, so a month can be linked to.
*/
#[Url]
public string $month = '';
public function mount(): void
{
if (! preg_match('/^\d{4}-\d{2}$/', $this->month)) {
$this->month = now()->format('Y-m');
}
}
public static function canAccess(): bool
{
return Auth::user()?->can('panel.access') ?? false;
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.content');
}
public static function getNavigationLabel(): string
{
return __('panel.calendar.title');
}
public function getTitle(): string|Htmlable
{
return __('panel.calendar.title');
}
public function previous(): void
{
$this->month = $this->first()->subMonth()->format('Y-m');
}
public function next(): void
{
$this->month = $this->first()->addMonth()->format('Y-m');
}
public function today(): void
{
$this->month = now()->format('Y-m');
}
public function first(): CarbonImmutable
{
return CarbonImmutable::createFromFormat('Y-m-d', $this->month.'-01')->startOfDay();
}
public function label(): string
{
return ucfirst($this->first()->locale(app()->getLocale())->isoFormat('MMMM YYYY'));
}
/**
* Whole weeks, Monday first, covering the month.
*
* @return array<int, array<int, array{date: CarbonImmutable, inMonth: bool, isToday: bool, events: array<int, array<string, mixed>>}>>
*/
public function weeks(): array
{
$start = $this->first()->startOfWeek(CarbonImmutable::MONDAY);
$end = $this->first()->endOfMonth()->endOfWeek(CarbonImmutable::SUNDAY);
$events = Calendar::between($start, $end, Auth::user())->groupBy('date');
$weeks = [];
for ($day = $start; $day->lte($end); $day = $day->addDay()) {
$weeks[intdiv((int) $start->diffInDays($day), 7)][] = [
'date' => $day,
'inMonth' => $day->month === $this->first()->month,
'isToday' => $day->isToday(),
'events' => ($events[$day->toDateString()] ?? collect())->all(),
];
}
return $weeks;
}
/**
* @return array<int, string>
*/
public function weekdays(): array
{
$monday = CarbonImmutable::now()->locale(app()->getLocale())->startOfWeek(CarbonImmutable::MONDAY);
return array_map(fn (int $i) => $monday->addDays($i)->isoFormat('ddd'), range(0, 6));
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Support\ViewStats;
use BackedEnum;
use Filament\Pages\Page as FilamentPage;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Url;
use UnitEnum;
/**
* What people read: the most read, how each language is doing, and what is
* on the site that nobody opened. Counts only — what the frontend reports is
* a daily total per record and language, with nothing about the person.
*/
class Statistics extends FilamentPage
{
public const PERIODS = [7, 30, 90];
protected string $view = 'filament.pages.statistics';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
protected static ?int $navigationSort = 6;
#[Url]
public int $days = 30;
public function mount(): void
{
if (! in_array($this->days, self::PERIODS, true)) {
$this->days = 30;
}
}
public function updatedDays(): void
{
$this->mount();
}
public static function canAccess(): bool
{
return Auth::user()?->can('panel.access') ?? false;
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.content');
}
public static function getNavigationLabel(): string
{
return __('panel.statistics.title');
}
public function getTitle(): string|Htmlable
{
return __('panel.statistics.title');
}
public function stats(): ViewStats
{
return new ViewStats($this->days);
}
/**
* "+52 %", "−10 %", or "new" when there was nothing to compare with.
*/
public function change(int $now, int $before): string
{
if ($before === 0) {
return $now > 0 ? __('panel.statistics.new') : '—';
}
$percent = (int) round(($now - $before) * 100 / $before);
return ($percent > 0 ? '+' : ($percent < 0 ? '−' : '')).abs($percent).' %';
}
}

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Support\ContentTypes;
use App\Support\PendingTranslations;
use App\Support\TextDiff;
use App\Support\TranslationFreshness;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page as FilamentPage;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\HtmlString;
use Livewire\Attributes\Url;
use UnitEnum;
/**
* Translating with the original beside you.
*
* Without a record: what is waiting to be translated, into which language.
* With one: each field's original next to the field to write, and for a
* translation that fell behind, what the original said when it was translated
* and what it says now — the sentence to redo rather than the whole text.
*/
class TranslationAssistant extends FilamentPage
{
/**
* Rich text, edited as such; everything else is plain.
*
* @var array<int, string>
*/
public const RICH = ['body', 'description', 'decisions', 'postmortem'];
/**
* Never translated here: an address is not a translation, and changing it
* has its own consequences on the record's own form.
*
* @var array<int, string>
*/
protected const SKIPPED = ['slug', 'featured_image_alt', 'cover_image_alt'];
protected string $view = 'filament.pages.translation-assistant';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedLanguage;
protected static ?int $navigationSort = 7;
#[Url]
public ?string $type = null;
#[Url]
public ?string $record = null;
#[Url]
public ?string $locale = null;
public ?array $data = [];
public static function canAccess(): bool
{
return Auth::user()?->can('panel.access') ?? false;
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.content');
}
public static function getNavigationLabel(): string
{
return __('panel.translate.title');
}
public static function urlFor(Model $record, ?string $locale = null): string
{
$type = array_search($record::class, PendingTranslations::TYPES, true);
return self::getUrl(array_filter(['type' => $type, 'record' => $record->getKey(), 'locale' => $locale]));
}
public function getTitle(): string|Htmlable
{
$record = $this->target();
return $record
? __('panel.translate.title_for', ['title' => ContentTypes::title($record), 'language' => $this->languageName($this->locale)])
: __('panel.translate.title');
}
public function mount(): void
{
$record = $this->target();
if ($record === null) {
return;
}
abort_unless(Auth::user()->can('update', $record), 403);
$this->form->fill(collect($this->fields($record))
->mapWithKeys(fn (string $field) => [$field => $record->getTranslation($field, $this->locale, false)])
->all());
}
/**
* The record being translated, when there is one and the language is one
* to translate into.
*/
public function target(): ?Model
{
$model = PendingTranslations::TYPES[$this->type] ?? null;
$locales = array_keys((array) config('locales.supported'));
if ($model === null || $this->record === null || ! in_array($this->locale, $locales, true) || $this->locale === config('locales.default')) {
return null;
}
return $model::query()->find($this->record);
}
/**
* @return Collection<int, array<string, mixed>>
*/
public function pending(): Collection
{
return PendingTranslations::find(Auth::user());
}
/**
* @return array<int, string>
*/
public function targetLocales(): array
{
return array_values(array_diff(array_keys((array) config('locales.supported')), [config('locales.default')]));
}
public function languageName(?string $locale): string
{
return (string) config("locales.supported.{$locale}.native", $locale);
}
public function form(Schema $schema): Schema
{
$record = $this->target();
if ($record === null) {
return $schema->statePath('data')->components([]);
}
$source = (string) config('locales.default');
$stale = TranslationFreshness::staleFields($record, $this->locale);
return $schema
->statePath('data')
->components(collect($this->fields($record))->map(function (string $field) use ($record, $source, $stale) {
$original = (string) $record->getTranslation($field, $source, false);
$rich = in_array($field, self::RICH, true);
$label = __("panel.translate.fields.{$field}");
$input = match (true) {
$rich => RichEditor::make($field)->hiddenLabel(),
mb_strlen($original) > 120 => Textarea::make($field)->hiddenLabel()->rows(4),
default => TextInput::make($field)->hiddenLabel(),
};
$diff = isset($stale[$field]) ? TextDiff::html($stale[$field]['was'], $original) : null;
return Section::make($label)
->description(isset($stale[$field])
? __('panel.translate.stale_since', ['date' => $stale[$field]['since']->isoFormat('LL')])
: null)
->icon(isset($stale[$field]) ? 'heroicon-o-exclamation-triangle' : null)
->iconColor('warning')
->compact()
->schema(array_filter([
$diff ? Text::make(new HtmlString('<div class="ta-diff"><strong>'.e(__('panel.translate.changed')).'</strong> '.$diff.'</div>')) : null,
Grid::make(['default' => 1, 'lg' => 2])->schema([
Text::make(new HtmlString(
'<div class="ta-source"><span class="ta-lang">'.e($this->languageName($source)).'</span>'
.($rich ? $original : nl2br(e($original))).'</div>'
)),
$input,
]),
]));
})->values()->all());
}
protected function getHeaderActions(): array
{
return $this->target() === null ? [] : [
Action::make('back')
->label(__('panel.translate.back'))
->color('gray')
->url(self::getUrl()),
];
}
public function save(): void
{
$record = $this->target();
abort_unless($record !== null && Auth::user()->can('update', $record), 403);
foreach ($this->form->getState() as $field => $value) {
if (in_array($field, $this->fields($record), true)) {
$record->setTranslation($field, $this->locale, (string) ($value ?? ''));
}
}
$record->save();
Notification::make()->title(__('panel.translate.saved'))->success()->send();
}
/**
* The fields worth translating on this record: translatable, not an
* address, and with something in the original to translate.
*
* @return array<int, string>
*/
protected function fields(Model $record): array
{
$source = (string) config('locales.default');
return array_values(array_filter(
array_diff($record->getTranslatableAttributes(), self::SKIPPED),
fn (string $field) => filled($record->getTranslation($field, $source, false)),
));
}
}

View File

@@ -51,6 +51,7 @@ class RevisionsRelationManager extends RelationManager
->color(fn (string $state) => match ($state) {
'published' => 'success',
'unpublished' => 'warning',
'scheduled' => 'info',
'created' => 'info',
'deleted', 'erased' => 'danger',
default => 'gray',
@@ -77,7 +78,7 @@ class RevisionsRelationManager extends RelationManager
->filters([
SelectFilter::make('event')
->label(__('panel.revisions.fields.event'))
->options(fn () => collect(['created', 'updated', 'published', 'unpublished', 'deleted', 'restored', 'erased'])
->options(fn () => collect(['created', 'updated', 'published', 'scheduled', 'unpublished', 'deleted', 'restored', 'erased'])
->mapWithKeys(fn (string $e) => [$e => __("panel.revisions.events.{$e}")])
->all()),
])

View File

@@ -7,13 +7,19 @@ namespace App\Filament\Resources\Applications\Pages;
use App\Filament\Resources\Applications\ApplicationResource;
use App\Filament\Resources\Applications\Tables\ApplicationsTable;
use App\Models\Application;
use App\Models\ApplicationReply;
use App\Models\ReplyTemplate;
use App\Support\Inbox\Replier;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Support\Facades\Auth;
class ViewApplication extends ViewRecord
{
@@ -24,10 +30,67 @@ class ViewApplication extends ViewRecord
return [
// Answering is the point of the record, so the reply is the first
// button rather than something to copy out of the page.
// Answering is the point of the record, so the reply is the first
// button: a template to start from, edited here, and sent — by
// this application when it can send mail, or handed to the
// person's own mail program with the text already written.
Action::make('reply')
->label(__('panel.applications.actions.reply'))
->icon('heroicon-o-envelope')
->color('primary')
->authorize('update')
->modalHeading(fn (Application $record) => __('panel.replies.heading', ['name' => $record->name]))
->modalDescription(fn () => Replier::canSendMail() ? __('panel.replies.sends') : __('panel.replies.hands_over'))
->modalSubmitActionLabel(fn () => Replier::canSendMail() ? __('panel.replies.send') : __('panel.replies.open_mail'))
->schema([
Select::make('template_id')
->label(__('panel.replies.fields.template'))
->options(fn (Application $record) => ReplyTemplate::query()->for($record->kind)->pluck('name', 'id')->all())
->placeholder(__('panel.replies.no_template'))
->live()
->afterStateUpdated(function (?string $state, callable $set, Application $record): void {
$template = $state ? ReplyTemplate::find($state) : null;
if ($template !== null) {
$text = $template->renderFor($record, Auth::user());
$set('subject', $text['subject']);
$set('body', $text['body']);
}
}),
TextInput::make('subject')
->label(__('panel.replies.fields.subject'))
->default(fn (Application $record) => __('panel.applications.reply_subject', ['subject' => $record->subject()], $record->locale))
->required()
->maxLength(255),
Textarea::make('body')
->label(__('panel.replies.fields.body'))
->helperText(fn (Application $record) => __('panel.replies.in_language', [
'language' => config("locales.supported.{$record->locale}.native", $record->locale),
]))
->required()
->rows(12),
])
->action(function (Application $record, array $data, $livewire): void {
$reply = Replier::reply($record, $data['subject'], $data['body'], Auth::user());
if ($reply->channel === ApplicationReply::MAILTO) {
$livewire->js('window.location.href = '.json_encode(Replier::mailto($record, $data['subject'], $data['body'])));
}
Notification::make()
->title($reply->channel === ApplicationReply::MAIL ? __('panel.replies.sent') : __('panel.replies.recorded'))
->success()
->send();
}),
// The way it always worked: an empty message in your own mail
// program, for an answer that fits no template.
Action::make('reply_elsewhere')
->label(__('panel.replies.elsewhere'))
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(fn (Application $record) => 'mailto:'.$record->email.'?subject='
.rawurlencode(__('panel.applications.reply_subject', [
'subject' => $record->subject(),

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Filament\Resources\Applications\Schemas;
use App\Models\Application;
use App\Models\ApplicationReply;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
@@ -69,6 +71,36 @@ class ApplicationInfolist
->visible(fn (Application $record) => filled($record->links)),
]),
// What was already said, so the next person to open this does not
// say it again.
Section::make(__('panel.replies.history'))
->visible(fn (Application $record) => $record->replies()->exists())
->schema([
RepeatableEntry::make('replies')
->hiddenLabel()
->schema([
TextEntry::make('subject')
->hiddenLabel()
->weight('bold'),
TextEntry::make('created_at')
->hiddenLabel()
->state(fn (ApplicationReply $record) => __('panel.replies.by', [
'name' => $record->user_name ?? '—',
'when' => $record->created_at?->isoFormat('LLL'),
]).' · '.__("panel.replies.channels.{$record->channel}"))
->color('gray'),
TextEntry::make('body')
->hiddenLabel()
->prose()
->formatStateUsing(fn (string $state) => nl2br(e($state)))
->html()
->columnSpanFull(),
])
->columns(2),
]),
Section::make(__('panel.applications.sections.internal'))
->description(__('panel.applications.sections.internal_hint'))
->columns(4)

View File

@@ -10,6 +10,7 @@ use App\Filament\Resources\Calls\Pages\CreateCall;
use App\Filament\Resources\Calls\Pages\EditCall;
use App\Filament\Resources\Calls\Pages\ListCalls;
use App\Filament\Resources\Calls\RelationManagers\ApplicationsRelationManager;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\TranslatableTabs;
use App\Filament\Support\TranslatedOptionLabel;
@@ -20,7 +21,6 @@ use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ReplicateAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
@@ -230,17 +230,9 @@ class CallResource extends Resource
->preload(),
])
->recordActions([
// A near-copy is a real workflow: the third similar service,
// next year's call. The slug is cleared because two records
// cannot answer at the same URL.
ReplicateAction::make()
->label(__('panel.actions.duplicate'))
->icon('heroicon-o-document-duplicate')
->excludeAttributes(['slug', 'published_at', 'unpublish_at'])
->modalHeading(__('panel.actions.duplicate_heading'))
->successNotificationTitle(__('panel.actions.duplicated')),
PreviewAction::for('call'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),

View File

@@ -6,6 +6,9 @@ namespace App\Filament\Resources\Calls\Pages;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
use App\Filament\Resources\Calls\CallResource;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\TranslateAction;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
@@ -18,6 +21,10 @@ class EditCall extends EditRecord
protected function getHeaderActions(): array
{
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('call'),
DuplicateAction::make(),
TranslateAction::make(),
DeleteAction::make(),
];
}

View File

@@ -7,6 +7,9 @@ namespace App\Filament\Resources\Pages\Pages;
use App\Enums\PageStatus;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
use App\Filament\Resources\Pages\PageResource;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\TranslateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
@@ -21,6 +24,10 @@ class EditPage extends EditRecord
protected function getHeaderActions(): array
{
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('page'),
DuplicateAction::make(),
TranslateAction::make(),
DeleteAction::make(),
ForceDeleteAction::make(),
RestoreAction::make(),

View File

@@ -9,6 +9,7 @@ use App\Filament\Resources\Pages\PageResource;
use App\Filament\Support\TranslatableTabs;
use App\Filament\Support\TranslatedOptionLabel;
use App\Models\Page;
use App\Models\Tag;
use App\Rules\UniqueTranslatedSlug;
use App\Support\DuplicateTitles;
use Filament\Forms\Components\DateTimePicker;
@@ -126,6 +127,39 @@ class PageForm
->searchable()
->preload(),
// What the post is about, for the blog to be read by
// subject. A missing one is made from here.
Select::make('tags')
->label(__('panel.tags.plural'))
->relationship('tags', 'name->'.config('locales.default'))
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name'))
->multiple()
->searchable()
->preload()
->createOptionForm([
TextInput::make('name')
->label(__('panel.tags.fields.name'))
->required()
->maxLength(80),
])
->createOptionUsing(function (array $data): int {
$locale = (string) config('locales.default');
return Tag::query()->create([
'name' => [$locale => $data['name']],
'slug' => [$locale => Str::slug($data['name'])],
])->getKey();
})
->createOptionAction(fn ($action) => $action->authorize('create', Tag::class)),
// Who signs it on the site, with a link to their profile.
Select::make('byline_member_id')
->label(__('panel.pages.fields.byline'))
->helperText(__('panel.pages.fields.byline_hint'))
->relationship('byline', 'name')
->searchable()
->preload(),
Select::make('author_id')
->label(__('panel.pages.fields.author'))
->relationship('author', 'name')

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\Pages\Tables;
use App\Enums\PageStatus;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\ReviewActions;
use App\Filament\Support\ReviewStatusColumn;
@@ -20,7 +21,6 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn;
@@ -211,17 +211,8 @@ class PagesTable
&& Auth::user()?->can('publish', $record))
->action(fn (Page $record) => $record->update(['status' => PageStatus::Draft])),
// A near-copy is a real workflow: the third similar service,
// next year's call. The slug is cleared because two records
// cannot answer at the same URL.
ReplicateAction::make()
->label(__('panel.actions.duplicate'))
->icon('heroicon-o-document-duplicate')
->excludeAttributes(['slug', 'published_at', 'unpublish_at'])
->modalHeading(__('panel.actions.duplicate_heading'))
->successNotificationTitle(__('panel.actions.duplicated')),
PreviewAction::for('page'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),

View File

@@ -4,10 +4,79 @@ declare(strict_types=1);
namespace App\Filament\Resources\Projects\Pages;
use App\Actions\Projects\OpenInTaskStream;
use App\Filament\Resources\Projects\ProjectResource;
use App\Models\Project;
use App\Services\Pireon\TokenStore;
use App\Services\TaskStream\TaskStreamClient;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
class CreateProject extends CreateRecord
{
protected static string $resource = ProjectResource::class;
/**
* With the TaskStream toggle on, the board is opened before the project
* is saved, as the person signed in (their Pireon token from the session).
*/
protected function handleRecordCreation(array $data): Model
{
$taskstream = (bool) Arr::pull($data, 'taskstream', false);
$key = Arr::pull($data, 'taskstream_key');
$project = new Project($data);
if ($taskstream) {
$this->openInTaskStream($project, filled($key) ? $key : null);
}
$project->save();
return $project;
}
protected function openInTaskStream(Project $project, ?string $key): void
{
try {
app(OpenInTaskStream::class)->handle($project, Auth::user(), app(TokenStore::class)->accessToken(), $key);
} catch (ValidationException $e) {
// The action names the API's fields; here they live under `data`.
throw ValidationException::withMessages(
collect($e->errors())->mapWithKeys(fn (array $messages, string $field) => ["data.{$field}" => $messages])->all(),
);
} catch (HttpException $e) {
Notification::make()->danger()->title($e->getMessage())->send();
throw (new Halt)->rollBackDatabaseTransaction();
}
}
protected function getCreatedNotification(): ?Notification
{
$notification = parent::getCreatedNotification();
$key = $this->getRecord()->taskstream_key;
if ($notification === null || $key === null) {
return $notification;
}
$url = app(TaskStreamClient::class)->projectUrl($key);
return $notification
->body(__('panel.projects.taskstream.created', ['url' => $url]))
->actions([
Action::make('taskstream')
->label(__('panel.projects.taskstream.open'))
->url($url, shouldOpenInNewTab: true),
])
->persistent();
}
}

View File

@@ -4,10 +4,22 @@ declare(strict_types=1);
namespace App\Filament\Resources\Projects\Pages;
use App\Actions\Projects\OpenInTaskStream;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
use App\Filament\Resources\Projects\ProjectResource;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\TranslateAction;
use App\Services\Pireon\TokenStore;
use App\Services\TaskStream\TaskStreamClient;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
class EditProject extends EditRecord
{
@@ -18,7 +30,81 @@ class EditProject extends EditRecord
protected function getHeaderActions(): array
{
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('project'),
Action::make('taskstream')
->label(__('panel.projects.taskstream.open'))
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(fn () => app(TaskStreamClient::class)->projectUrl((string) $this->record->taskstream_key))
->openUrlInNewTab()
->visible(fn () => filled($this->record->taskstream_key)),
$this->createInTaskStream(),
DuplicateAction::make(),
TranslateAction::make(),
DeleteAction::make(),
];
}
/**
* For a project made without a board: opens one now, as the person
* signed in, and saves only the link (whatever is being edited in the
* form stays as it is, unsaved).
*/
protected function createInTaskStream(): Action
{
return Action::make('createInTaskStream')
->label(__('panel.projects.taskstream.create'))
->icon('heroicon-o-plus')
->color('gray')
->visible(fn () => blank($this->record->taskstream_key))
->authorize('update')
->modalHeading(__('panel.projects.taskstream.create_heading'))
->modalDescription(__('panel.projects.taskstream.section_hint'))
->modalSubmitActionLabel(__('panel.projects.taskstream.create'))
->schema([
TextInput::make('key')
->label(__('panel.projects.taskstream.key'))
->helperText(__('panel.projects.taskstream.key_hint'))
->placeholder(fn () => app(OpenInTaskStream::class)
->keyFrom((string) $this->record->getTranslation('slug', (string) config('locales.default'), false)))
->maxLength(10)
->regex('/^[A-Za-z][A-Za-z0-9]{1,9}$/'),
])
->action(function (array $data, Action $action): void {
$project = $this->record;
try {
app(OpenInTaskStream::class)->handle(
$project,
Auth::user(),
app(TokenStore::class)->accessToken(),
filled($data['key'] ?? null) ? $data['key'] : null,
);
} catch (ValidationException|HttpException $e) {
// The modal stays open, to change the key and try again.
Notification::make()
->danger()
->title($e instanceof ValidationException ? collect($e->errors())->flatten()->first() : $e->getMessage())
->send();
$action->halt();
}
$project->save();
$url = app(TaskStreamClient::class)->projectUrl((string) $project->taskstream_key);
Notification::make()
->success()
->title(__('panel.projects.taskstream.created', ['url' => $url]))
->actions([
Action::make('taskstream')
->label(__('panel.projects.taskstream.open'))
->url($url, shouldOpenInNewTab: true),
])
->persistent()
->send();
});
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\Projects\Schemas;
use App\Actions\Projects\OpenInTaskStream;
use App\Enums\ProjectStatus;
use App\Filament\Resources\Projects\ProjectResource;
use App\Filament\Support\TranslatableTabs;
@@ -221,6 +222,27 @@ class ProjectForm
->label(__('panel.projects.fields.needs_help'))
->helperText(__('panel.projects.fields.needs_help_hint')),
]),
// Only when creating: CreateProject opens the board before
// saving, so a refusal there leaves nothing half made here.
Section::make(__('panel.projects.taskstream.section'))
->description(__('panel.projects.taskstream.section_hint'))
->visibleOn('create')
->columns(2)
->schema([
Toggle::make('taskstream')
->label(__('panel.projects.taskstream.toggle'))
->live(),
TextInput::make('taskstream_key')
->label(__('panel.projects.taskstream.key'))
->helperText(__('panel.projects.taskstream.key_hint'))
->placeholder(fn (callable $get) => app(OpenInTaskStream::class)
->keyFrom((string) $get('slug.'.config('locales.default'))))
->maxLength(10)
->regex('/^[A-Za-z][A-Za-z0-9]{1,9}$/')
->visible(fn (callable $get) => (bool) $get('taskstream')),
]),
]);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\Projects\Tables;
use App\Enums\ProjectStatus;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\ReviewActions;
use App\Filament\Support\ReviewStatusColumn;
@@ -19,7 +20,6 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn;
@@ -197,17 +197,8 @@ class ProjectsTable
&& Auth::user()?->can('publish', $record))
->action(fn (Project $record) => $record->update(['published_at' => now()])),
// A near-copy is a real workflow: the third similar service,
// next year's call. The slug is cleared because two records
// cannot answer at the same URL.
ReplicateAction::make()
->label(__('panel.actions.duplicate'))
->icon('heroicon-o-document-duplicate')
->excludeAttributes(['slug', 'published_at', 'unpublish_at'])
->modalHeading(__('panel.actions.duplicate_heading'))
->successNotificationTitle(__('panel.actions.duplicated')),
PreviewAction::for('project'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReplyTemplates\Pages;
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
use Filament\Resources\Pages\CreateRecord;
class CreateReplyTemplate extends CreateRecord
{
protected static string $resource = ReplyTemplateResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReplyTemplates\Pages;
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditReplyTemplate extends EditRecord
{
protected static string $resource = ReplyTemplateResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReplyTemplates\Pages;
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListReplyTemplates extends ListRecords
{
protected static string $resource = ReplyTemplateResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReplyTemplates;
use App\Enums\ApplicationKind;
use App\Filament\Resources\ReplyTemplates\Pages\CreateReplyTemplate;
use App\Filament\Resources\ReplyTemplates\Pages\EditReplyTemplate;
use App\Filament\Resources\ReplyTemplates\Pages\ListReplyTemplates;
use App\Filament\Support\TranslatableTabs;
use App\Models\ReplyTemplate;
use BackedEnum;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use UnitEnum;
/**
* The answers the inbox gives often enough to write once.
*/
class ReplyTemplateResource extends Resource
{
protected static ?string $model = ReplyTemplate::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleBottomCenterText;
protected static ?int $navigationSort = 5;
protected static ?string $recordTitleAttribute = 'name';
public static function getModelLabel(): string
{
return __('panel.replies.templates.label');
}
public static function getPluralModelLabel(): string
{
return __('panel.replies.templates.plural');
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.collective');
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Section::make()
->columns(2)
->schema([
TextInput::make('name')
->label(__('panel.replies.templates.fields.name'))
->helperText(__('panel.replies.templates.fields.name_hint'))
->required()
->maxLength(255),
Select::make('kind')
->label(__('panel.replies.templates.fields.kind'))
->options(ApplicationKind::options())
->placeholder(__('panel.replies.templates.any_kind')),
TextInput::make('position')
->label(__('panel.replies.templates.fields.position'))
->numeric()
->minValue(0)
->default(0),
]),
Section::make()
->description(__('panel.replies.templates.placeholders', ['list' => implode(', ', ReplyTemplate::PLACEHOLDERS)]))
->schema([
TranslatableTabs::make(fn (string $locale, bool $isDefault) => [
TextInput::make("subject.{$locale}")
->label(__('panel.replies.fields.subject'))
->required($isDefault)
->maxLength(255),
Textarea::make("body.{$locale}")
->label(__('panel.replies.fields.body'))
->required($isDefault)
->rows(10),
]),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('position')
->columns([
TextColumn::make('name')
->label(__('panel.replies.templates.fields.name'))
->searchable()
->weight('bold'),
TextColumn::make('kind')
->label(__('panel.replies.templates.fields.kind'))
->badge()
->formatStateUsing(fn (?string $state) => $state ? ApplicationKind::from($state)->getLabel() : __('panel.replies.templates.any_kind'))
->placeholder(__('panel.replies.templates.any_kind')),
TextColumn::make('subject')
->label(__('panel.replies.fields.subject'))
->limit(60),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => ListReplyTemplates::route('/'),
'create' => CreateReplyTemplate::route('/create'),
'edit' => EditReplyTemplate::route('/{record}/edit'),
];
}
}

View File

@@ -6,6 +6,9 @@ namespace App\Filament\Resources\Services\Pages;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
use App\Filament\Resources\Services\ServiceResource;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\TranslateAction;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
@@ -18,6 +21,10 @@ class EditService extends EditRecord
protected function getHeaderActions(): array
{
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('service'),
DuplicateAction::make(),
TranslateAction::make(),
DeleteAction::make(),
];
}

View File

@@ -9,6 +9,7 @@ use App\Filament\RelationManagers\RevisionsRelationManager;
use App\Filament\Resources\Services\Pages\CreateService;
use App\Filament\Resources\Services\Pages\EditService;
use App\Filament\Resources\Services\Pages\ListServices;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\ReviewActions;
use App\Filament\Support\ReviewStatusColumn;
@@ -26,7 +27,6 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
@@ -323,17 +323,8 @@ class ServiceResource extends Resource
ReviewActions::request(),
ReviewActions::returnForChanges(),
// A near-copy is a real workflow: the third similar service,
// next year's call. The slug is cleared because two records
// cannot answer at the same URL.
ReplicateAction::make()
->label(__('panel.actions.duplicate'))
->icon('heroicon-o-document-duplicate')
->excludeAttributes(['slug', 'published_at', 'unpublish_at'])
->modalHeading(__('panel.actions.duplicate_heading'))
->successNotificationTitle(__('panel.actions.duplicated')),
PreviewAction::for('service'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Tags\Pages;
use App\Filament\Resources\Tags\TagResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ManageRecords;
class ManageTags extends ManageRecords
{
protected static string $resource = TagResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Tags;
use App\Filament\Resources\Tags\Pages\ManageTags;
use App\Filament\Support\TranslatableTabs;
use App\Models\Tag;
use App\Rules\UniqueTranslatedSlug;
use BackedEnum;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use UnitEnum;
/**
* Blog tags: what each post is about, named in every language.
*/
class TagResource extends Resource
{
protected static ?string $model = Tag::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedHashtag;
protected static ?int $navigationSort = 3;
public static function getModelLabel(): string
{
return __('panel.tags.label');
}
public static function getPluralModelLabel(): string
{
return __('panel.tags.plural');
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.content');
}
public static function getRecordTitle(?Model $record): ?string
{
return $record?->getTranslation('name', app()->getLocale());
}
public static function form(Schema $schema): Schema
{
return $schema->components([
TranslatableTabs::make(fn (string $locale, bool $isDefault) => [
TextInput::make("name.{$locale}")
->label(__('panel.tags.fields.name'))
->required($isDefault)
->maxLength(80)
->live(onBlur: true)
->afterStateUpdated(function (?string $state, callable $get, callable $set) use ($locale): void {
if (blank($get("slug.{$locale}"))) {
$set("slug.{$locale}", Str::slug((string) $state));
}
}),
TextInput::make("slug.{$locale}")
->label(__('panel.tags.fields.slug'))
->required($isDefault)
->maxLength(80)
->rules(fn ($record) => [new UniqueTranslatedSlug(Tag::class, $record?->id)]),
])->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn ($query) => $query->withCount('pages'))
->columns([
TextColumn::make('name')
->label(__('panel.tags.fields.name'))
->searchable(),
TextColumn::make('slug')
->label(__('panel.tags.fields.slug'))
->color('gray'),
TextColumn::make('pages_count')
->label(__('panel.tags.fields.pages'))
->sortable(),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => ManageTags::route('/'),
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Webhooks\Pages;
use App\Filament\Resources\Webhooks\WebhookResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
/**
* The secret is made here and shown once, on the page that opens next.
*/
class CreateWebhook extends CreateRecord
{
protected static string $resource = WebhookResource::class;
protected string $secret = '';
protected function mutateFormDataBeforeCreate(array $data): array
{
$this->secret = Str::random(40);
return [...$data, 'secret' => $this->secret];
}
protected function afterCreate(): void
{
$this->record->forceFill(['created_by' => Auth::id()])->save();
WebhookResource::showSecret($this->secret);
}
protected function getCreatedNotification(): null
{
return null;
}
protected function getRedirectUrl(): string
{
return WebhookResource::getUrl('edit', ['record' => $this->record]);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Webhooks\Pages;
use App\Filament\Resources\Webhooks\WebhookResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditWebhook extends EditRecord
{
protected static string $resource = WebhookResource::class;
protected function getHeaderActions(): array
{
return [
WebhookResource::pingAction(),
WebhookResource::rotateAction(),
DeleteAction::make(),
];
}
/**
* Switching it back on starts its failure count again, as through the API.
*/
protected function mutateFormDataBeforeSave(array $data): array
{
if (($data['is_active'] ?? false) && ! $this->record->is_active) {
$this->record->consecutive_failures = 0;
}
return $data;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Webhooks\Pages;
use App\Filament\Resources\Webhooks\WebhookResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWebhooks extends ListRecords
{
protected static string $resource = WebhookResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Webhooks\RelationManagers;
use App\Models\WebhookDelivery;
use App\Support\Webhooks\Webhooks;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;
/**
* What was sent, and what the other end answered.
*/
class DeliveriesRelationManager extends RelationManager
{
protected static string $relationship = 'deliveries';
public static function getTitle(Model $ownerRecord, string $pageClass): string
{
return __('panel.webhooks.deliveries.title');
}
public function isReadOnly(): bool
{
return true;
}
public function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->poll('10s')
->columns([
TextColumn::make('event')
->label(__('panel.webhooks.deliveries.event'))
->badge()
->color('gray'),
TextColumn::make('status')
->label(__('panel.webhooks.deliveries.status'))
->badge()
->formatStateUsing(fn (string $state) => __("panel.webhooks.deliveries.statuses.{$state}"))
->color(fn (string $state) => match ($state) {
WebhookDelivery::SUCCEEDED => 'success',
WebhookDelivery::FAILED => 'danger',
default => 'warning',
}),
TextColumn::make('response_status')
->label(__('panel.webhooks.deliveries.response'))
->placeholder('—'),
TextColumn::make('attempts')
->label(__('panel.webhooks.deliveries.attempts')),
TextColumn::make('error')
->label(__('panel.webhooks.deliveries.error'))
->limit(60)
->tooltip(fn (WebhookDelivery $record) => $record->error)
->placeholder('—')
->color('danger'),
TextColumn::make('created_at')
->label(__('panel.webhooks.deliveries.created_at'))
->since()
->dateTimeTooltip(),
])
->filters([
SelectFilter::make('status')
->label(__('panel.webhooks.deliveries.status'))
->options(collect([WebhookDelivery::PENDING, WebhookDelivery::SUCCEEDED, WebhookDelivery::FAILED])
->mapWithKeys(fn (string $s) => [$s => __("panel.webhooks.deliveries.statuses.{$s}")])
->all()),
])
->recordActions([
Action::make('payload')
->label(__('panel.webhooks.deliveries.payload'))
->icon('heroicon-o-code-bracket')
->color('gray')
->modalHeading(fn (WebhookDelivery $record) => $record->event.' · '.$record->uuid)
->modalSubmitAction(false)
->modalContent(fn (WebhookDelivery $record) => new HtmlString(
'<pre style="white-space: pre-wrap; overflow-wrap: anywhere; font-size: .8125rem;">'
.e((string) json_encode($record->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))
.'</pre>'
)),
Action::make('redeliver')
->label(__('panel.webhooks.deliveries.redeliver'))
->icon('heroicon-o-arrow-path')
->requiresConfirmation()
->authorize(fn () => $this->getOwnerRecord() && auth()->user()?->can('update', $this->getOwnerRecord()))
->action(function (WebhookDelivery $record): void {
Webhooks::redeliver($record);
Notification::make()->title(__('panel.webhooks.deliveries.redelivered'))->success()->send();
}),
]);
}
}

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Webhooks;
use App\Filament\Resources\Webhooks\Pages\CreateWebhook;
use App\Filament\Resources\Webhooks\Pages\EditWebhook;
use App\Filament\Resources\Webhooks\Pages\ListWebhooks;
use App\Filament\Resources\Webhooks\RelationManagers\DeliveriesRelationManager;
use App\Models\Webhook;
use App\Rules\PublicHttpsUrl;
use App\Support\Webhooks\Webhooks;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use UnitEnum;
/**
* Outgoing webhooks: where to tell when something happens here, and how each
* delivery went.
*/
class WebhookResource extends Resource
{
protected static ?string $model = Webhook::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBolt;
protected static ?int $navigationSort = 85;
protected static ?string $recordTitleAttribute = 'name';
public static function getModelLabel(): string
{
return __('panel.webhooks.label');
}
public static function getPluralModelLabel(): string
{
return __('panel.webhooks.plural');
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.administration');
}
/**
* Red while one is switched off by failures: something is not hearing
* what it signed up for.
*/
public static function getNavigationBadge(): ?string
{
$off = static::getModel()::query()
->where('is_active', false)
->where('consecutive_failures', '>=', Webhook::MAX_FAILURES)
->count();
return $off > 0 ? (string) $off : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'danger';
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Section::make()
->columns(2)
->schema([
TextInput::make('name')
->label(__('panel.webhooks.fields.name'))
->required()
->maxLength(120),
TextInput::make('url')
->label(__('panel.webhooks.fields.url'))
->helperText(__('panel.webhooks.fields.url_hint'))
->required()
->url()
->maxLength(2048)
->rule(new PublicHttpsUrl),
Select::make('events')
->label(__('panel.webhooks.fields.events'))
->helperText(__('panel.webhooks.events_hint'))
->multiple()
->searchable()
->options(fn () => self::eventOptions())
->required()
->columnSpanFull(),
Toggle::make('include_personal_data')
->label(__('panel.webhooks.fields.include_personal_data'))
->helperText(__('panel.webhooks.fields.include_personal_data_hint')),
Toggle::make('is_active')
->label(__('panel.webhooks.fields.is_active'))
->default(true),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('panel.webhooks.fields.name'))
->weight('bold')
->description(fn (Webhook $record) => $record->url)
->searchable(),
TextColumn::make('events')
->label(__('panel.webhooks.fields.events'))
->badge()
->color('gray'),
IconColumn::make('is_active')
->label(__('panel.webhooks.fields.is_active'))
->boolean(),
TextColumn::make('consecutive_failures')
->label(__('panel.webhooks.fields.failures'))
->color(fn (int $state) => $state > 0 ? 'danger' : 'gray'),
TextColumn::make('last_delivered_at')
->label(__('panel.webhooks.fields.last_delivered_at'))
->since()
->dateTimeTooltip()
->placeholder('—'),
])
->recordActions([
self::pingAction(),
EditAction::make(),
DeleteAction::make(),
]);
}
public static function eventLabel(string $event): string
{
$key = str_replace('.', '_', $event);
return __("panel.webhooks.events.{$key}");
}
/**
* Grouped for the select: everything, the events with a meaning of their
* own, any record by action, then each kind of record.
*
* @return array<string, array<string, string>>
*/
public static function eventOptions(): array
{
$groups = [
__('panel.webhooks.groups.everything') => [
'*' => __('panel.webhooks.events.*'),
...collect(Webhook::ACTIONS)->mapWithKeys(fn (string $action) => [
"*.{$action}" => __('panel.webhooks.any_record', ['action' => self::actionLabel($action)]),
])->all(),
],
__('panel.webhooks.groups.notices') => collect(Webhook::EVENTS)
->mapWithKeys(fn (string $event) => [$event => self::eventLabel($event)])
->all(),
];
foreach (Webhook::RESOURCES as $resource) {
$groups[__("panel.webhooks.resources.{$resource}")] = [
"{$resource}.*" => __('panel.webhooks.all_of', ['resource' => __("panel.webhooks.resources.{$resource}")]),
...collect(Webhook::ACTIONS)->mapWithKeys(fn (string $action) => [
"{$resource}.{$action}" => __("panel.webhooks.resources.{$resource}").' · '.self::actionLabel($action),
])->all(),
];
}
return $groups;
}
protected static function actionLabel(string $action): string
{
return __("panel.webhooks.actions_on.{$action}");
}
public static function pingAction(): Action
{
return Action::make('ping')
->label(__('panel.webhooks.actions.ping'))
->icon('heroicon-o-paper-airplane')
->color('gray')
->authorize('update')
->action(function (Webhook $record): void {
Webhooks::ping($record);
Notification::make()
->title(__('panel.webhooks.actions.ping_sent'))
->body(__('panel.webhooks.actions.ping_hint'))
->success()
->send();
});
}
/**
* A new secret, shown once. The old one stops being valid at once, so the
* receiving end has to be updated straight after.
*/
public static function rotateAction(): Action
{
return Action::make('rotate_secret')
->label(__('panel.webhooks.actions.rotate'))
->icon('heroicon-o-key')
->color('warning')
->requiresConfirmation()
->modalDescription(__('panel.webhooks.actions.rotate_hint'))
->authorize('update')
->action(function (Webhook $record): void {
$secret = Str::random(40);
$record->forceFill(['secret' => $secret])->save();
self::showSecret($secret);
});
}
public static function showSecret(string $secret): void
{
Notification::make()
->title(__('panel.webhooks.secret.title'))
->body(__('panel.webhooks.secret.body', ['secret' => $secret]))
->warning()
->persistent()
->send();
}
public static function getRelations(): array
{
return [
DeliveriesRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListWebhooks::route('/'),
'create' => CreateWebhook::route('/create'),
'edit' => EditWebhook::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Filament\Support;
use App\Support\Duplicator;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Model;
/**
* "Duplicate": a draft copy to start the next one from, opened straight away.
*/
final class DuplicateAction
{
public static function make(): Action
{
return Action::make('duplicate')
->label(__('panel.duplicate.label'))
->icon('heroicon-o-document-duplicate')
->color('gray')
->requiresConfirmation()
->modalHeading(__('panel.duplicate.heading'))
->modalDescription(__('panel.duplicate.hint'))
->authorize('create')
->action(function (Model $record, $livewire): void {
$copy = Duplicator::duplicate($record);
Notification::make()
->title(__('panel.duplicate.done'))
->success()
->send();
$livewire->redirect($livewire::getResource()::getUrl('edit', ['record' => $copy]));
});
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Filament\Support;
use App\Filament\Pages\TranslationAssistant;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Illuminate\Database\Eloquent\Model;
/**
* "Translate into…": this record in the translation assistant, one entry per
* language there is to translate into.
*/
final class TranslateAction
{
public static function make(): ActionGroup
{
$default = (string) config('locales.default');
return ActionGroup::make(collect(config('locales.supported'))
->except($default)
->map(fn (array $meta, string $locale) => Action::make("translate_{$locale}")
->label($meta['native'] ?? $locale)
->url(fn (Model $record) => TranslationAssistant::urlFor($record, $locale)))
->values()
->all())
->label(__('panel.translate.action'))
->icon('heroicon-o-language')
->color('gray')
->button();
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Support\Expiring;
use Filament\Widgets\Widget;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
/**
* "Coming up": calls closing and content coming off the site in the next two
* weeks, for whoever can do something about them.
*/
class ExpiringSoon extends Widget
{
public const DAYS = 14;
protected static ?int $sort = 1;
protected string $view = 'filament.widgets.expiring-soon';
protected int|string|array $columnSpan = 'full';
public static function canView(): bool
{
$user = Auth::user();
return $user !== null && Expiring::for(Expiring::within(self::DAYS), $user)->isNotEmpty();
}
/**
* @return Collection<int, array<string, mixed>>
*/
public function items(): Collection
{
return Expiring::for(Expiring::within(self::DAYS), Auth::user());
}
}

View File

@@ -6,7 +6,9 @@ namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Http\Resources\PublicApi\PublicPageResource;
use App\Models\Page;
use App\Models\Tag;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -15,12 +17,15 @@ class PageController extends PublicController
public function index(Request $request): AnonymousResourceCollection
{
$pages = Page::query()
->with(['category', 'featuredMedia'])
->with(['category', 'featuredMedia', 'tags', 'byline.avatarMedia'])
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
->when($request->filled('category'), fn (Builder $q) => $q
->whereHas('category', fn (Builder $c) => $c
->where('slug->'.app()->getLocale(), $request->string('category'))))
->when($request->boolean('featured'), fn (Builder $q) => $q->where('is_featured', true))
->when($request->filled('tag'), fn (Builder $q) => $q
->whereHas('tags', fn (Builder $t) => $t
->where('slug->'.app()->getLocale(), $request->string('tag'))))
->orderByDesc('published_at')
->paginate($this->perPage($request))
->withQueryString();
@@ -31,11 +36,38 @@ class PageController extends PublicController
public function show(Request $request, string $slug): PublicPageResource
{
$page = Page::query()
->with(['category', 'featuredMedia'])
->with(['category', 'featuredMedia', 'tags', 'byline.avatarMedia'])
->whereSlug($slug)
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
->firstOrFail();
return PublicPageResource::make($page);
}
/**
* The tags used by something published, with how many posts each has in
* the request's language — for a blog to offer them as a way in.
*/
public function tags(Request $request): JsonResponse
{
$locale = app()->getLocale();
$published = fn (Builder $q) => $q
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
->whereNotNull("slug->{$locale}");
$tags = Tag::query()
->whereHas('pages', $published)
->withCount(['pages' => $published])
->get()
->filter(fn (Tag $tag) => filled($tag->getTranslation('slug', $locale, false)))
->sortByDesc('pages_count')
->map(fn (Tag $tag) => [
'name' => $tag->getTranslation('name', $locale),
'slug' => $tag->getTranslation('slug', $locale, false),
'count' => (int) $tag->pages_count,
])
->values();
return response()->json(['data' => $tags]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Support\Search\SiteSearch;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* GET /public/v1/search?q=…
*
* What the site shows that matches some words, in the request's language,
* best match first.
*/
class SearchController extends PublicController
{
public function __invoke(Request $request, SiteSearch $search): JsonResponse
{
$data = $request->validate([
'q' => ['required', 'string', 'min:2', 'max:120'],
'type' => ['sometimes', 'array'],
'type.*' => [Rule::in(SiteSearch::TYPES)],
'limit' => ['sometimes', 'integer', 'min:1', 'max:50'],
]);
$results = $search->search(
$data['q'],
app()->getLocale(),
$data['type'] ?? SiteSearch::TYPES,
(int) ($data['limit'] ?? 20),
);
return response()->json([
'data' => $results,
'meta' => ['query' => $data['q'], 'locale' => app()->getLocale(), 'count' => $results->count()],
]);
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\ApiTokenResource;
use App\Models\ApiToken;
use App\Models\User;
use App\Support\Permissions;
use Carbon\CarbonImmutable;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Str;
/**
* Machine tokens: made by a person, for something that is not one.
*
* Only a person signed in through the identity server manages them — a
* machine token cannot make another, or a leaked one would be a way of never
* expiring.
*/
class ApiTokenController extends Controller
{
/**
* The caller's own tokens, newest first. A super admin may ask for
* everyone's with `all`.
*/
public function index(Request $request): AnonymousResourceCollection
{
$user = $this->person($request);
$tokens = ApiToken::query()
->with('user')
->unless($request->boolean('all') && $user->hasRole('super_admin'), fn ($q) => $q->whereBelongsTo($user))
->latest('id')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return ApiTokenResource::collection($tokens);
}
public function store(Request $request): JsonResponse
{
$user = $this->person($request);
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'abilities' => ['required', 'array', 'min:1'],
'abilities.*' => [
'string',
'distinct',
// Only what the person could do themselves: a token narrows,
// it never widens.
function (string $attribute, mixed $value, Closure $fail) use ($user): void {
$matches = array_filter(Permissions::all(), fn (string $permission) => $permission === $value || Str::is((string) $value, $permission));
if ($matches === [] || ! collect($matches)->contains(fn (string $permission) => $user->can($permission))) {
$fail(__('rules.tokens.ability', ['ability' => $value]));
}
},
],
'expires_at' => ['sometimes', 'date', 'after:now', 'before_or_equal:'.now()->addDays(ApiToken::MAX_DAYS)->toIso8601String()],
]);
[$token, $plain] = ApiToken::issue(
$user,
$data['name'],
$data['abilities'],
isset($data['expires_at']) ? CarbonImmutable::parse($data['expires_at']) : now()->addDays(90),
);
return ApiTokenResource::make($token->load('user'))
->additional(['token' => $plain])
->response()
->setStatusCode(201);
}
/**
* Revoke. Kept in the list afterwards, so it is clear what stopped working.
*/
public function destroy(Request $request, ApiToken $token): JsonResponse
{
$user = $this->person($request);
abort_unless($token->user_id === $user->id || $user->hasRole('super_admin'), 403);
$token->revoke();
return response()->json(status: 204);
}
protected function person(Request $request): User
{
$user = $request->user();
abort_if($user->currentAccessToken() !== null, 403, __('rules.tokens.machine'));
return $user;
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ApplicationReply;
use App\Models\ReplyTemplate;
use App\Support\Inbox\Replier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* Answering the inbox through the API: what was already said, the templates
* to start from, and sending an answer — the panel's "Reply".
*/
class ApplicationReplyController extends Controller
{
public function index(Application $application): JsonResponse
{
$this->authorize('view', $application);
return response()->json([
'data' => $application->replies()->get()->map(fn (ApplicationReply $reply) => $this->present($reply))->values(),
]);
}
/**
* `subject` and `body`, or a `template_id` to fill them from — in the
* language the person wrote in. Sent when this installation can send
* mail; otherwise recorded, with a `mailto` link to send it by hand.
*/
public function store(Request $request, Application $application): JsonResponse
{
$this->authorize('update', $application);
$data = $request->validate([
'template_id' => ['sometimes', 'nullable', 'integer', 'exists:reply_templates,id'],
'subject' => ['required_without:template_id', 'nullable', 'string', 'max:255'],
'body' => ['required_without:template_id', 'nullable', 'string', 'max:20000'],
]);
$text = ['subject' => $data['subject'] ?? null, 'body' => $data['body'] ?? null];
if (filled($data['template_id'] ?? null)) {
$rendered = ReplyTemplate::query()->findOrFail($data['template_id'])->renderFor($application, $request->user());
$text = ['subject' => $text['subject'] ?: $rendered['subject'], 'body' => $text['body'] ?: $rendered['body']];
}
if (blank($text['subject']) || blank($text['body'])) {
throw ValidationException::withMessages(['template_id' => [__('panel.replies.empty_template')]]);
}
$reply = Replier::reply($application, $text['subject'], $text['body'], $request->user());
return response()->json([
'data' => [
...$this->present($reply),
'mailto' => $reply->channel === ApplicationReply::MAILTO
? Replier::mailto($application, $text['subject'], $text['body'])
: null,
],
], 201);
}
public function templates(Request $request): JsonResponse
{
$this->authorize('viewAny', ReplyTemplate::class);
return response()->json([
'data' => ReplyTemplate::query()->orderBy('position')->orderBy('name')->get()->map(fn (ReplyTemplate $template) => [
'id' => $template->id,
'name' => $template->name,
'kind' => $template->kind,
'subject' => (object) $template->getTranslations('subject'),
'body' => (object) $template->getTranslations('body'),
])->values(),
]);
}
/**
* @return array<string, mixed>
*/
protected function present(ApplicationReply $reply): array
{
return [
'id' => $reply->id,
'subject' => $reply->subject,
'body' => $reply->body,
'channel' => $reply->channel,
'author' => ['id' => $reply->user_id, 'name' => $reply->user_name],
'created_at' => $reply->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\AuditLogResource;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;
/**
* GET /v1/audit — who did what, when, and how they came in.
*/
class AuditController extends Controller
{
/**
* Newest first. Filters: user_id, action (exact, or a prefix ending in
* `.`, e.g. `pages.`), subject_type, subject_id, via, from, to.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('audit.view_any');
$request->validate([
'via' => ['sometimes', Rule::in(['panel', 'api', 'token', 'web', 'console'])],
'from' => ['sometimes', 'date'],
'to' => ['sometimes', 'date'],
]);
$entries = AuditLog::query()
->when($request->filled('user_id'), fn ($q) => $q->where('user_id', $request->integer('user_id')))
->when($request->filled('action'), function ($q) use ($request): void {
$action = (string) $request->string('action');
str_ends_with($action, '.')
? $q->where('action', 'like', $action.'%')
: $q->where('action', $action);
})
->when($request->filled('subject_type'), fn ($q) => $q->where('subject_type', $request->string('subject_type')))
->when($request->filled('subject_id'), fn ($q) => $q->where('subject_id', (string) $request->string('subject_id')))
->when($request->filled('via'), fn ($q) => $q->where('via', $request->string('via')))
->when($request->filled('from'), fn ($q) => $q->where('created_at', '>=', $request->date('from')))
->when($request->filled('to'), fn ($q) => $q->where('created_at', '<=', $request->date('to')))
->latest('id')
->paginate(perPage: min($request->integer('per_page', 50), 100))
->withQueryString();
return AuditLogResource::collection($entries);
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Support\AdminContent;
use App\Support\Batch\BatchRunner;
use App\Support\Batch\Importer;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* Several calls in one: a batch of any admin operations, and importing rows
* of one kind of record from JSON or CSV.
*/
class BatchController extends Controller
{
public function batch(Request $request, BatchRunner $runner): JsonResponse
{
$data = $request->validate([
'operations' => ['required', 'array', 'min:1', 'max:'.BatchRunner::MAX_OPERATIONS],
'operations.*.method' => ['required', Rule::in(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'get', 'post', 'put', 'patch', 'delete'])],
'operations.*.path' => [
'required',
'string',
'max:500',
function (string $attribute, mixed $value, Closure $fail): void {
if (! is_string($value) || ! BatchRunner::allows($value)) {
$fail(__('rules.batch.path'));
}
},
],
'operations.*.body' => ['sometimes', 'nullable', 'array'],
'atomic' => ['sometimes', 'boolean'],
]);
$outcome = $runner->run($request, $data['operations'], atomic: $request->boolean('atomic'));
return $this->answer($outcome);
}
/**
* Rows as JSON (`rows`) or a CSV file (`file`). `match` names the field
* that finds an existing record to update; `dry_run` validates everything
* and keeps nothing.
*/
public function import(Request $request, string $resource, BatchRunner $runner, Importer $importer): JsonResponse
{
abort_unless(array_key_exists($resource, AdminContent::WITH_HISTORY), 404);
$request->validate([
'rows' => ['required_without:file', 'array'],
'rows.*' => ['array'],
'file' => ['required_without:rows', 'file', 'mimes:csv,txt', 'max:2048'],
'match' => ['sometimes', 'nullable', 'string', Rule::in(['slug', 'name', 'full_name'])],
'atomic' => ['sometimes', 'boolean'],
'dry_run' => ['sometimes', 'boolean'],
]);
$rows = $request->hasFile('file')
? $importer->rowsFromCsv($request->file('file'))
: $request->input('rows');
$operations = $importer->operations($resource, $rows, $request->input('match'));
// A dry run is all-or-nothing by nature: it keeps nothing either way.
$outcome = $runner->run(
$request,
$operations,
atomic: $request->boolean('atomic', true) || $request->boolean('dry_run'),
rollback: $request->boolean('dry_run'),
);
return $this->answer($outcome, ['dry_run' => $request->boolean('dry_run')]);
}
/**
* @param array{results: array<int, array<string, mixed>>, committed: bool} $outcome
* @param array<string, mixed> $meta
*/
protected function answer(array $outcome, array $meta = []): JsonResponse
{
$failed = collect($outcome['results'])->where('status', '>=', 400)->count();
return response()->json([
'data' => $outcome['results'],
'meta' => [
...$meta,
'committed' => $outcome['committed'],
'succeeded' => count($outcome['results']) - $failed,
'failed' => $failed,
],
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Concerns;
use App\Support\AdminContent;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
/**
* For the endpoints shared by several kinds of content —
* `/pages/{page}/revisions`, `/projects/{project}/revisions` — whose route
* says which kind through its `content` default.
*/
trait ResolvesContent
{
protected function content(Request $request): string
{
return (string) $request->route('content');
}
protected function record(Request $request): Model
{
$content = $this->content($request);
return AdminContent::find($content, $request->route(AdminContent::parameter($content)));
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Api\V1\Concerns\ResolvesContent;
use App\Http\Controllers\Controller;
use App\Support\ContentTypes;
use App\Support\Duplicator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* A draft copy of a page, project, service or call — the panel's "Duplicate".
*/
class DuplicateController extends Controller
{
use ResolvesContent;
public function store(Request $request): JsonResponse
{
$record = $this->record($request);
$this->authorize('view', $record);
$this->authorize('create', $record::class);
$copy = Duplicator::duplicate($record);
return response()->json(['data' => [
'type' => $this->content($request),
'id' => $copy->getKey(),
'title' => ContentTypes::title($copy),
'slug' => (object) $copy->getTranslations('slug'),
]], 201);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use App\Support\PendingTranslations;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* What is wrong with the site, and what is left to translate — the panel's
* health screen, for a client that cannot open it.
*/
class HealthController extends Controller
{
/**
* Every check the caller may see, problems first. A check behind a
* permission they lack is left out, as on the panel.
*/
public function index(Request $request, HealthReport $report): JsonResponse
{
// Who may open the panel's screen; each check guards itself further.
$this->authorize('panel.access');
$findings = $report->runFor($request->user());
return response()->json([
'data' => $findings->map(fn (Finding $finding) => [
'key' => $finding->key,
'severity' => $finding->severity->value,
'count' => $finding->count,
'title' => $finding->title(),
'description' => $finding->description(),
'samples' => $finding->samples,
'panel_url' => $finding->url,
])->values(),
'meta' => [
'problems' => $findings->filter(fn (Finding $f) => $f->severity->isProblem())->count(),
'checked_at' => now()->toIso8601String(),
],
]);
}
/**
* Records with a language still to write (`missing`) or left behind by the
* one it was translated from (`stale`, with since when).
*
* Filters: locale (only what that language lacks), type, published (only
* what is on the site — open, for calls).
*/
public function translations(Request $request): JsonResponse
{
$locales = array_keys((array) config('locales.supported'));
$request->validate([
'locale' => ['sometimes', Rule::in($locales)],
'type' => ['sometimes', Rule::in(['pages', 'projects', 'services', 'calls'])],
'published' => ['sometimes', 'boolean'],
]);
$items = PendingTranslations::find(
$request->user(),
$request->input('locale'),
$request->input('type'),
$request->boolean('published'),
)->map(fn (array $item) => [
'type' => $item['type'],
'id' => $item['id'],
'title' => $item['title'],
'missing' => $item['missing'],
'stale' => (object) collect($item['stale'])->map(fn ($since) => $since->toIso8601String())->all(),
]);
return response()->json(['data' => $items->values()]);
}
}

View File

@@ -17,6 +17,8 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class PageController extends Controller
{
protected const WITH = ['category', 'author', 'featuredMedia', 'tags'];
/**
* GET /api/v1/pages
*
@@ -30,7 +32,7 @@ class PageController extends Controller
$locale = app()->getLocale();
$pages = Page::query()
->with(['category', 'author', 'featuredMedia'])
->with(self::WITH)
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('category_id'), fn ($q) => $q->where('category_id', $request->integer('category_id')))
->when($request->has('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured')))
@@ -57,7 +59,7 @@ class PageController extends Controller
$this->authorize('create', Page::class);
$page = new Page;
$page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable));
$page->fill(TranslatablePayload::merge($page, collect($request->validated())->except('tag_ids')->all(), $page->translatable));
$page->author_id ??= $request->user()->id;
// A page created as published with no explicit date goes live now.
@@ -66,8 +68,9 @@ class PageController extends Controller
}
$page->save();
$this->syncTags($page, $request);
return PageResource::make($page->load(['category', 'author', 'featuredMedia']))
return PageResource::make($page->load(self::WITH))
->response()
->setStatusCode(201);
}
@@ -76,22 +79,23 @@ class PageController extends Controller
{
$this->authorize('view', $page);
return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
return PageResource::make($page->load(self::WITH));
}
public function update(PageRequest $request, Page $page): PageResource
{
$this->authorize('update', $page);
$page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable));
$page->fill(TranslatablePayload::merge($page, collect($request->validated())->except('tag_ids')->all(), $page->translatable));
if ($page->status === PageStatus::Published && $page->published_at === null) {
$page->published_at = now();
}
$page->save();
$this->syncTags($page, $request);
return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
return PageResource::make($page->load(self::WITH));
}
public function destroy(Page $page): JsonResponse
@@ -109,7 +113,7 @@ class PageController extends Controller
$page->restore();
return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
return PageResource::make($page->load(self::WITH));
}
/**
@@ -135,4 +139,11 @@ class PageController extends Controller
$query->orderBy(in_array($column, $sortable, true) ? $column : 'created_at', $direction);
}
protected function syncTags(Page $page, PageRequest $request): void
{
if ($request->has('tag_ids')) {
$page->tags()->sync($request->validated('tag_ids') ?? []);
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Actions\Auth\HandlePireonWebhook;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Receives `user.deactivated` and `token.revoked` from the Pireon identity
* server, called by it and not by apps.
*
* `X-Webhook-Signature` is the HMAC-SHA256 (hex) of the raw body with the
* secret of the Pireon client that registered the webhook. This application
* has two clients (password and redirect), so a delivery is good if it
* verifies with either secret. With none configured the endpoint is off: an
* open one would let anyone switch accounts off.
*/
class PireonWebhookController extends Controller
{
public function __invoke(Request $request, HandlePireonWebhook $handle): Response|JsonResponse
{
$secrets = (array) config('pireon.webhook_secrets', []);
if ($secrets === []) {
return $this->refuse('Pireon webhooks are disabled (PIREON_WEBHOOK_SECRET is not set).');
}
$signature = preg_replace('/^sha256=/', '', (string) $request->header('X-Webhook-Signature'));
$body = $request->getContent();
$valid = $signature !== '' && collect($secrets)->contains(
fn (string $secret): bool => hash_equals(hash_hmac('sha256', $body, $secret), $signature),
);
if (! $valid) {
return $this->refuse('Missing or invalid signature.');
}
$event = (string) ($request->input('event') ?? $request->input('type') ?? '');
if ($event === '') {
return response()->json(['message' => 'Missing event name.'], 400);
}
$data = (array) ($request->input('data') ?? $request->input('payload') ?? $request->all());
$handle->handle($event, $data);
return response()->noContent();
}
protected function refuse(string $message): JsonResponse
{
return response()->json(['message' => $message], 401);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Api\V1\Concerns\ResolvesContent;
use App\Http\Controllers\Controller;
use App\Support\AdminContent;
use App\Support\PreviewLink;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Links to see a draft on the real site, one per language it has an address
* in — the panel's preview button. Each opens that one record only, and stops
* working after `PreviewLink::HOURS`.
*/
class PreviewController extends Controller
{
use ResolvesContent;
public function store(Request $request): JsonResponse
{
$record = $this->record($request);
$this->authorize('view', $record);
$type = AdminContent::PREVIEWABLE[$this->content($request)];
$links = collect(array_keys((array) config('locales.supported')))
->mapWithKeys(fn (string $locale) => [$locale => PreviewLink::for($record, $type, $locale)])
->filter()
->all();
return response()->json([
'data' => [
'links' => (object) $links,
'expires_at' => now()->addHours(PreviewLink::HOURS)->toIso8601String(),
],
], 201);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Actions\Projects\OpenInTaskStream;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\ProjectRequest;
use App\Http\Resources\ProjectResource;
@@ -45,11 +46,21 @@ class ProjectController extends Controller
return ProjectResource::collection($projects);
}
public function store(ProjectRequest $request): JsonResponse
/**
* With `taskstream: true`, the project's board is opened in TaskStream
* first; if that fails, nothing is created here either.
*/
public function store(ProjectRequest $request, OpenInTaskStream $openInTaskStream): JsonResponse
{
$this->authorize('create', Project::class);
$project = $this->save(new Project, $request);
$project = $this->fill(new Project, $request);
if ($request->boolean('taskstream')) {
$openInTaskStream->handle($project, $request->user(), $request->bearerToken(), $request->validated('taskstream_key'));
}
$project = $this->persist($project, $request);
return ProjectResource::make($project)->response()->setStatusCode(201);
}
@@ -61,11 +72,22 @@ class ProjectController extends Controller
return ProjectResource::make($project->load(self::WITH));
}
public function update(ProjectRequest $request, Project $project): ProjectResource
/**
* `taskstream: true` opens a board for a project that has none yet, as on
* creation (and nothing is saved if that fails); one that has a board
* keeps it.
*/
public function update(ProjectRequest $request, Project $project, OpenInTaskStream $openInTaskStream): ProjectResource
{
$this->authorize('update', $project);
return ProjectResource::make($this->save($project, $request));
$project = $this->fill($project, $request);
if ($request->boolean('taskstream') && $project->taskstream_project_id === null) {
$openInTaskStream->handle($project, $request->user(), $request->bearerToken(), $request->validated('taskstream_key'));
}
return ProjectResource::make($this->persist($project, $request));
}
public function destroy(Project $project): JsonResponse
@@ -86,20 +108,24 @@ class ProjectController extends Controller
return ProjectResource::make($project->load(self::WITH));
}
protected function fill(Project $project, ProjectRequest $request): Project
{
return $project->fill(TranslatablePayload::merge(
$project,
collect($request->validated())->except(['technology_ids', 'service_ids', 'members', 'taskstream', 'taskstream_key'])->all(),
$project->translatable,
));
}
/**
* The record and its relations in one transaction: a project that saved
* but lost its team halfway would be worse than one that did not save.
*/
protected function save(Project $project, ProjectRequest $request): Project
protected function persist(Project $project, ProjectRequest $request): Project
{
$data = $request->validated();
DB::transaction(function () use ($project, $data): void {
$project->fill(TranslatablePayload::merge(
$project,
collect($data)->except(['technology_ids', 'service_ids', 'members'])->all(),
$project->translatable,
));
$project->save();
if (array_key_exists('technology_ids', $data)) {

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Api\V1\Concerns\ResolvesContent;
use App\Http\Controllers\Controller;
use App\Http\Resources\ReviewCommentResource;
use App\Models\User;
use App\Support\AdminContent;
use App\Support\ContentTypes;
use App\Support\Publisher;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\ValidationException;
/**
* The handover between writing something and putting it on the site — the
* panel's review buttons, as calls. Pages, projects and services only: the
* kinds with a separate `publish` permission.
*/
class ReviewController extends Controller
{
use ResolvesContent;
/**
* What is waiting for the caller: everything awaiting review they could
* answer, either addressed to them or to nobody in particular.
*/
public function queue(Request $request): JsonResponse
{
$user = $request->user();
$items = collect();
foreach (array_keys(AdminContent::REVIEWABLE) as $resource) {
$model = AdminContent::model($resource);
if ($user->cannot("{$resource}.publish")) {
continue;
}
$model::query()
->awaitingReviewBy($user)
->with(['reviewRequester', 'reviewer'])
->get()
->each(fn (Model $record) => $items->push($this->summary($resource, $record)));
}
return response()->json([
'data' => $items->sortBy('requested_at')->values(),
]);
}
/**
* The thread: requests, returns, publications and comments, newest first.
*/
public function thread(Request $request): AnonymousResourceCollection
{
$record = $this->record($request);
$this->authorize('view', $record);
return ReviewCommentResource::collection(
$record->reviewComments()->with('user')->paginate(perPage: min($request->integer('per_page', 25), 100)),
);
}
/**
* Ask for it to be reviewed — the only way somebody without the publish
* permission has of putting their work live.
*/
public function request(Request $request): JsonResponse
{
$record = $this->record($request);
$this->authorize('update', $record);
$data = $request->validate([
'reviewer_id' => ['sometimes', 'nullable', 'integer', 'exists:users,id'],
'note' => ['sometimes', 'nullable', 'string', 'max:2000'],
]);
if ($record->isAwaitingReview() || $record->isPublishedNow()) {
throw ValidationException::withMessages(['review' => [__('rules.reviews.not_requestable')]]);
}
$reviewer = isset($data['reviewer_id']) ? User::find($data['reviewer_id']) : null;
if ($reviewer !== null && $reviewer->cannot($record->getTable().'.publish')) {
throw ValidationException::withMessages(['reviewer_id' => [__('rules.reviews.not_a_reviewer')]]);
}
$record->requestReview($reviewer, $data['note'] ?? null, $request->user());
return $this->answer($record);
}
/**
* Send it back to whoever asked, saying why.
*/
public function return(Request $request): JsonResponse
{
$record = $this->record($request);
$this->authorize($record->getTable().'.publish');
$data = $request->validate(['note' => ['required', 'string', 'max:2000']]);
if (! $record->isAwaitingReview()) {
throw ValidationException::withMessages(['review' => [__('rules.reviews.not_awaiting')]]);
}
$record->returnForChanges($data['note'], $request->user());
return $this->answer($record);
}
public function comment(Request $request): JsonResponse
{
$record = $this->record($request);
$this->authorize('view', $record);
$data = $request->validate(['body' => ['required', 'string', 'max:2000']]);
return ReviewCommentResource::make($record->comment($data['body'], $request->user())->load('user'))
->response()
->setStatusCode(201);
}
/**
* Approve: on the site now, or from `at`. Either way the review, if there
* was one, is answered.
*/
public function publish(Request $request): JsonResponse
{
$record = $this->record($request);
$this->authorize($record->getTable().'.publish');
$data = $request->validate(['at' => ['sometimes', 'nullable', 'date']]);
Publisher::publish($record, filled($data['at'] ?? null) ? CarbonImmutable::parse($data['at']) : null);
return $this->answer($record);
}
public function unpublish(Request $request): JsonResponse
{
$record = $this->record($request);
$this->authorize($record->getTable().'.publish');
Publisher::unpublish($record);
return $this->answer($record);
}
protected function answer(Model $record): JsonResponse
{
$record->refresh()->load(['reviewRequester', 'reviewer']);
return response()->json(['data' => $this->summary(AdminContent::nameOf($record), $record)]);
}
/**
* @return array<string, mixed>
*/
protected function summary(string $resource, Model $record): array
{
return [
'type' => $resource,
'id' => $record->getKey(),
'title' => ContentTypes::title($record),
'published' => $record->isPublishedNow(),
'published_at' => $record->published_at?->toIso8601String(),
'awaiting_review' => $record->isAwaitingReview(),
'requested_at' => $record->review_requested_at?->toIso8601String(),
'requested_by' => $record->reviewRequester ? ['id' => $record->reviewRequester->id, 'name' => $record->reviewRequester->name] : null,
'reviewer' => $record->reviewer ? ['id' => $record->reviewer->id, 'name' => $record->reviewer->name] : null,
];
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Api\V1\Concerns\ResolvesContent;
use App\Http\Controllers\Controller;
use App\Http\Resources\RevisionResource;
use App\Models\Revision;
use App\Support\AdminContent;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\ValidationException;
/**
* A record's history: who changed what and when, and undoing one change.
*/
class RevisionController extends Controller
{
use ResolvesContent;
/**
* Newest first. Filter: event.
*/
public function index(Request $request): AnonymousResourceCollection
{
$record = $this->record($request);
$this->authorize('view', $record);
$revisions = $record->revisions()
->with('user')
->when($request->filled('event'), fn ($q) => $q->where('event', $request->string('event')))
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return RevisionResource::collection($revisions);
}
/**
* Put back what one revision changed, as the panel's history does.
* Restoring is itself a change, so it lands in the history too — the way
* back is never lost.
*/
public function restore(Request $request, Revision $revision): RevisionResource
{
$model = Relation::getMorphedModel($revision->revisionable_type) ?? $revision->revisionable_type;
$record = AdminContent::findOf($model, $revision->revisionable_id);
$this->authorize('update', $record);
$values = $revision->previousValues();
if ($values === []) {
throw ValidationException::withMessages(['revision' => [__('rules.revisions.nothing_to_restore')]]);
}
// Undoing a change can publish or unpublish, and that stays with whoever
// may publish.
$guarded = array_intersect(array_keys($values), AdminContent::publicationFields($record));
if ($guarded !== [] && $request->user()->cannot($record->getTable().'.publish')) {
throw ValidationException::withMessages(['revision' => [__('rules.publication.forbidden')]]);
}
// Only what the model lets a form write: a revision may mention
// bookkeeping columns, and those are not anybody's to put back.
$record->forceFill(array_intersect_key($values, array_flip($record->getFillable())))->save();
return RevisionResource::make($record->revisions()->with('user')->first());
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use App\Rules\Translatable;
use App\Rules\UniqueTranslatedSlug;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Blog tags. Small enough that the whole list is one answer.
*/
class TagController extends Controller
{
public function index(Request $request): JsonResponse
{
$this->authorize('viewAny', Tag::class);
return response()->json([
'data' => Tag::query()->withCount('pages')->orderBy('id')->get()->map(fn (Tag $tag) => $this->present($tag))->values(),
]);
}
public function store(Request $request): JsonResponse
{
$this->authorize('create', Tag::class);
$tag = new Tag;
$tag->fill(TranslatablePayload::merge($tag, $this->validated($request, null), $tag->translatable))->save();
return response()->json(['data' => $this->present($tag->loadCount('pages'))], 201);
}
public function update(Request $request, Tag $tag): JsonResponse
{
$this->authorize('update', $tag);
$tag->fill(TranslatablePayload::merge($tag, $this->validated($request, $tag), $tag->translatable))->save();
return response()->json(['data' => $this->present($tag->loadCount('pages'))]);
}
public function destroy(Tag $tag): JsonResponse
{
$this->authorize('delete', $tag);
$tag->delete();
return response()->json(status: 204);
}
/**
* @return array<string, mixed>
*/
protected function validated(Request $request, ?Tag $tag): array
{
$sometimes = $tag === null ? 'required' : 'sometimes';
return $request->validate([
'name' => [$sometimes, new Translatable(requireDefaultLocale: $tag === null, max: 80)],
'slug' => [$sometimes, new Translatable(requireDefaultLocale: $tag === null, max: 80), new UniqueTranslatedSlug(Tag::class, $tag?->id)],
]);
}
/**
* @return array<string, mixed>
*/
protected function present(Tag $tag): array
{
return [
'id' => $tag->id,
'name' => (object) $tag->getTranslations('name'),
'slug' => (object) $tag->getTranslations('slug'),
'pages_count' => (int) ($tag->pages_count ?? 0),
];
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\WebhookRequest;
use App\Http\Resources\WebhookDeliveryResource;
use App\Http\Resources\WebhookResource;
use App\Models\Webhook;
use App\Models\WebhookDelivery;
use App\Support\Webhooks\Webhooks;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Str;
/**
* Outgoing webhooks: where to send, which events, and how it has gone.
*/
class WebhookController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Webhook::class);
return WebhookResource::collection(
Webhook::query()->latest('id')->paginate(perPage: min($request->integer('per_page', 25), 100)),
);
}
/**
* The secret is in the answer once, when it is made (or rotated), and
* never again.
*/
public function store(WebhookRequest $request): JsonResponse
{
$this->authorize('create', Webhook::class);
$secret = $request->input('secret') ?: Str::random(40);
$webhook = new Webhook([...$request->validated(), 'secret' => $secret]);
$webhook->forceFill(['created_by' => $request->user()->id])->save();
return WebhookResource::make($webhook->refresh())
->additional(['secret' => $secret])
->response()
->setStatusCode(201);
}
public function show(Webhook $webhook): WebhookResource
{
$this->authorize('view', $webhook);
return WebhookResource::make($webhook);
}
/**
* Switching it back on starts its failure count again.
*/
public function update(WebhookRequest $request, Webhook $webhook): JsonResponse
{
$this->authorize('update', $webhook);
$data = $request->validated();
$rotated = $request->boolean('rotate_secret') ? Str::random(40) : null;
unset($data['rotate_secret']);
$webhook->fill($data);
if ($rotated !== null) {
$webhook->secret = $rotated;
}
if ($webhook->isDirty('is_active') && $webhook->is_active) {
$webhook->consecutive_failures = 0;
}
$webhook->save();
return WebhookResource::make($webhook)
->additional($rotated !== null ? ['secret' => $rotated] : [])
->response();
}
public function destroy(Webhook $webhook): JsonResponse
{
$this->authorize('delete', $webhook);
$webhook->delete();
return response()->json(status: 204);
}
/**
* Newest first. Filter: status.
*/
public function deliveries(Request $request, Webhook $webhook): AnonymousResourceCollection
{
$this->authorize('view', $webhook);
return WebhookDeliveryResource::collection(
$webhook->deliveries()
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString(),
);
}
/**
* A `ping`, to check the receiving end before anything real happens.
*/
public function ping(Webhook $webhook): JsonResponse
{
$this->authorize('update', $webhook);
$delivery = Webhooks::ping($webhook);
return WebhookDeliveryResource::make($delivery)->response()->setStatusCode(202);
}
/**
* Send one again, as a new delivery of the same event and payload.
*/
public function redeliver(WebhookDelivery $delivery): JsonResponse
{
$this->authorize('update', $delivery->webhook);
$again = Webhooks::redeliver($delivery);
return WebhookDeliveryResource::make($again)->response()->setStatusCode(202);
}
}

View File

@@ -19,6 +19,8 @@ use Illuminate\Support\Facades\Log;
*
* The browser never sees a password belonging to this application, and the
* authorization code alone is useless without the verifier we keep server-side.
* It is «Entrar con Pireon» on the login page; it uses the redirect client
* (`PireonClient::forRedirect()`), its own one when configured.
*/
class PireonAuthController extends Controller
{
@@ -38,7 +40,7 @@ class PireonAuthController extends Controller
}
try {
['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = $this->client->authorizationUrl();
['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = PireonClient::forRedirect()->authorizationUrl();
} catch (PireonException $e) {
return $this->fail($e, route('login'));
}
@@ -81,7 +83,7 @@ class PireonAuthController extends Controller
}
try {
$token = $this->client->exchangeCode((string) $request->query('code'), (string) $verifier);
$token = PireonClient::forRedirect()->exchangeCode((string) $request->query('code'), (string) $verifier);
$identity = $this->client->identity($token->accessToken);
} catch (PireonException $e) {
return $this->fail($e, route('login'));
@@ -100,7 +102,7 @@ class PireonAuthController extends Controller
$request->session()->regenerate();
Auth::login($user, remember: true);
$this->tokens->put($token);
$this->tokens->put($token, viaRedirect: true);
if ($user->locale) {
$request->session()->put(config('locales.session_key'), $user->locale);

View File

@@ -49,6 +49,10 @@ class PageRequest extends FormRequest
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
'category_id' => ['sometimes', 'nullable', 'integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
// Who signs it on the site; replaced whole when sent, like the tags.
'byline_member_id' => ['sometimes', 'nullable', 'integer', Rule::exists('members', 'id')->whereNull('deleted_at')],
'tag_ids' => ['sometimes', 'array'],
'tag_ids.*' => ['integer', 'distinct', Rule::exists('tags', 'id')],
'status' => ['sometimes', Rule::enum(PageStatus::class)],
'featured_image' => ['sometimes', 'nullable', 'string', 'max:2048'],
'featured_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)],

View File

@@ -19,6 +19,10 @@ use Illuminate\Validation\Validator;
*
* Relations travel as ids and are replaced whole when sent: `technology_ids`,
* `service_ids`, and `members` with each one's role on the project.
*
* `taskstream` opens a public board in TaskStream for the project, keyed
* `taskstream_key` or one derived from the slug; on an update, only when the
* project has none yet.
*/
class ProjectRequest extends FormRequest
{
@@ -74,6 +78,11 @@ class ProjectRequest extends FormRequest
'members' => ['sometimes', 'array'],
'members.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')],
'members.*.role' => ['nullable', 'string', 'max:120'],
// The key is only read along with `taskstream`: a client PATCHing
// back what it read sends the key it got, which changes nothing.
'taskstream' => ['sometimes', 'boolean'],
'taskstream_key' => ['sometimes', 'nullable', 'string', 'regex:/^[A-Za-z][A-Za-z0-9]{1,9}$/'],
];
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Models\Webhook;
use App\Rules\PublicHttpsUrl;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
class WebhookRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$creating = ! $this->route('webhook') instanceof Webhook;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'name' => [$sometimes, 'string', 'max:120'],
'url' => [
$sometimes,
'string',
'url',
'max:2048',
// Refused now rather than at the first delivery; checked
// again at every delivery all the same.
new PublicHttpsUrl,
],
'events' => [$sometimes, 'array', 'min:1'],
'events.*' => [
'string',
'distinct',
function (string $attribute, mixed $value, Closure $fail): void {
if (! is_string($value) || ! Webhook::isSubscribable($value)) {
$fail(__('rules.webhooks.event', ['event' => (string) (is_scalar($value) ? $value : '')]));
}
},
],
'secret' => [$creating ? 'sometimes' : 'prohibited', 'nullable', 'string', 'min:16', 'max:255'],
'rotate_secret' => [$creating ? 'prohibited' : 'sometimes', 'boolean'],
'include_personal_data' => ['sometimes', 'boolean'],
'is_active' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\ApiToken;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Never the token itself, which only exists in the answer that made it.
*
* @mixin ApiToken
*/
class ApiTokenResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'prefix' => $this->prefix,
'abilities' => $this->abilities ?? [],
'owner' => ['id' => $this->user_id, 'name' => $this->user?->name],
'status' => match (true) {
$this->revoked_at !== null => 'revoked',
$this->expires_at->isPast() => 'expired',
default => 'active',
},
'expires_at' => $this->expires_at->toIso8601String(),
'last_used_at' => $this->last_used_at?->toIso8601String(),
'revoked_at' => $this->revoked_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin AuditLog
*/
class AuditLogResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'action' => $this->action,
'user' => ['id' => $this->user_id, 'name' => $this->user_name],
'via' => $this->via,
'api_token_id' => $this->api_token_id,
'ip' => $this->ip,
'subject' => $this->subject_type ? [
'type' => $this->subject_type,
'id' => $this->subject_id,
'label' => $this->subject_label,
] : null,
'changes' => $this->changes === null ? null : (object) $this->changes,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -49,6 +49,8 @@ class PageResource extends JsonResource
'category' => CategoryResource::make($this->whenLoaded('category')),
'author' => UserResource::make($this->whenLoaded('author')),
'byline_member_id' => $this->byline_member_id,
'tag_ids' => $this->whenLoaded('tags', fn () => $this->tags->pluck('id')->values()),
// Opt-in: `?with=translations` returns every language at once,
// which is what a translation UI needs and a page renderer does not.

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Project;
use App\Services\TaskStream\TaskStreamClient;
use App\Support\ImagePayload;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
@@ -33,6 +34,10 @@ class ProjectResource extends JsonResource
'is_featured' => (bool) $this->is_featured,
'needs_help' => (bool) $this->needs_help,
'demo_url' => $this->demo_url,
'taskstream_key' => $this->taskstream_key,
'taskstream_url' => $this->taskstream_key !== null
? app(TaskStreamClient::class)->projectUrl($this->taskstream_key)
: null,
// The stored path, which is what a client PATCHes back; what was
// derived from it travels alongside, as on a page.
'cover_image' => $this->cover_image,

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Resources\PublicApi;
use App\Models\Page;
use App\Models\Tag;
use App\Support\ImagePayload;
use App\Support\SiteUrls;
use App\Support\TranslatablePayload;
@@ -58,6 +59,29 @@ class PublicPageResource extends JsonResource
'category' => PublicCategoryResource::make($this->whenLoaded('category')),
// Only the tags named in this language: one without a slug here
// has no listing to link to.
'tags' => $this->whenLoaded('tags', fn () => $this->tags
->filter(fn (Tag $tag) => filled($tag->getTranslation('slug', $locale, false)))
->map(fn (Tag $tag) => [
'name' => $tag->getTranslation('name', $locale),
'slug' => $tag->getTranslation('slug', $locale, false),
])
->values()),
// Who signs it — only someone with a public profile to link to.
'byline' => $this->whenLoaded('byline', fn () => $this->byline?->is_public ? [
'name' => $this->byline->name,
'slug' => $this->byline->slug,
'headline' => $this->byline->getTranslation('headline', $locale),
'avatar' => ImagePayload::make(
$this->byline->avatar,
$this->byline->relationLoaded('avatarMedia') ? $this->byline->getRelation('avatarMedia') : null,
$this->byline->name,
),
'path' => SiteUrls::path('member', $locale, $this->byline->slug),
] : null),
// Lets the frontend emit correct hreflang tags. Only locales that
// actually have a translation appear.
'alternates' => SiteUrls::alternates($this->resource, 'page'),

View File

@@ -6,6 +6,7 @@ namespace App\Http\Resources\PublicApi;
use App\Models\Media;
use App\Models\Project;
use App\Services\TaskStream\TaskStreamClient;
use App\Support\ImagePayload;
use App\Support\SiteUrls;
use App\Support\TranslatablePayload;
@@ -110,11 +111,30 @@ class PublicProjectResource extends JsonResource
// alive without anyone maintaining it.
'repositories' => PublicRepositoryResource::collection($this->whenLoaded('repositories')),
// Its board in TaskStream, when it has one: the public site reads
// the roadmap, releases and tickets for newcomers from there.
'taskstream' => $this->taskstream_key === null ? null : $this->taskstream(),
'alternates' => SiteUrls::alternates($this->resource, 'project'),
'path' => SiteUrls::alternates($this->resource, 'project')[$locale] ?? null,
];
}
/**
* @return array{key: string, url: string, web_url: string, showcase_url: string}
*/
protected function taskstream(): array
{
$client = app(TaskStreamClient::class);
return [
'key' => $this->taskstream_key,
'url' => $client->projectUrl($this->taskstream_key),
'web_url' => $client->webUrl(),
'showcase_url' => $client->showcaseUrl($this->taskstream_key),
];
}
/**
* The media row when the caller loaded it, and null otherwise — never a
* lazy load, because this runs once per row in a listing.

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\ReviewComment;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin ReviewComment
*/
class ReviewCommentResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'event' => $this->event->value,
'body' => $this->body,
'author' => [
'id' => $this->user_id,
'name' => $this->user?->name ?? $this->user_name,
],
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Revision;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Revision
*/
class RevisionResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'event' => $this->event,
'author' => [
'id' => $this->user_id,
'name' => $this->resource->author(),
],
// Field => {old, new}. Translatable fields carry every language.
'changes' => (object) ($this->changeset ?? []),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\User;
use App\Support\Permissions;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -35,7 +36,10 @@ class UserResource extends JsonResource
'roles' => $this->whenLoaded('roles', fn () => $this->roles->pluck('name')->values()),
'permissions' => $this->when(
$request->routeIs('api.v1.me'),
fn () => $this->getAllPermissions()->pluck('name')->values(),
// Through a machine token, only what the token may do.
fn () => $this->resource->currentAccessToken()
? collect(Permissions::all())->filter(fn (string $permission) => $this->resource->can($permission))->values()
: $this->getAllPermissions()->pluck('name')->values(),
),
// Roles reported by the identity server. Informational only: they

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\WebhookDelivery;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin WebhookDelivery
*/
class WebhookDeliveryResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'uuid' => $this->uuid,
'event' => $this->event,
'status' => $this->status,
'attempts' => (int) $this->attempts,
'response_status' => $this->response_status,
'error' => $this->error,
'payload' => (object) ($this->payload ?? []),
'delivered_at' => $this->delivered_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Webhook;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Never the secret.
*
* @mixin Webhook
*/
class WebhookResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'url' => $this->url,
'events' => $this->events ?? [],
'include_personal_data' => (bool) $this->include_personal_data,
'is_active' => (bool) $this->is_active,
'consecutive_failures' => (int) $this->consecutive_failures,
'last_delivered_at' => $this->last_delivered_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

152
app/Jobs/DeliverWebhook.php Normal file
View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Webhook;
use App\Models\WebhookDelivery;
use App\Support\Webhooks\PublicEndpoint;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Sends one delivery, signed, and retries it with growing pauses.
*
* The receiver checks `X-Webhook-Signature`: `sha256=` and the HMAC of
* `{timestamp}.{body}` with the webhook's secret, the timestamp being
* `X-Webhook-Timestamp` — so a captured delivery cannot be replayed later
* under a new date.
*/
class DeliverWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public function __construct(public WebhookDelivery $delivery) {}
/**
* @return array<int, int>
*/
public function backoff(): array
{
return [60, 300, 1800, 7200];
}
public function handle(PublicEndpoint $endpoint): void
{
$delivery = $this->delivery;
$webhook = $delivery->webhook;
if ($webhook === null || ! $webhook->is_active) {
return;
}
$delivery->increment('attempts');
$body = (string) json_encode([
'id' => $delivery->uuid,
'event' => $delivery->event,
'created_at' => $delivery->created_at?->toIso8601String(),
'data' => $delivery->payload,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
[$address, $refused] = $endpoint->check($webhook->url);
// Checked again at every attempt, not only when the URL was saved: a
// name can be pointed somewhere else in between.
if ($refused !== null) {
$this->failed(new RuntimeException(__("rules.media.remote.{$refused}")));
$this->delete();
return;
}
$parts = parse_url($webhook->url);
$timestamp = (string) now()->timestamp;
try {
$response = Http::withOptions([
'allow_redirects' => false,
'connect_timeout' => 5,
'timeout' => 10,
'curl' => [CURLOPT_RESOLVE => [sprintf(
'%s:%d:%s',
trim((string) $parts['host'], '[]'),
(int) ($parts['port'] ?? 443),
str_contains((string) $address, ':') ? "[{$address}]" : $address,
)]],
])
->withHeaders([
'Content-Type' => 'application/json',
'X-Webhook-Event' => $delivery->event,
'X-Webhook-Delivery' => $delivery->uuid,
'X-Webhook-Timestamp' => $timestamp,
'X-Webhook-Signature' => 'sha256='.hash_hmac('sha256', "{$timestamp}.{$body}", (string) $webhook->secret),
])
->withUserAgent(sprintf('%s/1.0 (+%s; webhooks)', Str::slug((string) config('app.name')) ?: 'landing', rtrim((string) config('app.url'), '/')))
->withBody($body, 'application/json')
->post($webhook->url);
} catch (ConnectionException $e) {
$this->retryOrFail($e->getMessage(), null);
return;
}
if ($response->successful()) {
$delivery->update([
'status' => WebhookDelivery::SUCCEEDED,
'response_status' => $response->status(),
'error' => null,
'delivered_at' => now(),
]);
$webhook->forceFill(['consecutive_failures' => 0, 'last_delivered_at' => now()])->save();
return;
}
$this->retryOrFail(Str::limit($response->body(), 500), $response->status());
}
public function failed(?\Throwable $e): void
{
$this->delivery->update([
'status' => WebhookDelivery::FAILED,
'error' => Str::limit((string) $e?->getMessage(), 500),
]);
$webhook = $this->delivery->webhook;
if ($webhook !== null) {
$failures = $webhook->consecutive_failures + 1;
$webhook->forceFill([
'consecutive_failures' => $failures,
'is_active' => $failures < Webhook::MAX_FAILURES && $webhook->is_active,
])->save();
}
}
protected function retryOrFail(string $error, ?int $status): void
{
$this->delivery->update(['response_status' => $status, 'error' => $error]);
if ($this->attempts() < $this->tries) {
$this->release($this->backoff()[$this->attempts() - 1] ?? 7200);
return;
}
$this->failed(new RuntimeException($error));
$this->delete();
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\ApplicationReply;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* The answer, as plain text — what a person would have typed, not a
* newsletter.
*/
class ApplicationReplyMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public ApplicationReply $reply) {}
public function envelope(): Envelope
{
$replyTo = config('mail.reply_to.address');
return new Envelope(
subject: $this->reply->subject,
replyTo: $replyTo ? [new Address((string) $replyTo, (string) config('mail.reply_to.name'))] : [],
);
}
public function content(): Content
{
return new Content(text: 'mail.application-reply');
}
}

128
app/Models/ApiToken.php Normal file
View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* An access token for a machine.
*
* It acts as the person who made it, and never as more: what it may do is the
* intersection of its `abilities` and that person's own permissions, checked
* on every request — so taking a role away from them narrows their tokens at
* once. It always expires, and only its hash is stored.
*/
#[Fillable(['name', 'abilities', 'expires_at'])]
class ApiToken extends Model
{
/**
* What tells this application's tokens from the identity server's, so a
* machine token is never sent upstream to be checked.
*/
public const PREFIX = 'pmt_';
public const MAX_DAYS = 365;
protected function casts(): array
{
return [
'abilities' => 'array',
'expires_at' => 'datetime',
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
];
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Make one, and return the plain token alongside: the only time it exists
* anywhere but in the caller's hands.
*
* @param array<int, string> $abilities
* @return array{0: self, 1: string}
*/
public static function issue(User $owner, string $name, array $abilities, CarbonInterface $expiresAt): array
{
$plain = self::PREFIX.Str::random(40);
$token = new self(['name' => $name, 'abilities' => array_values($abilities), 'expires_at' => $expiresAt]);
$token->user()->associate($owner);
$token->forceFill([
'token_hash' => self::hash($plain),
'prefix' => substr($plain, 0, 12),
])->save();
return [$token, $plain];
}
public static function hash(string $plain): string
{
return hash('sha256', $plain);
}
public static function findUsable(string $plain): ?self
{
$token = self::query()->with('user')->where('token_hash', self::hash($plain))->first();
return $token?->isUsable() ? $token : null;
}
public function isUsable(): bool
{
return $this->revoked_at === null && $this->expires_at->isFuture();
}
/**
* Whether the token was given this permission. `pages.*` covers every
* permission on pages.
*/
public function allows(string $permission): bool
{
foreach ($this->abilities ?? [] as $ability) {
if ($ability === $permission || Str::is($ability, $permission)) {
return true;
}
}
return false;
}
/**
* Recorded at most once a minute: a busy script would otherwise turn
* every request into a write.
*/
public function touchLastUsed(): void
{
if ($this->last_used_at === null || $this->last_used_at->lt(now()->subMinute())) {
$this->forceFill(['last_used_at' => now()])->saveQuietly();
}
}
public function revoke(): void
{
$this->forceFill(['revoked_at' => now()])->save();
}
/**
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopeUsable(Builder $query): Builder
{
return $query->whereNull('revoked_at')->where('expires_at', '>', now());
}
}

View File

@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Config;
@@ -59,6 +60,14 @@ class Application extends Model
/**
* @return BelongsTo<User, $this>
*/
/**
* @return HasMany<ApplicationReply, $this>
*/
public function replies(): HasMany
{
return $this->hasMany(ApplicationReply::class)->latest('id');
}
public function handler(): BelongsTo
{
return $this->belongsTo(User::class, 'handled_by');

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* An answer given to a submission, kept so the next person to open it knows
* what was already said.
*/
#[Fillable(['user_id', 'user_name', 'subject', 'body', 'channel'])]
class ApplicationReply extends Model
{
public const UPDATED_AT = null;
public const MAIL = 'mail';
public const MAILTO = 'mailto';
/**
* @return BelongsTo<Application, $this>
*/
public function application(): BelongsTo
{
return $this->belongsTo(Application::class);
}
}

46
app/Models/AuditLog.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One thing somebody did. Written by App\Support\Audit\Auditor, never edited.
*/
class AuditLog extends Model
{
use MassPrunable;
public const UPDATED_AT = null;
protected $guarded = [];
protected function casts(): array
{
return [
'changes' => 'array',
'created_at' => 'datetime',
];
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return Builder<$this>
*/
public function prunable(): Builder
{
return static::query()->where('created_at', '<', now()->subDays((int) config('audit.retention_days', 365)));
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models\Concerns;
use App\Models\Revision;
use App\Support\AdminContent;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Auth;
@@ -119,17 +120,34 @@ trait HasRevisions
*/
protected function revisionEvent(array $changes): string
{
if (! array_key_exists('published_at', $changes) && ! array_key_exists('status', $changes)) {
// Which fields put this kind of record on the site: a project's
// `status` is whether the work is active, not whether it is public,
// and a service is published by a flag.
$publication = AdminContent::publicationFields($this) ?: ['published_at', 'status'];
if (array_intersect(array_keys($changes), $publication) === []) {
return 'updated';
}
// Ask the model itself: "published" means different things to a page
// with a status and a project with only a date.
if (method_exists($this, 'isPublishedNow')) {
return $this->isPublishedNow() ? 'published' : 'unpublished';
if (! method_exists($this, 'isPublishedNow')) {
return 'updated';
}
return 'updated';
// Before and after, because the change is what has a name: going live,
// coming off, or being given a date — which is neither, and which a
// history that only asked "is it live now?" called "unpublished".
$before = (clone $this)->setRawAttributes($this->getRawOriginal());
$now = $this->isPublishedNow();
$was = $before->isPublishedNow();
return match (true) {
$now && ! $was => 'published',
! $now && $was => 'unpublished',
! $now && method_exists($this, 'isScheduled') && $this->isScheduled() => 'scheduled',
default => 'updated',
};
}
protected function revisionValue(mixed $value): mixed

View File

@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Translatable\HasTranslations;
@@ -26,7 +27,7 @@ use Spatie\Translatable\HasTranslations;
* @property array<string, string> $slug
*/
#[Fillable([
'category_id', 'author_id', 'title', 'slug', 'excerpt', 'body',
'category_id', 'author_id', 'byline_member_id', 'title', 'slug', 'excerpt', 'body',
'meta_title', 'meta_description', 'status', 'featured_image',
'featured_image_alt', 'is_featured', 'published_at', 'unpublish_at',
])]
@@ -146,6 +147,25 @@ class Page extends Model
return $this->belongsTo(User::class, 'author_id');
}
/**
* Who signs it on the site: a member, with a profile to link to. Not the
* account that typed it in, which is `author` and stays in the panel.
*
* @return BelongsTo<Member, $this>
*/
public function byline(): BelongsTo
{
return $this->belongsTo(Member::class, 'byline_member_id');
}
/**
* @return BelongsToMany<Tag, $this>
*/
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
/**
* Publicly visible pages: published and not scheduled for the future.
*

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\ApplicationKind;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
/**
* An answer the inbox gives often enough to write once: "thanks, we'll be in
* touch", "this call is filled", "here is how we work".
*
* Written in every language, because the answer goes out in the one the
* person wrote in. `{name}`, `{subject}` and `{sender}` are filled in.
*/
#[Fillable(['name', 'kind', 'subject', 'body', 'position'])]
class ReplyTemplate extends Model
{
use HasFactory, HasTranslations;
public const PLACEHOLDERS = ['{name}', '{subject}', '{sender}'];
/**
* @var array<int, string>
*/
public array $translatable = ['subject', 'body'];
protected function casts(): array
{
return ['position' => 'integer'];
}
/**
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopeFor(Builder $query, ApplicationKind $kind): Builder
{
return $query
->where(fn (Builder $q) => $q->whereNull('kind')->orWhere('kind', $kind->value))
->orderBy('position')
->orderBy('name');
}
/**
* Subject and body for one submission, in its language.
*
* @return array{subject: string, body: string}
*/
public function renderFor(Application $application, ?User $sender = null): array
{
$values = [$application->name, $application->subject(), $sender?->name ?? (string) config('app.name')];
return [
'subject' => str_replace(self::PLACEHOLDERS, $values, (string) $this->getTranslation('subject', $application->locale)),
'body' => str_replace(self::PLACEHOLDERS, $values, (string) $this->getTranslation('body', $application->locale)),
];
}
}

37
app/Models/Tag.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\BumpsContentVersion;
use Database\Factories\TagFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Spatie\Translatable\HasTranslations;
/**
* What a post is about — `angular`, `accesibilidad` — so the blog can be read
* by subject. Named in every language, like a category.
*/
#[Fillable(['name', 'slug'])]
class Tag extends Model
{
/** @use HasFactory<TagFactory> */
use BumpsContentVersion, HasFactory, HasTranslations;
/**
* @var array<int, string>
*/
public array $translatable = ['name', 'slug'];
/**
* @return BelongsToMany<Page, $this>
*/
public function pages(): BelongsToMany
{
return $this->belongsToMany(Page::class);
}
}

View File

@@ -42,6 +42,32 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasName
*/
protected string $guard_name = 'web';
/**
* The machine token this request came in with, when it did. Not stored:
* it describes the request, not the person.
*/
protected ?ApiToken $accessToken = null;
public function withAccessToken(?ApiToken $token): static
{
$this->accessToken = $token;
return $this;
}
public function currentAccessToken(): ?ApiToken
{
return $this->accessToken;
}
/**
* @return HasMany<ApiToken, $this>
*/
public function apiTokens(): HasMany
{
return $this->hasMany(ApiToken::class);
}
/**
* Get the attributes that should be cast.
*

132
app/Models/Webhook.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
/**
* Somewhere to tell when something happens here.
*/
#[Fillable(['name', 'url', 'secret', 'events', 'include_personal_data', 'is_active'])]
#[Hidden(['secret'])]
class Webhook extends Model
{
use HasFactory;
/**
* Events with a meaning of their own, whatever record they are about.
*/
public const EVENTS = [
'application.received',
'content.published',
'content.unpublished',
'review.requested',
'review.returned',
];
/**
* Kinds of record whose every change can be subscribed to, as
* `{resource}.{action}`. Not users, tokens, webhooks or settings: those
* are the application's security, not its content, and do not leave it.
*/
public const RESOURCES = [
'pages', 'categories', 'tags', 'projects', 'members', 'services',
'clients', 'testimonials', 'talks', 'technologies', 'calls',
'redirects', 'repositories', 'media', 'applications',
];
public const ACTIONS = ['created', 'updated', 'deleted', 'restored'];
/**
* Every event that is ever sent.
*
* @return array<int, string>
*/
public static function events(): array
{
$changes = [];
foreach (self::RESOURCES as $resource) {
foreach (self::ACTIONS as $action) {
$changes[] = "{$resource}.{$action}";
}
}
return [...self::EVENTS, ...$changes];
}
/**
* What a webhook may subscribe to: an event, `*`, everything about one
* kind of record (`projects.*`) or one action on any (`*.deleted`).
*/
public static function isSubscribable(string $pattern): bool
{
if ($pattern === '*' || in_array($pattern, self::events(), true)) {
return true;
}
[$resource, $action] = array_pad(explode('.', $pattern, 2), 2, null);
return ($action === '*' && in_array($resource, self::RESOURCES, true))
|| ($resource === '*' && in_array($action, self::ACTIONS, true));
}
/**
* Switched off after this many failed deliveries in a row, so a dead
* endpoint does not keep a queue busy forever. Switching it back on is a
* person's decision.
*/
public const MAX_FAILURES = 20;
protected $attributes = [
'include_personal_data' => false,
'is_active' => true,
];
protected function casts(): array
{
return [
'secret' => 'encrypted',
'events' => 'array',
'include_personal_data' => 'boolean',
'is_active' => 'boolean',
'last_delivered_at' => 'datetime',
];
}
/**
* @return HasMany<WebhookDelivery, $this>
*/
public function deliveries(): HasMany
{
return $this->hasMany(WebhookDelivery::class)->latest('id');
}
public function listensTo(string $event): bool
{
foreach ($this->events ?? [] as $pattern) {
if ($pattern === $event || Str::is($pattern, $event)) {
return true;
}
}
return false;
}
/**
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One attempt to tell a webhook about one event, and how it went.
*/
#[Fillable(['uuid', 'event', 'payload', 'status', 'attempts', 'response_status', 'error', 'delivered_at'])]
class WebhookDelivery extends Model
{
public const PENDING = 'pending';
public const SUCCEEDED = 'succeeded';
public const FAILED = 'failed';
protected function casts(): array
{
return [
'payload' => 'array',
'attempts' => 'integer',
'response_status' => 'integer',
'delivered_at' => 'datetime',
];
}
/**
* @return BelongsTo<Webhook, $this>
*/
public function webhook(): BelongsTo
{
return $this->belongsTo(Webhook::class);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use Filament\Actions\Action;
use Filament\Notifications\Notification as FilamentNotification;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Collection;
/**
* A digest: what this person looks after that is about to close or come off
* the site.
*/
class ExpiringSoon extends Notification
{
/**
* @param Collection<int, array<string, mixed>> $items
*/
public function __construct(public readonly Collection $items) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toDatabase(object $notifiable): array
{
$first = $this->items->first();
return FilamentNotification::make()
->title(trans_choice('panel.expiring.notification.title', $this->items->count(), ['count' => $this->items->count()]))
->body($this->items
->map(fn (array $item) => $item['title'].' — '.__("panel.expiring.events.{$item['event']}", [
'when' => $item['at']->diffForHumans(),
'date' => $item['at']->isoFormat('LL'),
]))
->implode("\n"))
->icon('heroicon-o-clock')
->iconColor('warning')
->actions($this->items->count() === 1 ? [
Action::make('open')
->label(__('panel.expiring.notification.open'))
->url($first['url'])
->markAsRead(),
] : [])
->getDatabaseMessage();
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
/**
* Templates belong to the inbox: whoever answers it may write them.
*/
class ReplyTemplatePolicy
{
public function viewAny(User $user): bool
{
return $user->can('applications.view_any');
}
public function view(User $user, Model $model): bool
{
return $user->can('applications.view_any');
}
public function create(User $user): bool
{
return $user->can('applications.update');
}
public function update(User $user, Model $model): bool
{
return $user->can('applications.update');
}
public function delete(User $user, Model $model): bool
{
return $user->can('applications.update');
}
public function deleteAny(User $user): bool
{
return $user->can('applications.update');
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Policies;
/**
* Tags are kept by whoever keeps the categories: the same job, sorting the
* blog, with no separate permission nobody would remember to grant.
*/
class TagPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'categories';
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Policies;
class WebhookPolicy extends ResourcePolicy
{
protected function resource(): string
{
return 'webhooks';
}
}

View File

@@ -5,9 +5,18 @@ declare(strict_types=1);
namespace App\Providers;
use App\Auth\PireonTokenGuard;
use App\Models\Application;
use App\Models\AuditLog;
use App\Models\ReviewComment;
use App\Models\Revision;
use App\Models\User;
use App\Services\Git\GitManager;
use App\Services\Pireon\PireonClient;
use App\Services\Pireon\TokenStore;
use App\Services\TaskStream\TaskStreamClient;
use App\Support\Audit\Auditor;
use App\Support\Webhooks\Webhooks;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Logout;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Model;
@@ -18,6 +27,9 @@ use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Spatie\Permission\Events\RoleAttached;
use Spatie\Permission\Events\RoleDetached;
use Spatie\Permission\Models\Role;
class AppServiceProvider extends ServiceProvider
{
@@ -32,10 +44,50 @@ class AppServiceProvider extends ServiceProvider
// Resolves git hosting drivers; see config/git.php.
$this->app->singleton(GitManager::class);
$this->app->singleton(TaskStreamClient::class, fn () => TaskStreamClient::fromConfig(config('services.taskstream')));
}
/**
* @return array<int, string>
*/
protected static function roleNames(mixed $rolesOrIds): array
{
return Role::query()
->whereIn('id', collect(is_iterable($rolesOrIds) ? $rolesOrIds : [$rolesOrIds])
->map(fn ($role) => $role instanceof Role ? $role->id : $role)
->all())
->pluck('name')
->all();
}
public function boot(): void
{
// Outgoing webhooks, fed by the records themselves so the panel, the
// API and a batch all announce the same things the same way.
Application::created(fn (Application $application) => Webhooks::applicationReceived($application));
Revision::created(fn (Revision $revision) => Webhooks::revisionRecorded($revision));
ReviewComment::created(fn (ReviewComment $comment) => Webhooks::reviewRecorded($comment));
AuditLog::created(fn (AuditLog $entry) => Webhooks::auditRecorded($entry));
// The audit log: every change a person makes to an audited record,
// role changes, and signing in to the panel.
foreach (['created', 'updated', 'deleted', 'restored'] as $event) {
Event::listen("eloquent.{$event}: *", function (string $name, array $payload) use ($event): void {
if (($payload[0] ?? null) instanceof Model) {
Auditor::modelEvent($event, $payload[0]);
}
});
}
Event::listen(RoleAttached::class, fn (RoleAttached $e) => Auditor::record('users.roles_added', $e->model, ['roles' => self::roleNames($e->rolesOrIds)]));
Event::listen(RoleDetached::class, fn (RoleDetached $e) => Auditor::record('users.roles_removed', $e->model, ['roles' => self::roleNames($e->rolesOrIds)]));
Event::listen(Login::class, function (Login $e): void {
if ($e->user instanceof User) {
Auditor::record('auth.login', $e->user, null, $e->user);
}
});
// Backs the "api" guard declared in config/auth.php.
Auth::viaRequest('pireon', fn (Request $request) => $this->app
->make(PireonTokenGuard::class)($request));

View File

@@ -14,12 +14,15 @@ use App\Models\MissingUrl;
use App\Models\Page;
use App\Models\Project;
use App\Models\Redirect;
use App\Models\ReplyTemplate;
use App\Models\Repository;
use App\Models\Service;
use App\Models\Tag;
use App\Models\Talk;
use App\Models\Technology;
use App\Models\Testimonial;
use App\Models\User;
use App\Models\Webhook;
use App\Policies\ApplicationPolicy;
use App\Policies\CallPolicy;
use App\Policies\CategoryPolicy;
@@ -30,13 +33,17 @@ use App\Policies\MissingUrlPolicy;
use App\Policies\PagePolicy;
use App\Policies\ProjectPolicy;
use App\Policies\RedirectPolicy;
use App\Policies\ReplyTemplatePolicy;
use App\Policies\RepositoryPolicy;
use App\Policies\RolePolicy;
use App\Policies\ServicePolicy;
use App\Policies\TagPolicy;
use App\Policies\TalkPolicy;
use App\Policies\TechnologyPolicy;
use App\Policies\TestimonialPolicy;
use App\Policies\UserPolicy;
use App\Policies\WebhookPolicy;
use App\Support\Permissions;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Spatie\Permission\Models\Role;
@@ -62,13 +69,39 @@ class AuthServiceProvider extends ServiceProvider
Gate::policy(Repository::class, RepositoryPolicy::class);
Gate::policy(User::class, UserPolicy::class);
Gate::policy(Role::class, RolePolicy::class);
Gate::policy(Webhook::class, WebhookPolicy::class);
Gate::policy(ReplyTemplate::class, ReplyTemplatePolicy::class);
Gate::policy(Tag::class, TagPolicy::class);
// Super admins bypass every permission check. Granting the role this
// way rather than by attaching every permission means the role can
// never fall behind as the catalogue grows.
// The one place a permission is granted — spatie's own gate check is
// switched off (config/permission.php) so nothing answers before this.
//
// Returning null (not false) when the role is absent is important: it
// lets the normal policy chain run instead of short-circuiting it.
Gate::before(fn (User $user, string $ability) => $user->hasRole('super_admin') ? true : null);
// Super admins bypass every check. Granting the role this way rather
// than by attaching every permission means the role can never fall
// behind as the catalogue grows. Returning null (not false) when
// nothing applies lets the normal policy chain run.
//
// A machine token narrows all of that: a permission must be one the
// token was given *and* one its owner holds. A policy is not a
// permission, so it runs as usual and asks for permissions, which come
// back here.
Gate::before(function (User $user, string $ability): ?bool {
$token = $user->currentAccessToken();
if ($token !== null) {
if (! in_array($ability, Permissions::all(), true)) {
return null;
}
return $token->allows($ability)
&& ($user->hasRole('super_admin') || $user->checkPermissionTo($ability));
}
if ($user->hasRole('super_admin')) {
return true;
}
return $user->checkPermissionTo($ability) ?: null;
});
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Rules;
use App\Support\Webhooks\PublicEndpoint;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* An HTTPS address whose host resolves only to public addresses — somewhere
* this server may send a request to without reaching into its own network.
*/
class PublicHttpsUrl implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
[, $reason] = app(PublicEndpoint::class)->check((string) $value);
if ($reason !== null) {
$fail(__("rules.media.remote.{$reason}"));
}
}
}

View File

@@ -37,6 +37,22 @@ class PireonClient
);
}
/**
* The client for the redirect flow: its own when PIREON_REDIRECT_CLIENT_ID
* is set (a Pireon client allows one grant type), the main one otherwise.
*/
public static function forRedirect(): self
{
$own = filled(config('pireon.redirect_client_id'));
return new self(
server: (string) config('pireon.server'),
clientId: $own ? config('pireon.redirect_client_id') : config('pireon.client_id'),
clientSecret: $own ? config('pireon.redirect_client_secret') : config('pireon.client_secret'),
redirectUri: (string) config('pireon.redirect_uri'),
);
}
public function isConfigured(): bool
{
return filled($this->clientId);

View File

@@ -18,14 +18,25 @@ class TokenStore
{
protected const KEY = 'pireon.token';
/** Which client issued it: a refresh token only works with that one. */
protected const ISSUER = 'pireon.token_issuer';
public function __construct(
protected Session $session,
protected PireonClient $client,
) {}
public function put(AccessToken $token): void
/**
* @param bool|null $viaRedirect Whether the redirect flow's client
* issued it; null keeps what was said.
*/
public function put(AccessToken $token, ?bool $viaRedirect = null): void
{
$this->session->put(self::KEY, $token->toArray());
if ($viaRedirect !== null) {
$this->session->put(self::ISSUER, $viaRedirect ? 'redirect' : 'password');
}
}
public function get(): ?AccessToken
@@ -37,7 +48,7 @@ class TokenStore
public function forget(): void
{
$this->session->forget(self::KEY);
$this->session->forget([self::KEY, self::ISSUER]);
}
/**
@@ -66,7 +77,10 @@ class TokenStore
}
try {
$refreshed = $this->client->refresh($token->refreshToken);
$issuer = $this->session->get(self::ISSUER) === 'redirect'
? PireonClient::forRedirect()
: $this->client;
$refreshed = $issuer->refresh($token->refreshToken);
} catch (PireonException) {
// The refresh token was revoked or expired upstream.
$this->forget();

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Services\TaskStream;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
/**
* The few calls this application makes to TaskStream's REST API.
*
* Every call is made as somebody: TaskStream accepts the same Pireon tokens
* this application does, and whoever's token it is becomes the owner of
* what is created.
*/
class TaskStreamClient
{
public function __construct(
protected string $apiUrl,
protected string $webUrl,
protected ?string $serviceToken = null,
protected int $timeout = 10,
) {}
/**
* @param array<string, mixed> $config
*/
public static function fromConfig(array $config): self
{
return new self(
apiUrl: rtrim((string) ($config['api_url'] ?? ''), '/'),
webUrl: rtrim((string) ($config['web_url'] ?? ''), '/'),
serviceToken: filled($config['token'] ?? null) ? $config['token'] : null,
timeout: (int) ($config['timeout'] ?? 10),
);
}
/**
* The token for requests that do not carry a Pireon token of their own.
*/
public function serviceToken(): ?string
{
return $this->serviceToken;
}
/**
* @return array{id: string, key: string}
*
* @throws TaskStreamException
*/
public function createProject(string $token, string $key, string $name, ?string $description, bool $public): array
{
try {
$response = Http::withToken($token)
->acceptJson()
->timeout($this->timeout)
->post("{$this->apiUrl}/api/projects", array_filter([
'key' => $key,
'name' => $name,
'description' => $description,
'isPublic' => $public,
], fn ($value) => $value !== null));
} catch (ConnectionException $e) {
throw TaskStreamException::unreachable($this->apiUrl, $e);
}
if ($response->failed()) {
// Nest answers `message` as a string, or as a list from validation.
$message = $response->json('message');
throw new TaskStreamException(
is_array($message) ? implode(' ', $message) : (string) ($message ?: $response->reason()),
status: $response->status(),
);
}
return ['id' => (string) $response->json('id'), 'key' => (string) $response->json('key')];
}
/**
* Where people open a project's board.
*/
public function projectUrl(string $key): string
{
return "{$this->webUrl}/p/{$key}";
}
/**
* The public summary of a board (roadmap, releases, sprint, tickets for
* newcomers). Anyone may read it, so the public site can ask directly.
*/
public function showcaseUrl(string $key): string
{
return "{$this->apiUrl}/api/explore/projects/{$key}/showcase";
}
/**
* Where the paths of a showcase (`/p/KEY/issues/KEY-3`) are opened.
*/
public function webUrl(): string
{
return $this->webUrl;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\TaskStream;
use RuntimeException;
use Throwable;
class TaskStreamException extends RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null,
?Throwable $previous = null,
) {
parent::__construct($message, $status ?? 0, $previous);
}
/**
* TaskStream refused what it was asked for (a key in use, a bad name):
* the caller can fix it and try again.
*/
public function isRefusal(): bool
{
return in_array($this->status, [400, 409, 422], true);
}
/**
* The token was not good enough there: expired, or without access.
*/
public function isUnauthorised(): bool
{
return in_array($this->status, [401, 403], true);
}
public static function unreachable(string $url, ?Throwable $previous = null): self
{
return new self("TaskStream could not be reached at [{$url}].", previous: $previous);
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Call;
use App\Models\Category;
use App\Models\Client;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\Talk;
use App\Models\Technology;
use App\Models\Testimonial;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* The content the admin API addresses by name — `pages`, `projects` — and
* what each one supports beyond plain editing: a history, a review, a preview.
*
* One table rather than a flag on every model, so the routes, the
* specification and the controllers that serve those extras all read the same
* answer.
*/
final class AdminContent
{
/**
* API name => [model, route parameter].
*
* @var array<string, array{0: class-string<Model>, 1: string}>
*/
public const WITH_HISTORY = [
'pages' => [Page::class, 'page'],
'categories' => [Category::class, 'category'],
'projects' => [Project::class, 'project'],
'members' => [Member::class, 'member'],
'services' => [Service::class, 'service'],
'clients' => [Client::class, 'client'],
'testimonials' => [Testimonial::class, 'testimonial'],
'talks' => [Talk::class, 'talk'],
'technologies' => [Technology::class, 'technology'],
'calls' => [Call::class, 'call'],
];
/**
* What goes through a review before it reaches the site, and the fields
* that put each one there — the ones `{resource}.publish` guards.
*
* @var array<string, array<int, string>>
*/
public const REVIEWABLE = [
'pages' => ['status', 'published_at', 'unpublish_at'],
'projects' => ['published_at', 'unpublish_at'],
'services' => ['is_published', 'published_at', 'unpublish_at'],
];
/**
* What has an address on the public site to preview, by the type name
* `SiteUrls` knows it as.
*
* @var array<string, string>
*/
public const PREVIEWABLE = [
'pages' => 'page',
'projects' => 'project',
'services' => 'service',
'calls' => 'call',
];
/**
* @return class-string<Model>
*/
public static function model(string $resource): string
{
return self::WITH_HISTORY[$resource][0] ?? abort(404);
}
public static function parameter(string $resource): string
{
return self::WITH_HISTORY[$resource][1] ?? abort(404);
}
/**
* The record a route names, binned ones included: their history is still
* worth reading, and restoring them is what the bin is for.
*/
public static function find(string $resource, mixed $id): Model
{
return self::findOf(self::model($resource), $id);
}
/**
* @param class-string<Model> $model
*/
public static function findOf(string $model, mixed $id): Model
{
$query = $model::query();
if (in_array(SoftDeletes::class, class_uses_recursive($model), true)) {
$query->withTrashed();
}
return $query->findOrFail($id);
}
/**
* The API name of a record, for a review queue that mixes several kinds.
*/
public static function nameOf(Model $record): ?string
{
foreach (self::WITH_HISTORY as $resource => [$model]) {
if ($record instanceof $model) {
return $resource;
}
}
return null;
}
/**
* The fields only `{resource}.publish` may write, for this record.
*
* @return array<int, string>
*/
public static function publicationFields(Model $record): array
{
return self::REVIEWABLE[self::nameOf($record)] ?? [];
}
}

View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace App\Support\Audit;
use App\Models\ApiToken;
use App\Models\Application;
use App\Models\AuditLog;
use App\Models\Call;
use App\Models\Category;
use App\Models\Client;
use App\Models\Media;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Redirect;
use App\Models\Repository;
use App\Models\Service;
use App\Models\Setting;
use App\Models\Tag;
use App\Models\Talk;
use App\Models\Technology;
use App\Models\Testimonial;
use App\Models\User;
use App\Models\Webhook;
use App\Support\AdminContent;
use App\Support\ContentVersion;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
/**
* Writes the audit log.
*
* Only what a person did: a repository's numbers refreshed by the sync job, or
* a 404 reported by the frontend, are the application at work, and logging
* them would bury the entries anybody comes here for.
*/
final class Auditor
{
/**
* What is audited, by the name the entries use for it.
*
* @var array<class-string<Model>, string>
*/
public const AUDITED = [
Page::class => 'pages',
Category::class => 'categories',
Tag::class => 'tags',
Project::class => 'projects',
Member::class => 'members',
Service::class => 'services',
Client::class => 'clients',
Testimonial::class => 'testimonials',
Talk::class => 'talks',
Technology::class => 'technologies',
Call::class => 'calls',
Application::class => 'applications',
Redirect::class => 'redirects',
Repository::class => 'repositories',
Media::class => 'media',
Setting::class => 'settings',
User::class => 'users',
ApiToken::class => 'tokens',
Webhook::class => 'webhooks',
];
/**
* Never written down, whatever changed.
*/
protected const SECRET = ['password', 'remember_token', 'token_hash', 'secret'];
protected const IGNORED = ['created_at', 'updated_at', 'last_used_at', 'last_login_at'];
/**
* @param array<string, mixed>|null $changes
*/
public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): ?AuditLog
{
$actor ??= Auth::user();
if (! $actor instanceof User) {
return null;
}
$request = app()->bound('request') ? request() : null;
$route = $request?->route();
$token = $actor->currentAccessToken();
return AuditLog::query()->create([
'user_id' => $actor->id,
'user_name' => $actor->name,
'via' => self::via($route?->getName(), $token !== null),
'api_token_id' => $token?->id,
'ip' => $route ? $request->ip() : null,
'action' => $action,
'subject_type' => $subject ? (self::AUDITED[$subject::class] ?? class_basename($subject)) : null,
'subject_id' => $subject ? (string) $subject->getKey() : null,
'subject_label' => $subject ? Str::limit(self::label($subject), 250) : null,
'changes' => $changes ?: null,
]);
}
/**
* From a model event: `pages.created`, `pages.updated` with what changed,
* `pages.deleted`…
*/
public static function modelEvent(string $event, Model $model): void
{
$resource = self::AUDITED[$model::class] ?? null;
// Bumped by every content edit, by the application: the edit itself is
// what is logged.
if ($resource === null || ($model instanceof Setting && $model->getKey() === ContentVersion::KEY)) {
return;
}
$changes = match ($event) {
'created' => self::values($model->getAttributes()),
'updated' => self::diff($model),
default => null,
};
// Saved with nothing anyone would call a change.
if ($event === 'updated' && $changes === []) {
return;
}
self::record("{$resource}.{$event}", $model, $changes);
}
protected static function via(?string $routeName, bool $token): string
{
return match (true) {
$token => 'token',
$routeName !== null && str_starts_with($routeName, 'api.') => 'api',
$routeName !== null && str_starts_with($routeName, 'filament.') => 'panel',
$routeName !== null, app()->runningInConsole() === false => 'web',
default => 'console',
};
}
/**
* @return array<string, array{old: mixed, new: mixed}>
*/
protected static function diff(Model $model): array
{
$changes = [];
foreach ($model->getChanges() as $attribute => $new) {
if (in_array($attribute, self::IGNORED, true)) {
continue;
}
$changes[$attribute] = in_array($attribute, self::SECRET, true)
? ['old' => '••••', 'new' => '••••']
: ['old' => self::value($model->getRawOriginal($attribute)), 'new' => self::value($model->getAttributes()[$attribute] ?? null)];
}
return $changes;
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
protected static function values(array $attributes): array
{
$values = [];
foreach ($attributes as $attribute => $value) {
if (in_array($attribute, self::IGNORED, true) || $value === null) {
continue;
}
$values[$attribute] = in_array($attribute, self::SECRET, true) ? '••••' : self::value($value);
}
return $values;
}
/**
* Short enough to keep a log a log: what changed is here in outline, and
* in full in the record's own history.
*/
protected static function value(mixed $value): mixed
{
if ($value instanceof \BackedEnum) {
return $value->value;
}
if ($value instanceof \DateTimeInterface) {
return $value->format('c');
}
if (is_string($value)) {
return Str::limit($value, 300);
}
if (is_array($value) || is_object($value)) {
return Str::limit((string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 300);
}
return $value;
}
protected static function label(Model $model): string
{
foreach (['title', 'name', 'full_name', 'from', 'key', 'path', 'email'] as $attribute) {
// Only real columns: `Repository::name()` is a method, and asking
// for it as an attribute would treat it as a relation.
if (! array_key_exists($attribute, $model->getAttributes())) {
continue;
}
$value = $model->getAttribute($attribute);
if (is_array($value)) {
$value = $value[app()->getLocale()] ?? reset($value);
}
if (method_exists($model, 'isTranslatableAttribute') && $model->isTranslatableAttribute($attribute)) {
$value = $model->getTranslation($attribute, app()->getLocale(), true);
}
if (filled($value) && is_scalar($value)) {
return (string) $value;
}
}
return (AdminContent::nameOf($model) ?? class_basename($model)).' #'.$model->getKey();
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Support\Batch;
use RuntimeException;
/**
* Thrown inside the batch's transaction to undo it.
*/
final class BatchAborted extends RuntimeException {}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Support\Batch;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* Runs several admin API calls as one.
*
* Each operation goes through the HTTP kernel as a request of its own, with
* the caller's credentials — so it is validated, authorised, throttled and
* answered exactly as if it had been sent separately. Nothing here knows what
* a page or a project is, which is the point: a batch cannot do anything the
* endpoints themselves would refuse.
*
* Atomic batches run inside one transaction and stop at the first failure,
* undoing everything before it. What left the database meanwhile — a queued
* job, a notification — is not undone.
*/
final class BatchRunner
{
public const MAX_OPERATIONS = 50;
/**
* Endpoints a batch may not call: itself (no nesting), the import that is
* built on it, and the token exchange, which is not an admin operation.
*/
protected const EXCLUDED = ['/v1/batch', '/v1/import/', '/v1/auth/'];
public function __construct(protected Kernel $kernel) {}
public static function allows(string $path): bool
{
// A plain path and an optional query string, with no way of climbing
// out of `/v1/` once the router normalises it.
if (! preg_match('#^/v1/[A-Za-z0-9_\-./]*(\?[A-Za-z0-9_\-.=&%\[\]+,]*)?$#', $path)
|| str_contains($path, '..')
|| str_contains($path, '//')) {
return false;
}
foreach (self::EXCLUDED as $excluded) {
if (str_starts_with($path, $excluded)) {
return false;
}
}
return true;
}
/**
* @param array<int, array{method: string, path: string, body?: array<string, mixed>|null}> $operations
* @param bool $atomic All or nothing.
* @param bool $rollback Undo everything even when all succeeded — a dry run.
* @return array{results: array<int, array<string, mixed>>, committed: bool}
*/
public function run(Request $parent, array $operations, bool $atomic = false, bool $rollback = false): array
{
$results = [];
$execute = function () use ($parent, $operations, $atomic, &$results): void {
foreach ($operations as $index => $operation) {
$result = $this->dispatch($parent, $operation);
$results[] = ['index' => $index, ...$result];
if ($atomic && $result['status'] >= 400) {
throw new BatchAborted;
}
}
};
if (! $atomic && ! $rollback) {
$execute();
return ['results' => $results, 'committed' => true];
}
try {
DB::transaction(function () use ($execute, $rollback): void {
$execute();
if ($rollback) {
throw new BatchAborted;
}
});
} catch (BatchAborted) {
return ['results' => $results, 'committed' => false];
}
return ['results' => $results, 'committed' => true];
}
/**
* @param array{method: string, path: string, body?: array<string, mixed>|null} $operation
* @return array{method: string, path: string, status: int, body: mixed}
*/
protected function dispatch(Request $parent, array $operation): array
{
$method = strtoupper($operation['method']);
$path = $operation['path'];
$request = Request::create(
'/api'.$path,
$method,
server: array_filter([
'HTTP_AUTHORIZATION' => $parent->header('Authorization'),
'HTTP_ACCEPT' => 'application/json',
'HTTP_ACCEPT_LANGUAGE' => $parent->header('Accept-Language'),
'CONTENT_TYPE' => 'application/json',
'REMOTE_ADDR' => $parent->ip(),
]),
content: $method === 'GET' ? null : (string) json_encode((object) ($operation['body'] ?? [])),
);
try {
$response = $this->kernel->handle($request);
} catch (Throwable $e) {
report($e);
return ['method' => $method, 'path' => $path, 'status' => 500, 'body' => null];
} finally {
// The kernel swapped the current request for the operation's;
// whatever runs after this one still belongs to the batch's.
app()->instance('request', $parent);
}
$content = $response->getContent();
return [
'method' => $method,
'path' => $path,
'status' => $response->getStatusCode(),
'body' => $content === '' || $content === false ? null : json_decode($content, true),
];
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Support\Batch;
use App\Support\AdminContent;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use Spatie\Translatable\HasTranslations;
/**
* Turns rows — from a JSON array or a CSV file — into the create and update
* calls the API already has, and hands them to the batch runner.
*
* A row that names an existing record, by `id` or by the field given as
* `match`, updates it; any other row creates one. In a CSV, a column called
* `title.es` fills the Spanish title, a list is written `1|2|3`, `true` and
* `false` are booleans, and an empty cell is left out rather than erasing
* what is there.
*/
final class Importer
{
public const MAX_ROWS = BatchRunner::MAX_OPERATIONS;
/**
* Fields a CSV writes as `a|b|c`.
*/
protected const LISTS = ['technology_ids', 'service_ids', 'project_ids'];
/**
* @return array<int, array<string, mixed>>
*/
public function rowsFromCsv(UploadedFile $file): array
{
$handle = fopen($file->getRealPath(), 'r');
$header = null;
$rows = [];
while (($line = fgetcsv($handle, escape: '\\')) !== false) {
if ($line === [null]) {
continue;
}
if ($header === null) {
// A byte-order mark is what a spreadsheet saving "CSV UTF-8"
// puts before the first column's name.
$header = array_map(fn ($name) => trim((string) preg_replace('/^\xEF\xBB\xBF/', '', (string) $name)), $line);
continue;
}
$row = [];
foreach ($header as $i => $column) {
$value = $line[$i] ?? '';
if ($column === '' || $value === '') {
continue;
}
Arr::set($row, $column, $this->cell($column, $value));
}
$rows[] = $row;
}
fclose($handle);
return $rows;
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array{method: string, path: string, body: array<string, mixed>}>
*/
public function operations(string $resource, array $rows, ?string $match = null): array
{
if (count($rows) > self::MAX_ROWS) {
throw ValidationException::withMessages(['rows' => [__('rules.batch.too_many', ['max' => self::MAX_ROWS])]]);
}
return array_map(function (array $row) use ($resource, $match): array {
$id = $row['id'] ?? $this->existing($resource, $row, $match);
unset($row['id']);
return $id === null
? ['method' => 'POST', 'path' => "/v1/{$resource}", 'body' => $row]
: ['method' => 'PATCH', 'path' => "/v1/{$resource}/{$id}", 'body' => $row];
}, $rows);
}
/**
* The record a row names through its `match` field, if any: a slug is
* looked up in the default language when it is translated.
*
* @param array<string, mixed> $row
*/
protected function existing(string $resource, array $row, ?string $match): mixed
{
if ($match === null || ! array_key_exists($match, $row)) {
return null;
}
$model = AdminContent::model($resource);
$instance = new $model;
$value = $row[$match];
$translated = in_array(HasTranslations::class, class_uses_recursive($model), true)
&& in_array($match, $instance->translatable, true);
if ($translated) {
$locale = (string) config('locales.default');
$value = is_array($value) ? ($value[$locale] ?? null) : $value;
$column = "{$match}->{$locale}";
} else {
$column = $match;
}
if ($value === null || is_array($value)) {
return null;
}
$query = $model::query();
if (method_exists($instance, 'bootSoftDeletes')) {
$query->withTrashed();
}
return $query->where($column, $value)->value($instance->getKeyName());
}
protected function cell(string $column, string $value): mixed
{
$base = explode('.', $column)[0];
return match (true) {
in_array($base, self::LISTS, true) => array_map(
fn ($item) => is_numeric($item) ? (int) $item : $item,
array_values(array_filter(array_map('trim', explode('|', $value)), fn ($item) => $item !== '')),
),
strtolower($value) === 'true' => true,
strtolower($value) === 'false' => false,
strtolower($value) === 'null' => null,
default => $value,
};
}
}

Some files were not shown because too many files have changed in this diff Show More