Compare commits
26 Commits
4c4a2b3299
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| c0825d94d4 | |||
| d10ce1ca57 | |||
| ec4f687ca8 | |||
| 3c119d425e | |||
| 7e7d2847fe | |||
| b32d6bf001 | |||
| cdfc8763a5 | |||
| d30dd971d7 | |||
| 9390db18c2 | |||
| d4cf4a66b7 | |||
| d80ed1a3e8 | |||
| 9f970808ca | |||
| 6fc600e6a1 | |||
| 76d022053b | |||
| 697d333ad4 | |||
| 8d0f81f9c5 | |||
| 773d3901c6 | |||
| 366568b793 | |||
| 70521d4bc9 | |||
| 1fe1e67773 | |||
| 9bdbf834f6 | |||
| f93775e549 | |||
| 0119004f2c | |||
| 6b3517743a | |||
| 68c5bd4274 | |||
| 5b46436641 |
30
.env.example
30
.env.example
@@ -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
350
README.md
@@ -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
|
||||
|
||||
|
||||
79
app/Actions/Auth/HandlePireonWebhook.php
Normal file
79
app/Actions/Auth/HandlePireonWebhook.php
Normal 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();
|
||||
}
|
||||
}
|
||||
111
app/Actions/Projects/OpenInTaskStream.php
Normal file
111
app/Actions/Projects/OpenInTaskStream.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
60
app/Console/Commands/NotifyExpiringCommand.php
Normal file
60
app/Console/Commands/NotifyExpiringCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
52
app/Filament/Charts/ViewsByLanguageChart.php
Normal file
52
app/Filament/Charts/ViewsByLanguageChart.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
123
app/Filament/Pages/EditorialCalendar.php
Normal file
123
app/Filament/Pages/EditorialCalendar.php
Normal 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));
|
||||
}
|
||||
}
|
||||
84
app/Filament/Pages/Statistics.php
Normal file
84
app/Filament/Pages/Statistics.php
Normal 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).' %';
|
||||
}
|
||||
}
|
||||
244
app/Filament/Pages/TranslationAssistant.php
Normal file
244
app/Filament/Pages/TranslationAssistant.php
Normal 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)),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
])
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
131
app/Filament/Resources/ReplyTemplates/ReplyTemplateResource.php
Normal file
131
app/Filament/Resources/ReplyTemplates/ReplyTemplateResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
21
app/Filament/Resources/Tags/Pages/ManageTags.php
Normal file
21
app/Filament/Resources/Tags/Pages/ManageTags.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
108
app/Filament/Resources/Tags/TagResource.php
Normal file
108
app/Filament/Resources/Tags/TagResource.php
Normal 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('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Filament/Resources/Webhooks/Pages/CreateWebhook.php
Normal file
44
app/Filament/Resources/Webhooks/Pages/CreateWebhook.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
35
app/Filament/Resources/Webhooks/Pages/EditWebhook.php
Normal file
35
app/Filament/Resources/Webhooks/Pages/EditWebhook.php
Normal 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;
|
||||
}
|
||||
}
|
||||
21
app/Filament/Resources/Webhooks/Pages/ListWebhooks.php
Normal file
21
app/Filament/Resources/Webhooks/Pages/ListWebhooks.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
263
app/Filament/Resources/Webhooks/WebhookResource.php
Normal file
263
app/Filament/Resources/Webhooks/WebhookResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
38
app/Filament/Support/DuplicateAction.php
Normal file
38
app/Filament/Support/DuplicateAction.php
Normal 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]));
|
||||
});
|
||||
}
|
||||
}
|
||||
34
app/Filament/Support/TranslateAction.php
Normal file
34
app/Filament/Support/TranslateAction.php
Normal 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();
|
||||
}
|
||||
}
|
||||
40
app/Filament/Widgets/ExpiringSoon.php
Normal file
40
app/Filament/Widgets/ExpiringSoon.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
41
app/Http/Controllers/Api/PublicApi/V1/SearchController.php
Normal file
41
app/Http/Controllers/Api/PublicApi/V1/SearchController.php
Normal 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()],
|
||||
]);
|
||||
}
|
||||
}
|
||||
104
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
104
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
98
app/Http/Controllers/Api/V1/ApplicationReplyController.php
Normal file
98
app/Http/Controllers/Api/V1/ApplicationReplyController.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Http/Controllers/Api/V1/AuditController.php
Normal file
53
app/Http/Controllers/Api/V1/AuditController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
99
app/Http/Controllers/Api/V1/BatchController.php
Normal file
99
app/Http/Controllers/Api/V1/BatchController.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/Api/V1/Concerns/ResolvesContent.php
Normal file
29
app/Http/Controllers/Api/V1/Concerns/ResolvesContent.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
37
app/Http/Controllers/Api/V1/DuplicateController.php
Normal file
37
app/Http/Controllers/Api/V1/DuplicateController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
81
app/Http/Controllers/Api/V1/HealthController.php
Normal file
81
app/Http/Controllers/Api/V1/HealthController.php
Normal 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()]);
|
||||
}
|
||||
}
|
||||
@@ -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') ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
61
app/Http/Controllers/Api/V1/PireonWebhookController.php
Normal file
61
app/Http/Controllers/Api/V1/PireonWebhookController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
43
app/Http/Controllers/Api/V1/PreviewController.php
Normal file
43
app/Http/Controllers/Api/V1/PreviewController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
187
app/Http/Controllers/Api/V1/ReviewController.php
Normal file
187
app/Http/Controllers/Api/V1/ReviewController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
74
app/Http/Controllers/Api/V1/RevisionController.php
Normal file
74
app/Http/Controllers/Api/V1/RevisionController.php
Normal 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());
|
||||
}
|
||||
}
|
||||
82
app/Http/Controllers/Api/V1/TagController.php
Normal file
82
app/Http/Controllers/Api/V1/TagController.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
134
app/Http/Controllers/Api/V1/WebhookController.php
Normal file
134
app/Http/Controllers/Api/V1/WebhookController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
54
app/Http/Requests/Api/V1/WebhookRequest.php
Normal file
54
app/Http/Requests/Api/V1/WebhookRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Http/Resources/ApiTokenResource.php
Normal file
40
app/Http/Resources/ApiTokenResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Http/Resources/AuditLogResource.php
Normal file
37
app/Http/Resources/AuditLogResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.
|
||||
|
||||
32
app/Http/Resources/ReviewCommentResource.php
Normal file
32
app/Http/Resources/ReviewCommentResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
33
app/Http/Resources/RevisionResource.php
Normal file
33
app/Http/Resources/RevisionResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
34
app/Http/Resources/WebhookDeliveryResource.php
Normal file
34
app/Http/Resources/WebhookDeliveryResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Resources/WebhookResource.php
Normal file
36
app/Http/Resources/WebhookResource.php
Normal 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
152
app/Jobs/DeliverWebhook.php
Normal 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();
|
||||
}
|
||||
}
|
||||
39
app/Mail/ApplicationReplyMail.php
Normal file
39
app/Mail/ApplicationReplyMail.php
Normal 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
128
app/Models/ApiToken.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
31
app/Models/ApplicationReply.php
Normal file
31
app/Models/ApplicationReply.php
Normal 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
46
app/Models/AuditLog.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
64
app/Models/ReplyTemplate.php
Normal file
64
app/Models/ReplyTemplate.php
Normal 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
37
app/Models/Tag.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
132
app/Models/Webhook.php
Normal 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);
|
||||
}
|
||||
}
|
||||
40
app/Models/WebhookDelivery.php
Normal file
40
app/Models/WebhookDelivery.php
Normal 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);
|
||||
}
|
||||
}
|
||||
50
app/Notifications/ExpiringSoon.php
Normal file
50
app/Notifications/ExpiringSoon.php
Normal 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();
|
||||
}
|
||||
}
|
||||
44
app/Policies/ReplyTemplatePolicy.php
Normal file
44
app/Policies/ReplyTemplatePolicy.php
Normal 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');
|
||||
}
|
||||
}
|
||||
17
app/Policies/TagPolicy.php
Normal file
17
app/Policies/TagPolicy.php
Normal 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';
|
||||
}
|
||||
}
|
||||
13
app/Policies/WebhookPolicy.php
Normal file
13
app/Policies/WebhookPolicy.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
class WebhookPolicy extends ResourcePolicy
|
||||
{
|
||||
protected function resource(): string
|
||||
{
|
||||
return 'webhooks';
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
25
app/Rules/PublicHttpsUrl.php
Normal file
25
app/Rules/PublicHttpsUrl.php
Normal 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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
105
app/Services/TaskStream/TaskStreamClient.php
Normal file
105
app/Services/TaskStream/TaskStreamClient.php
Normal 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;
|
||||
}
|
||||
}
|
||||
41
app/Services/TaskStream/TaskStreamException.php
Normal file
41
app/Services/TaskStream/TaskStreamException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
132
app/Support/AdminContent.php
Normal file
132
app/Support/AdminContent.php
Normal 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)] ?? [];
|
||||
}
|
||||
}
|
||||
234
app/Support/Audit/Auditor.php
Normal file
234
app/Support/Audit/Auditor.php
Normal 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();
|
||||
}
|
||||
}
|
||||
12
app/Support/Batch/BatchAborted.php
Normal file
12
app/Support/Batch/BatchAborted.php
Normal 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 {}
|
||||
141
app/Support/Batch/BatchRunner.php
Normal file
141
app/Support/Batch/BatchRunner.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
148
app/Support/Batch/Importer.php
Normal file
148
app/Support/Batch/Importer.php
Normal 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
Reference in New Issue
Block a user