Compare commits

..

15 Commits

Author SHA1 Message Date
9f970808ca Tag blog posts and sign them with a member
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Tags are named in every language and made from the page form; the byline is
the member who signs a post on the site. The public API sends both, filters
posts by tag and lists the tags in use.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:17:46 +02:00
773d3901c6 Warn about calls closing and content coming off the site
A dashboard widget lists what expires in the next two weeks, and a daily
command reminds whoever edits it a week and a day ahead.

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

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

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

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

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

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

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

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

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

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

240
README.md
View File

@@ -663,6 +663,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 renderizara en cliente necesitaría otra cosa —un token de preview por sesión y
con caducidad—, no este secreto compartido. 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 ## La vitrina: servicios, clientes, testimonios y charlas
Cuatro tipos de contenido con los que se compone una landing o un portfolio. Cuatro tipos de contenido con los que se compone una landing o un portfolio.
@@ -941,6 +957,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 El cálculo se cachea contra el `updated_at` del registro, así que se rehace en
cuanto algo cambia y ni un minuto antes. 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 ## Revisión
`pages.publish` lleva separado desde el principio: **poner algo en la web es un `pages.publish` lleva separado desde el principio: **poner algo en la web es un
@@ -1156,9 +1188,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 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. puede mandar por chat sin dar acceso a todos los borradores del sitio.
**Duplicar** — en páginas, proyectos, servicios y convocatorias. Copia todo **Duplicar** — en páginas, proyectos, servicios y convocatorias, en la tabla y
menos el slug y las fechas, porque dos registros no pueden responder en la misma en el formulario, y por API (`POST /api/v1/{tipo}/{id}/duplicate`). La copia es
URL. 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. **Búsqueda global** — los doce recursos, desde la barra de arriba.
@@ -1302,6 +1383,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 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. 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, Los ajustes siguen los permisos del panel: `settings.view` para leer,
`settings.update` para cambiar y `settings.maintenance` para `site.maintenance` `settings.update` para cambiar y `settings.maintenance` para `site.maintenance`
y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo
@@ -1315,6 +1429,121 @@ cliente en vez de comprobar nombres de rol.
Quien no tenga `pages.publish` solo ve páginas publicadas en el listado. Quien no tenga `pages.publish` solo ve páginas publicadas en el listado.
### Tokens para máquinas
```
GET /api/v1/tokens mis tokens (all=1 para un super_admin)
POST /api/v1/tokens {name, abilities, expires_at?} → el token, una vez
DELETE /api/v1/tokens/{id} revocar
```
Para scripts, CI o integraciones que no deberían usar la contraseña de nadie.
Una persona lo crea con los permisos que le da (`pages.update`, o `pages.*`
para todo lo de páginas) y nunca más de los que ella tiene; caduca a los 90
días si no se dice otra cosa, y como mucho al año. Se guarda solo su hash, y el
token en claro (`pmt_…`) sale una única vez, en la respuesta que lo crea.
Actúa como esa persona **dentro** de sus permisos, y se comprueba en cada
petición, así que quitarle un rol recorta sus tokens al momento; desactivarla
los apaga. El guard lo reconoce por el prefijo y **no lo manda nunca al
servidor de identidad**. Un token no puede crear ni revocar tokens: si no, uno
filtrado sería una forma de no caducar nunca.
Para que eso funcione, la comprobación de permisos es ahora una sola regla en
`AuthServiceProvider` y la de spatie está apagada
(`register_permission_check_method => false`): la de spatie respondía antes y
concedía el permiso sin preguntar al token.
### Webhooks salientes
```
/api/v1/webhooks CRUD (permiso webhooks.*, solo admin)
GET /api/v1/webhooks/{id}/deliveries qué se envió y cómo fue
POST /api/v1/webhooks/{id}/ping envío de prueba
POST /api/v1/webhook-deliveries/{id}/redeliver
```
Avisan a otro sistema de `application.received`, `content.published`,
`content.unpublished`, `review.requested` y `review.returned` (o `*`). Salen de
los propios registros —el historial ya distingue publicar de editar, venga del
panel, de la API o de un lote— y no de los controladores, así que no hay forma
de publicar algo sin que se avise.
Cada envío es un POST JSON `{id, event, created_at, data}` firmado: la cabecera
`X-Webhook-Signature` es `sha256=` más el HMAC-SHA256 de `{timestamp}.{body}`
con el secreto, y `X-Webhook-Timestamp` va aparte para que un envío capturado
no se pueda repetir más tarde. El secreto se genera solo (o se rota con
`rotate_secret`), se guarda cifrado y sale una única vez.
Las mismas defensas que el descargador de imágenes, porque la petición sale
desde dentro del stack: solo HTTPS, solo direcciones públicas —comprobado al
guardar y **otra vez en cada envío**, fijando la conexión a la dirección
comprobada—, sin seguir redirecciones. Cinco reintentos en unas dos horas y
media; veinte fallos seguidos lo apagan hasta que alguien lo vuelve a activar.
Los datos de quien escribe al buzón (nombre, correo, mensaje) solo viajan a los
webhooks con `include_personal_data`.
### Registro de auditoría
`GET /api/v1/audit` (permiso `audit.view_any`, solo admin): quién hizo qué,
cuándo y **por dónde entró** —`panel`, `api`, `token` (con qué token),
`console`— y desde qué IP. Filtros: `user_id`, `action` (exacta o un prefijo
como `pages.`), `subject_type`, `subject_id`, `via`, `from`, `to`.
El historial de cada ficha contesta «qué le pasó a esto»; esto contesta «qué
ha estado haciendo esta persona», en todo, incluido lo que no tiene historial
propio: roles (los eventos de spatie están activados para eso), ajustes,
tokens, webhooks, medios, el buzón. Lo escribe
[`Auditor`](app/Support/Audit/Auditor.php) desde los eventos de los modelos,
así que no depende de que cada controlador se acuerde.
Solo lo que hace una persona: una sincronización o una publicación programada
son la aplicación trabajando y enterrarían lo que se viene a buscar, y el
`content.version` que sube con cada edición tampoco cuenta. Los secretos
(contraseñas, hashes de token, secretos de webhook) salen como `••••`, y los
valores largos se recortan: el detalle completo está en el historial de la
ficha. Como nombra a personas y sus direcciones, se borra pasados
`AUDIT_RETENTION_DAYS` (365 por defecto) con `model:prune`, programado a
diario.
### Lotes e importación
```
POST /api/v1/batch hasta 50 operaciones {method, path, body}; atomic
POST /api/v1/import/{tipo} filas JSON (rows) o CSV (file); match, atomic, dry_run
```
Cada operación de un lote pasa por el kernel HTTP como una petición propia con
las credenciales de quien llama: se valida, se autoriza, cuenta para el límite
de peticiones y responde igual que si llegara sola, así que un lote no puede
hacer nada que sus operaciones no pudieran. [`BatchRunner`](app/Support/Batch/BatchRunner.php)
no sabe qué es una página, y es justo lo que lo hace seguro. Con `atomic` va en
una transacción y se para en el primer fallo deshaciéndolo todo; lo que ya salió
de la base (un trabajo en cola, un aviso) no se deshace.
La importación convierte filas en esas mismas llamadas: con `id`, o con el
campo de `match` (`slug`, `name`, `full_name`) apuntando a una ficha que existe,
actualiza; si no, crea. En CSV, `title.es` rellena el título en castellano, una
lista se escribe `1|2|3`, `true`/`false`/`null` son lo que dicen y una celda
vacía se omite en vez de borrar lo que hubiera. Por defecto es todo o nada, y
`dry_run` lo comprueba todo sin guardar nada.
### Búsqueda
`GET /api/public/v1/search?q=…` busca en lo que la web enseña —páginas,
proyectos y servicios publicados, convocatorias abiertas, miembros públicos y
tecnologías en uso— en el idioma de la petición, lo mejor primero, con el `path`
de cada resultado en la web.
En Postgres es **búsqueda de texto completo**: las palabras se reducen a su raíz
en el idioma que se busca («proyectos» encuentra «proyecto»; el catalán, sin
diccionario en un Postgres de serie, se compara palabra a palabra), el título
pesa más que el cuerpo y las etiquetas del texto enriquecido no cuentan como
palabras. En cualquier otra base —el SQLite de los tests— cae a comparar las
palabras tal cual: encuentra menos y ordena a grandes rasgos, pero responde con
la misma forma. Los tests cubren esa segunda rama; la de Postgres no tiene base
contra la que correr en la suite.
## Documentación navegable ## Documentación navegable
``` ```
@@ -1484,7 +1713,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_URL raíz de la aplicación (por defecto http://localhost:8000)
LANDING_USERNAME credenciales de Pireon para la API de administración; LANDING_USERNAME credenciales de Pireon para la API de administración;
LANDING_PASSWORD el token se pide y se refresca solo 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_PREVIEW_TOKEN opcional: las herramientas públicas ven borradores
LANDING_OPENAPI opcional: ruta o URL de la especificación LANDING_OPENAPI opcional: ruta o URL de la especificación
``` ```
@@ -1529,6 +1760,7 @@ php artisan media:process # deriva lo que falte de las imáge
php artisan media:process --all # reprocesa todas php artisan media:process --all # reprocesa todas
php artisan content:schedule # cierra convocatorias vencidas 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 --dry-run # qué se tiraría
php artisan content:prune --media # tira también imágenes huérfanas php artisan content:prune --media # tira también imágenes huérfanas

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Auth; namespace App\Auth;
use App\Actions\Auth\SyncPireonUser; use App\Actions\Auth\SyncPireonUser;
use App\Models\ApiToken;
use App\Models\User; use App\Models\User;
use App\Services\Pireon\PireonClient; use App\Services\Pireon\PireonClient;
use App\Services\Pireon\PireonException; use App\Services\Pireon\PireonException;
@@ -35,7 +36,16 @@ class PireonTokenGuard
{ {
$token = $request->bearerToken(); $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; return null;
} }
@@ -52,6 +62,21 @@ class PireonTokenGuard
return $user->is_active ? $user : null; 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 protected function resolveIdentity(string $token): ?PireonIdentity
{ {
$key = 'pireon:introspect:'.hash('sha256', $token); $key = 'pireon:introspect:'.hash('sha256', $token);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,13 +7,19 @@ namespace App\Filament\Resources\Applications\Pages;
use App\Filament\Resources\Applications\ApplicationResource; use App\Filament\Resources\Applications\ApplicationResource;
use App\Filament\Resources\Applications\Tables\ApplicationsTable; use App\Filament\Resources\Applications\Tables\ApplicationsTable;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationReply;
use App\Models\ReplyTemplate;
use App\Support\Inbox\Replier;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction; use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction; use Filament\Actions\RestoreAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Illuminate\Support\Facades\Auth;
class ViewApplication extends ViewRecord class ViewApplication extends ViewRecord
{ {
@@ -24,10 +30,67 @@ class ViewApplication extends ViewRecord
return [ return [
// Answering is the point of the record, so the reply is the first // Answering is the point of the record, so the reply is the first
// button rather than something to copy out of the page. // 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') Action::make('reply')
->label(__('panel.applications.actions.reply')) ->label(__('panel.applications.actions.reply'))
->icon('heroicon-o-envelope') ->icon('heroicon-o-envelope')
->color('primary') ->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=' ->url(fn (Application $record) => 'mailto:'.$record->email.'?subject='
.rawurlencode(__('panel.applications.reply_subject', [ .rawurlencode(__('panel.applications.reply_subject', [
'subject' => $record->subject(), 'subject' => $record->subject(),

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Filament\Resources\Applications\Schemas; namespace App\Filament\Resources\Applications\Schemas;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationReply;
use Filament\Infolists\Components\KeyValueEntry; use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
@@ -69,6 +71,36 @@ class ApplicationInfolist
->visible(fn (Application $record) => filled($record->links)), ->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')) Section::make(__('panel.applications.sections.internal'))
->description(__('panel.applications.sections.internal_hint')) ->description(__('panel.applications.sections.internal_hint'))
->columns(4) ->columns(4)

View File

@@ -10,6 +10,7 @@ use App\Filament\Resources\Calls\Pages\CreateCall;
use App\Filament\Resources\Calls\Pages\EditCall; use App\Filament\Resources\Calls\Pages\EditCall;
use App\Filament\Resources\Calls\Pages\ListCalls; use App\Filament\Resources\Calls\Pages\ListCalls;
use App\Filament\Resources\Calls\RelationManagers\ApplicationsRelationManager; use App\Filament\Resources\Calls\RelationManagers\ApplicationsRelationManager;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction; use App\Filament\Support\PreviewAction;
use App\Filament\Support\TranslatableTabs; use App\Filament\Support\TranslatableTabs;
use App\Filament\Support\TranslatedOptionLabel; use App\Filament\Support\TranslatedOptionLabel;
@@ -20,7 +21,6 @@ use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Actions\ReplicateAction;
use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
@@ -230,17 +230,9 @@ class CallResource extends Resource
->preload(), ->preload(),
]) ])
->recordActions([ ->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'), PreviewAction::for('call'),
DuplicateAction::make(),
EditAction::make(), EditAction::make(),
DeleteAction::make(), DeleteAction::make(),

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ use App\Filament\Resources\Pages\PageResource;
use App\Filament\Support\TranslatableTabs; use App\Filament\Support\TranslatableTabs;
use App\Filament\Support\TranslatedOptionLabel; use App\Filament\Support\TranslatedOptionLabel;
use App\Models\Page; use App\Models\Page;
use App\Models\Tag;
use App\Rules\UniqueTranslatedSlug; use App\Rules\UniqueTranslatedSlug;
use App\Support\DuplicateTitles; use App\Support\DuplicateTitles;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
@@ -126,6 +127,39 @@ class PageForm
->searchable() ->searchable()
->preload(), ->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') Select::make('author_id')
->label(__('panel.pages.fields.author')) ->label(__('panel.pages.fields.author'))
->relationship('author', 'name') ->relationship('author', 'name')

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\Pages\Tables; namespace App\Filament\Resources\Pages\Tables;
use App\Enums\PageStatus; use App\Enums\PageStatus;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction; use App\Filament\Support\PreviewAction;
use App\Filament\Support\ReviewActions; use App\Filament\Support\ReviewActions;
use App\Filament\Support\ReviewStatusColumn; use App\Filament\Support\ReviewStatusColumn;
@@ -20,7 +21,6 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction; use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\RestoreBulkAction; use Filament\Actions\RestoreBulkAction;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
@@ -211,17 +211,8 @@ class PagesTable
&& Auth::user()?->can('publish', $record)) && Auth::user()?->can('publish', $record))
->action(fn (Page $record) => $record->update(['status' => PageStatus::Draft])), ->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'), PreviewAction::for('page'),
DuplicateAction::make(),
EditAction::make(), EditAction::make(),
DeleteAction::make(), DeleteAction::make(),

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\Projects\Tables; namespace App\Filament\Resources\Projects\Tables;
use App\Enums\ProjectStatus; use App\Enums\ProjectStatus;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction; use App\Filament\Support\PreviewAction;
use App\Filament\Support\ReviewActions; use App\Filament\Support\ReviewActions;
use App\Filament\Support\ReviewStatusColumn; use App\Filament\Support\ReviewStatusColumn;
@@ -19,7 +20,6 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction; use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\RestoreBulkAction; use Filament\Actions\RestoreBulkAction;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
@@ -197,17 +197,8 @@ class ProjectsTable
&& Auth::user()?->can('publish', $record)) && Auth::user()?->can('publish', $record))
->action(fn (Project $record) => $record->update(['published_at' => now()])), ->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'), PreviewAction::for('project'),
DuplicateAction::make(),
EditAction::make(), EditAction::make(),
DeleteAction::make(), DeleteAction::make(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ use App\Filament\RelationManagers\RevisionsRelationManager;
use App\Filament\Resources\Services\Pages\CreateService; use App\Filament\Resources\Services\Pages\CreateService;
use App\Filament\Resources\Services\Pages\EditService; use App\Filament\Resources\Services\Pages\EditService;
use App\Filament\Resources\Services\Pages\ListServices; use App\Filament\Resources\Services\Pages\ListServices;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction; use App\Filament\Support\PreviewAction;
use App\Filament\Support\ReviewActions; use App\Filament\Support\ReviewActions;
use App\Filament\Support\ReviewStatusColumn; use App\Filament\Support\ReviewStatusColumn;
@@ -26,7 +27,6 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction; use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\RestoreBulkAction; use Filament\Actions\RestoreBulkAction;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
@@ -323,17 +323,8 @@ class ServiceResource extends Resource
ReviewActions::request(), ReviewActions::request(),
ReviewActions::returnForChanges(), 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'), PreviewAction::for('service'),
DuplicateAction::make(),
EditAction::make(), EditAction::make(),
DeleteAction::make(), DeleteAction::make(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,146 @@
<?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\Jobs\DeliverWebhook;
use App\Models\Webhook;
use App\Models\WebhookDelivery;
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 = $webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => 'ping',
'payload' => ['webhook' => $webhook->id, 'events' => $webhook->events],
]);
DeliverWebhook::dispatch($delivery);
return WebhookDeliveryResource::make($delivery->refresh())->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 = $delivery->webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => $delivery->event,
'payload' => $delivery->payload,
]);
DeliverWebhook::dispatch($again);
return WebhookDeliveryResource::make($again->refresh())->response()->setStatusCode(202);
}
}

View File

@@ -49,6 +49,10 @@ class PageRequest extends FormRequest
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)], 'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
'category_id' => ['sometimes', 'nullable', 'integer', Rule::exists('categories', 'id')->whereNull('deleted_at')], '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)], 'status' => ['sometimes', Rule::enum(PageStatus::class)],
'featured_image' => ['sometimes', 'nullable', 'string', 'max:2048'], 'featured_image' => ['sometimes', 'nullable', 'string', 'max:2048'],
'featured_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)], 'featured_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)],

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Models\Webhook;
use App\Support\Webhooks\PublicEndpoint;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
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.
function (string $attribute, mixed $value, Closure $fail): void {
[, $reason] = app(PublicEndpoint::class)->check((string) $value);
if ($reason !== null) {
$fail(__("rules.media.remote.{$reason}"));
}
},
],
'events' => [$sometimes, 'array', 'min:1'],
'events.*' => ['string', 'distinct', Rule::in(['*', ...Webhook::EVENTS])],
'secret' => [$creating ? 'sometimes' : 'prohibited', 'nullable', 'string', 'min:16', 'max:255'],
'rotate_secret' => [$creating ? 'prohibited' : 'sometimes', 'boolean'],
'include_personal_data' => ['sometimes', 'boolean'],
'is_active' => ['sometimes', 'boolean'],
];
}
}

View File

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

View File

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

View File

@@ -49,6 +49,8 @@ class PageResource extends JsonResource
'category' => CategoryResource::make($this->whenLoaded('category')), 'category' => CategoryResource::make($this->whenLoaded('category')),
'author' => UserResource::make($this->whenLoaded('author')), '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, // Opt-in: `?with=translations` returns every language at once,
// which is what a translation UI needs and a page renderer does not. // which is what a translation UI needs and a page renderer does not.

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Resources\PublicApi; namespace App\Http\Resources\PublicApi;
use App\Models\Page; use App\Models\Page;
use App\Models\Tag;
use App\Support\ImagePayload; use App\Support\ImagePayload;
use App\Support\SiteUrls; use App\Support\SiteUrls;
use App\Support\TranslatablePayload; use App\Support\TranslatablePayload;
@@ -58,6 +59,29 @@ class PublicPageResource extends JsonResource
'category' => PublicCategoryResource::make($this->whenLoaded('category')), '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 // Lets the frontend emit correct hreflang tags. Only locales that
// actually have a translation appear. // actually have a translation appear.
'alternates' => SiteUrls::alternates($this->resource, 'page'), 'alternates' => SiteUrls::alternates($this->resource, 'page'),

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Models\User; use App\Models\User;
use App\Support\Permissions;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -35,7 +36,10 @@ class UserResource extends JsonResource
'roles' => $this->whenLoaded('roles', fn () => $this->roles->pluck('name')->values()), 'roles' => $this->whenLoaded('roles', fn () => $this->roles->pluck('name')->values()),
'permissions' => $this->when( 'permissions' => $this->when(
$request->routeIs('api.v1.me'), $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 // Roles reported by the identity server. Informational only: they

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models\Concerns; namespace App\Models\Concerns;
use App\Models\Revision; use App\Models\Revision;
use App\Support\AdminContent;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -119,17 +120,34 @@ trait HasRevisions
*/ */
protected function revisionEvent(array $changes): string 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'; return 'updated';
} }
// Ask the model itself: "published" means different things to a page // Ask the model itself: "published" means different things to a page
// with a status and a project with only a date. // with a status and a project with only a date.
if (method_exists($this, 'isPublishedNow')) { if (! method_exists($this, 'isPublishedNow')) {
return $this->isPublishedNow() ? 'published' : 'unpublished'; 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 protected function revisionValue(mixed $value): mixed

View File

@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Translatable\HasTranslations; use Spatie\Translatable\HasTranslations;
@@ -26,7 +27,7 @@ use Spatie\Translatable\HasTranslations;
* @property array<string, string> $slug * @property array<string, string> $slug
*/ */
#[Fillable([ #[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', 'meta_title', 'meta_description', 'status', 'featured_image',
'featured_image_alt', 'is_featured', 'published_at', 'unpublish_at', 'featured_image_alt', 'is_featured', 'published_at', 'unpublish_at',
])] ])]
@@ -146,6 +147,25 @@ class Page extends Model
return $this->belongsTo(User::class, 'author_id'); 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. * Publicly visible pages: published and not scheduled for the future.
* *

View File

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

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

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

View File

@@ -42,6 +42,32 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasName
*/ */
protected string $guard_name = 'web'; 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. * Get the attributes that should be cast.
* *

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

@@ -0,0 +1,80 @@
<?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;
/**
* 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;
/**
* What can be subscribed to. `*` is all of them.
*/
public const EVENTS = [
'application.received',
'content.published',
'content.unpublished',
'review.requested',
'review.returned',
];
/**
* 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
{
$events = $this->events ?? [];
return in_array('*', $events, true) || in_array($event, $events, true);
}
/**
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,16 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Auth\PireonTokenGuard; use App\Auth\PireonTokenGuard;
use App\Models\Application;
use App\Models\ReviewComment;
use App\Models\Revision;
use App\Models\User;
use App\Services\Git\GitManager; use App\Services\Git\GitManager;
use App\Services\Pireon\PireonClient; use App\Services\Pireon\PireonClient;
use App\Services\Pireon\TokenStore; use App\Services\Pireon\TokenStore;
use App\Support\Audit\Auditor;
use App\Support\Webhooks\Webhooks;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Logout; use Illuminate\Auth\Events\Logout;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -18,6 +25,9 @@ use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Spatie\Permission\Events\RoleAttached;
use Spatie\Permission\Events\RoleDetached;
use Spatie\Permission\Models\Role;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -34,8 +44,45 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(GitManager::class); $this->app->singleton(GitManager::class);
} }
/**
* @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 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));
// 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. // Backs the "api" guard declared in config/auth.php.
Auth::viaRequest('pireon', fn (Request $request) => $this->app Auth::viaRequest('pireon', fn (Request $request) => $this->app
->make(PireonTokenGuard::class)($request)); ->make(PireonTokenGuard::class)($request));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

121
app/Support/Duplicator.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Enums\PageStatus;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/**
* A copy of a record to start the next one from: same text, same stack, same
* team, but a draft — off the site, out of any review, and with a title and
* an address that say it is a copy.
*/
final class Duplicator
{
/**
* @var array<class-string<Model>, array<int, string>>
*/
public const RELATIONS = [
Page::class => ['tags'],
Project::class => ['technologies', 'services', 'members'],
Service::class => ['technologies', 'projects'],
Call::class => ['technologies'],
];
/**
* Never copied: where it stands rather than what it says.
*/
protected const RESET = [
'published_at', 'unpublish_at', 'review_requested_at', 'review_requested_by', 'reviewer_id',
];
public static function supports(Model $record): bool
{
return array_key_exists($record::class, self::RELATIONS);
}
public static function duplicate(Model $record): Model
{
if (! self::supports($record)) {
throw new InvalidArgumentException($record::class.' cannot be duplicated.');
}
// Read again: a table row carries aggregates — view counts, summed
// stars — that are not columns and cannot be inserted.
$record = $record->fresh() ?? $record;
return DB::transaction(function () use ($record): Model {
$copy = $record->replicate(array_values(array_filter(
self::RESET,
fn (string $column) => array_key_exists($column, $record->getAttributes()),
)));
$titles = [];
$slugs = [];
foreach ((array) $record->getTranslations('title') as $locale => $title) {
if (filled($title)) {
$titles[$locale] = $title.' '.__('panel.duplicate.title_suffix', [], $locale);
}
}
foreach ((array) $record->getTranslations('slug') as $locale => $slug) {
if (filled($slug)) {
$slugs[$locale] = self::freeSlug($record, $locale, $slug.'-'.__('panel.duplicate.slug_suffix', [], $locale));
}
}
$copy->setTranslations('title', $titles);
$copy->setTranslations('slug', $slugs);
match (true) {
$copy instanceof Page => $copy->forceFill(['status' => PageStatus::Draft, 'author_id' => Auth::id() ?? $record->author_id]),
$copy instanceof Service => $copy->forceFill(['is_published' => false]),
default => null,
};
$copy->save();
foreach (self::RELATIONS[$record::class] as $relation) {
$related = $record->{$relation}()->get();
$copy->{$relation}()->sync(
$relation === 'members'
? $related->mapWithKeys(fn ($member) => [$member->id => ['role' => $member->pivot->role]])->all()
: $related->pluck('id')->all(),
);
}
return $copy;
});
}
/**
* The slug, or the slug with a number after it, whichever nobody has.
*/
protected static function freeSlug(Model $record, string $locale, string $slug): string
{
$candidate = $slug;
$n = 2;
$query = fn (string $value) => $record::query()
->when(method_exists($record, 'bootSoftDeletes'), fn ($q) => $q->withTrashed())
->where("slug->{$locale}", $value)
->exists();
while ($query($candidate)) {
$candidate = "{$slug}-{$n}";
$n++;
}
return $candidate;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Enums\PageStatus;
use App\Filament\Resources\Calls\CallResource;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\User;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Everything with a date that changes what the site shows: what went or goes
* live, what comes off, which calls open and close.
*/
final class EditorialCalendar
{
/**
* @return Collection<int, array{date: string, type: string, event: string, title: string, url: string, at: CarbonInterface}>
*/
public static function between(CarbonImmutable $from, CarbonImmutable $to, User $user): Collection
{
$events = collect();
$add = function (string $type, Model $record, string $event, ?CarbonInterface $at) use ($events): void {
if ($at === null) {
return;
}
$events->push([
'date' => $at->toDateString(),
'type' => $type,
'event' => $event,
'title' => ContentTypes::title($record),
'url' => $record instanceof Call ? CallResource::getUrl('edit', ['record' => $record]) : ReviewTarget::url($record),
'at' => $at,
]);
};
$inRange = fn ($query, string $column) => $query->whereBetween($column, [$from->startOfDay(), $to->endOfDay()]);
$published = [
'pages' => [Page::class, fn ($q) => $q->where('status', PageStatus::Published)],
'projects' => [Project::class, fn ($q) => $q],
'services' => [Service::class, fn ($q) => $q->where('is_published', true)],
];
foreach ($published as $type => [$model, $live]) {
if ($user->cannot('viewAny', $model)) {
continue;
}
$live($inRange($model::query(), 'published_at'))->get()
->each(fn (Model $r) => $add($type, $r, $r->published_at->isFuture() ? 'scheduled' : 'published', $r->published_at));
$live($inRange($model::query(), 'unpublish_at'))->get()
->each(fn (Model $r) => $add($type, $r, 'unpublishes', $r->unpublish_at));
}
if ($user->can('viewAny', Call::class)) {
$inRange(Call::query(), 'opens_at')->get()->each(fn (Call $c) => $add('calls', $c, 'opens', $c->opens_at));
$inRange(Call::query(), 'closes_at')->get()->each(fn (Call $c) => $add('calls', $c, 'closes', $c->closes_at));
}
return $events->sortBy(fn (array $e) => $e['at']->getTimestamp())->values();
}
}

83
app/Support/Expiring.php Normal file
View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Enums\CallStatus;
use App\Filament\Resources\Calls\CallResource;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* What is about to stop being true on the site: calls about to close, and
* published content about to come off by itself.
*
* Neither is a mistake — both were set on purpose — which is why the health
* check does not flag them. But a call that closes on Friday with nobody
* having replied, or a page that vanishes on the first of the month, is better
* known about a week ahead than found out after.
*/
final class Expiring
{
/**
* @return Collection<int, array{type: string, record: Model, title: string, event: string, at: CarbonInterface, url: string}>
*/
public static function within(int $days): Collection
{
$until = now()->addDays($days)->endOfDay();
$items = collect();
Call::query()
->where('status', CallStatus::Open)
->whereNotNull('closes_at')
->whereBetween('closes_at', [now()->startOfDay(), $until])
->get()
->each(fn (Call $call) => $items->push(self::item('calls', $call, 'closes', $call->closes_at)));
foreach (['pages' => Page::class, 'projects' => Project::class, 'services' => Service::class] as $type => $model) {
$model::query()
->published()
->whereNotNull('unpublish_at')
->whereBetween('unpublish_at', [now(), $until])
->get()
->each(fn (Model $record) => $items->push(self::item($type, $record, 'unpublishes', $record->unpublish_at)));
}
return $items->sortBy(fn (array $item) => $item['at']->getTimestamp())->values();
}
/**
* Only those this person may do something about.
*
* @param Collection<int, array<string, mixed>> $items
* @return Collection<int, array<string, mixed>>
*/
public static function for(Collection $items, User $user): Collection
{
return $items->filter(fn (array $item) => $user->can('update', $item['record']))->values();
}
/**
* @return array{type: string, record: Model, title: string, event: string, at: CarbonInterface, url: string}
*/
protected static function item(string $type, Model $record, string $event, CarbonInterface $at): array
{
return [
'type' => $type,
'record' => $record,
'title' => ContentTypes::title($record),
'event' => $event,
'at' => $at,
'url' => $record instanceof Call
? CallResource::getUrl('edit', ['record' => $record])
: ReviewTarget::url($record),
];
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Support\Inbox;
use App\Enums\ApplicationStatus;
use App\Mail\ApplicationReplyMail;
use App\Models\Application;
use App\Models\ApplicationReply;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
/**
* Answering a submission: sending it when this application can send mail,
* handing it to the person's own mail program when it cannot, and keeping a
* record either way.
*
* "Can send" is a mailer that actually delivers. `log` and `array` accept a
* message and drop it, so with either of them a reply "sent from here" would
* be one nobody received.
*/
final class Replier
{
public static function canSendMail(): bool
{
return ! in_array(config('mail.default'), ['log', 'array', null], true);
}
public static function reply(Application $application, string $subject, string $body, User $by): ApplicationReply
{
$send = self::canSendMail();
$reply = $application->replies()->create([
'user_id' => $by->id,
'user_name' => $by->name,
'subject' => $subject,
'body' => $body,
'channel' => $send ? ApplicationReply::MAIL : ApplicationReply::MAILTO,
]);
if ($send) {
Mail::to($application->email, $application->name)->send(new ApplicationReplyMail($reply));
}
// Answered is at least contacted; a decision already taken stays.
if ($application->status === ApplicationStatus::New) {
$application->markAs(ApplicationStatus::Contacted, $by);
}
return $reply;
}
/**
* The same answer, for the person's own mail program to send.
*/
public static function mailto(Application $application, string $subject, string $body): string
{
return 'mailto:'.$application->email.'?subject='.rawurlencode($subject).'&body='.rawurlencode($body);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Records with a language still to write (`missing`) or left behind by the
* one it was translated from (`stale`, with since when) — for the panel's
* translation assistant and the API alike.
*/
final class PendingTranslations
{
/**
* @var array<string, class-string<Model>>
*/
public const TYPES = [
'pages' => Page::class,
'projects' => Project::class,
'services' => Service::class,
'calls' => Call::class,
];
/**
* @return Collection<int, array{type: string, id: int|string, record: Model, title: string, missing: array<int, string>, stale: array<string, CarbonInterface>}>
*/
public static function find(User $user, ?string $locale = null, ?string $type = null, bool $publishedOnly = false): Collection
{
$locales = array_keys((array) config('locales.supported'));
$source = (string) config('locales.default');
$items = collect();
foreach (self::TYPES as $name => $model) {
if (($type !== null && $type !== $name) || $user->cannot('viewAny', $model)) {
continue;
}
$model::query()
->when($publishedOnly, fn ($q) => $model === Call::class ? $q->open() : $q->published())
->with('revisions')
->get()
->each(function (Model $record) use ($locales, $source, $locale, $name, $items): void {
$missing = array_values(array_filter(
$locales,
fn (string $l) => $l !== $source && blank($record->getTranslation('title', $l, false)),
));
$stale = TranslationFreshness::for($record);
if ($locale !== null) {
$missing = array_values(array_intersect($missing, [$locale]));
$stale = array_intersect_key($stale, [$locale => true]);
}
if ($missing === [] && $stale === []) {
return;
}
$items->push([
'type' => $name,
'id' => $record->getKey(),
'record' => $record,
'title' => ContentTypes::title($record),
'missing' => $missing,
'stale' => $stale,
]);
});
}
return $items->values();
}
}

View File

@@ -73,6 +73,13 @@ final class Permissions
// the provider's rate limit, which is a shared resource. // the provider's rate limit, which is a shared resource.
'repositories' => ['view_any', 'view', 'create', 'update', 'delete', 'sync'], 'repositories' => ['view_any', 'view', 'create', 'update', 'delete', 'sync'],
// Where the site tells other systems what happened. A webhook sends
// data off this server, so it is an administrator's decision.
'webhooks' => ['view_any', 'view', 'create', 'update', 'delete'],
// Who did what, from where. It names people and their addresses.
'audit' => ['view_any'],
'users' => ['view_any', 'view', 'create', 'update', 'delete'], 'users' => ['view_any', 'view', 'create', 'update', 'delete'],
'roles' => ['view_any', 'view', 'create', 'update', 'delete'], 'roles' => ['view_any', 'view', 'create', 'update', 'delete'],

53
app/Support/Publisher.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Enums\PageStatus;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
/**
* Putting a record on the site, or taking it off, whichever fields that means
* for its kind: a page has a status, a project only a date, a service a flag
* and a date.
*/
final class Publisher
{
/**
* Live now, or from `$at` when it is in the future. Whatever review it was
* waiting for is answered either way: scheduling is the decision.
*/
public static function publish(Model $record, ?CarbonInterface $at = null): void
{
$at ??= now();
$record->forceFill(match (true) {
$record instanceof Page => ['status' => PageStatus::Published, 'published_at' => $at],
$record instanceof Project => ['published_at' => $at],
$record instanceof Service => ['is_published' => true, 'published_at' => $at],
default => throw new InvalidArgumentException($record::class.' is not published this way.'),
})->save();
// Publishing now closes the review by itself (HasReviews); a date in
// the future does not, so it is closed here.
if ($record->isAwaitingReview()) {
$record->closeReview();
}
}
public static function unpublish(Model $record): void
{
$record->forceFill(match (true) {
$record instanceof Page => ['status' => PageStatus::Draft],
$record instanceof Project => ['published_at' => null],
$record instanceof Service => ['is_published' => false],
default => throw new InvalidArgumentException($record::class.' is not published this way.'),
})->save();
}
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Support\Search;
use App\Models\Call;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\Technology;
use App\Support\SiteUrls;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Searching what the public site shows, in one language.
*
* On Postgres it is full-text search: words are stemmed in the language being
* searched, so "proyectos" finds "proyecto", and results are ranked with the
* title weighing more than the body. Anywhere else — SQLite in the test suite,
* a laptop without Postgres — it falls back to matching the words as written,
* which finds less and ranks crudely but answers the same shape.
*
* Only what is published: a search is a way of reaching the site's pages, and
* a draft has none.
*/
final class SiteSearch
{
/**
* Postgres text search configuration per language. Catalan has no stemmer
* in a stock Postgres, so it is matched word for word.
*
* @var array<string, string>
*/
protected const DICTIONARIES = ['es' => 'spanish', 'en' => 'english'];
/**
* Type => what is searched in it: the title field, the fields with
* weight B, and whether those fields are translatable.
*
* @var array<string, array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>}>
*/
protected const SOURCES = [
'page' => ['model' => Page::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['excerpt', 'body']],
'project' => ['model' => Project::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['tagline', 'description']],
'service' => ['model' => Service::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['tagline', 'description']],
'call' => ['model' => Call::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['description']],
'member' => ['model' => Member::class, 'title' => 'name', 'translatedTitle' => false, 'text' => ['headline', 'bio']],
'technology' => ['model' => Technology::class, 'title' => 'name', 'translatedTitle' => false, 'text' => ['description']],
];
public const TYPES = ['page', 'project', 'service', 'call', 'member', 'technology'];
/**
* @param array<int, string> $types
* @return Collection<int, array<string, mixed>>
*/
public function search(string $query, string $locale, array $types = self::TYPES, int $limit = 20): Collection
{
$query = trim($query);
if (mb_strlen($query) < 2) {
return collect();
}
$postgres = DB::connection()->getDriverName() === 'pgsql';
return collect(self::SOURCES)
->only($types)
->flatMap(fn (array $source, string $type) => $this->searchIn($type, $source, $query, $locale, $postgres, $limit))
->sortByDesc('score')
->take($limit)
->values();
}
/**
* @param array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>} $source
* @return Collection<int, array<string, mixed>>
*/
protected function searchIn(string $type, array $source, string $query, string $locale, bool $postgres, int $limit): Collection
{
$builder = $this->visible($source['model']::query(), $source['model']);
$title = $source['translatedTitle'] ? $this->json($source['title'], $locale, $postgres) : $source['title'];
$text = collect($source['text'])->map(fn (string $field) => $this->json($field, $locale, $postgres));
if ($postgres) {
$dictionary = self::DICTIONARIES[$locale] ?? 'simple';
// Tags out of rich text, so `<strong>` is not a word anyone finds.
$body = $text->map(fn (string $sql) => "regexp_replace(coalesce({$sql}, ''), '<[^>]+>', ' ', 'g')")->implode(" || ' ' || ");
$document = "setweight(to_tsvector('{$dictionary}', coalesce({$title}, '')), 'A') || setweight(to_tsvector('{$dictionary}', {$body}), 'B')";
$tsquery = "websearch_to_tsquery('{$dictionary}', ?)";
$builder
->select('*')
->selectRaw("ts_rank({$document}, {$tsquery}) as search_score", [$query])
->whereRaw("{$document} @@ {$tsquery}", [$query])
->orderByDesc('search_score');
} else {
$words = collect(preg_split('/\s+/', $query))->filter()->map(fn ($w) => '%'.mb_strtolower($w).'%');
foreach ($words as $word) {
$builder->where(function (Builder $q) use ($title, $text, $word): void {
$q->whereRaw("lower({$title}) like ?", [$word]);
foreach ($text as $sql) {
$q->orWhereRaw("lower({$sql}) like ?", [$word]);
}
});
}
}
return $builder->limit($limit)->get()->map(fn (Model $record) => $this->result($type, $source, $record, $query, $locale));
}
/**
* @param array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>} $source
* @return array<string, mixed>
*/
protected function result(string $type, array $source, Model $record, string $query, string $locale): array
{
$title = $source['translatedTitle']
? (string) $record->getTranslation($source['title'], $locale, false)
: (string) $record->getAttribute($source['title']);
$text = collect($source['text'])
->map(fn (string $field) => (string) $record->getTranslation($field, $locale, false))
->implode(' ');
// Crude where Postgres did not rank: a title match counts more.
$score = $record->getAttribute('search_score')
?? (Str::contains(mb_strtolower($title), mb_strtolower($query)) ? 2.0 : 1.0);
return [
'type' => $type,
'id' => $record->getKey(),
'title' => $title,
'excerpt' => $this->excerpt($text, $query),
'path' => $this->path($type, $record, $locale),
'score' => round((float) $score, 4),
];
}
/**
* Published, open or public, whichever each kind calls it.
*
* @param class-string<Model> $model
*/
protected function visible(Builder $query, string $model): Builder
{
return match ($model) {
Call::class => $query->open(),
Member::class => $query->public(),
// A technology is shown on the site once something uses it.
Technology::class => $query->whereHas('projects', fn (Builder $q) => $q->published()),
default => $query->published(),
};
}
/**
* The text in one language of a translatable JSON column.
*/
protected function json(string $field, string $locale, bool $postgres): string
{
return $postgres
? "({$field}->>'{$locale}')"
: "json_extract({$field}, '$.{$locale}')";
}
/**
* Some words either side of the first match, out of plain text.
*/
protected function excerpt(string $html, string $query): string
{
$text = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($html))));
$first = collect(preg_split('/\s+/', $query))->filter()->first() ?? $query;
$at = mb_stripos($text, (string) $first);
if ($at === false || $at < 60) {
return Str::limit($text, 180);
}
return '…'.Str::limit(mb_substr($text, $at - 60), 180);
}
protected function path(string $type, Model $record, string $locale): ?string
{
return match ($type) {
'member' => SiteUrls::path('member', $locale, (string) $record->slug),
// The site gives technologies pages, but this side has no address
// for them: the slug is enough to build one.
'technology' => null,
default => SiteUrls::alternates($record, $type)[$locale] ?? null,
};
}
}

99
app/Support/TextDiff.php Normal file
View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* What changed between two versions of a text, word by word, as HTML with
* `<del>` and `<ins>` — enough to show a translator which sentence of the
* original to look at again.
*/
final class TextDiff
{
/**
* Above this many words on either side the comparison is skipped: it is
* quadratic, and a text that long changed is better reread whole.
*/
protected const MAX_WORDS = 1500;
public static function html(?string $old, ?string $new): ?string
{
$a = self::words($old);
$b = self::words($new);
if (count($a) > self::MAX_WORDS || count($b) > self::MAX_WORDS) {
return null;
}
// Longest common subsequence, then walked back into runs.
$m = count($a);
$n = count($b);
$lcs = array_fill(0, $m + 1, array_fill(0, $n + 1, 0));
for ($i = $m - 1; $i >= 0; $i--) {
for ($j = $n - 1; $j >= 0; $j--) {
$lcs[$i][$j] = $a[$i] === $b[$j] ? $lcs[$i + 1][$j + 1] + 1 : max($lcs[$i + 1][$j], $lcs[$i][$j + 1]);
}
}
$out = [];
$i = $j = 0;
while ($i < $m || $j < $n) {
if ($i < $m && $j < $n && $a[$i] === $b[$j]) {
$out[] = ['=', $a[$i]];
$i++;
$j++;
} elseif ($i < $m && ($j === $n || $lcs[$i + 1][$j] >= $lcs[$i][$j + 1])) {
// What went comes before what replaced it.
$out[] = ['-', $a[$i]];
$i++;
} else {
$out[] = ['+', $b[$j]];
$j++;
}
}
$html = '';
$run = null;
$buffer = [];
$flush = function () use (&$html, &$run, &$buffer): void {
if ($buffer === []) {
return;
}
$text = e(implode(' ', $buffer));
$html .= match ($run) {
'+' => "<ins>{$text}</ins> ",
'-' => "<del>{$text}</del> ",
default => "{$text} ",
};
$buffer = [];
};
foreach ($out as [$op, $word]) {
if ($op !== $run) {
$flush();
$run = $op;
}
$buffer[] = $word;
}
$flush();
return trim($html);
}
/**
* @return array<int, string>
*/
protected static function words(?string $text): array
{
$plain = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags(str_replace(['</p>', '<br>', '<br/>', '<br />'], ' ', (string) $text)))));
return $plain === '' ? [] : explode(' ', $plain);
}
}

View File

@@ -71,6 +71,69 @@ final class TranslationFreshness
/** /**
* @return array<string, CarbonInterface> * @return array<string, CarbonInterface>
*/ */
/**
* Field by field, for one language: which fields the original changed
* after this language was last written, since when, and what the
* original said back then — so a translator can see what to redo rather
* than rereading the whole text.
*
* @return array<string, array{since: CarbonInterface, was: ?string}>
*/
public static function staleFields(Model $record, string $locale): array
{
$source = (string) config('locales.default');
$fields = self::fields($record);
$touched = self::lastTouched($record, $fields);
$stale = [];
foreach ($fields as $field) {
if ($locale === $source || blank($record->getTranslation($field, $locale, false))) {
continue;
}
$sourceAt = $touched[$field][$source] ?? null;
$translatedAt = $touched[$field][$locale] ?? $record->created_at;
if ($sourceAt !== null && $translatedAt !== null && $sourceAt->gt($translatedAt)) {
$stale[$field] = [
'since' => $sourceAt,
'was' => self::sourceAsOf($record, $field, $translatedAt),
];
}
}
return $stale;
}
/**
* The original's text in a field as it stood at a moment, from the
* history: the last value written at or before it, or the value the first
* later change replaced.
*/
protected static function sourceAsOf(Model $record, string $field, CarbonInterface $at): ?string
{
$source = (string) config('locales.default');
$value = null;
$found = false;
foreach ($record->revisions()->reorder()->orderBy('id')->get() as $revision) {
$change = $revision->changeset[$field] ?? null;
if (! is_array($change)) {
continue;
}
if ($revision->created_at->lte($at)) {
$value = ((array) ($change['new'] ?? []))[$source] ?? null;
$found = true;
} elseif (! $found) {
return ((array) ($change['old'] ?? []))[$source] ?? null;
}
}
return $found ? $value : $record->getTranslation($field, $source, false);
}
protected static function compute(Model $record): array protected static function compute(Model $record): array
{ {
$source = (string) config('locales.default'); $source = (string) config('locales.default');

192
app/Support/ViewStats.php Normal file
View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Filament\Resources\Calls\CallResource;
use App\Filament\Resources\Members\MemberResource;
use App\Models\Call;
use App\Models\ContentView;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* What was read, from the daily per-language totals the frontend reports.
*
* Every figure is compared with the period just before it of the same length:
* "320 reads" says little, "320, up from 210" says whether something worked.
*/
final class ViewStats
{
/**
* @var array<string, class-string<Model>>
*/
protected const TYPES = [
'pages' => Page::class,
'projects' => Project::class,
'services' => Service::class,
'calls' => Call::class,
'members' => Member::class,
];
public function __construct(public readonly int $days) {}
public function from(): CarbonImmutable
{
return CarbonImmutable::today()->subDays($this->days - 1);
}
public function previousFrom(): CarbonImmutable
{
return $this->from()->subDays($this->days);
}
/**
* @return Collection<int, array{type: string, title: string, url: ?string, views: int, previous: int}>
*/
public function top(int $limit = 10): Collection
{
$current = ContentView::query()
->where('day', '>=', $this->from()->toDateString())
->selectRaw('viewable_type, viewable_id, SUM(count) as total')
->groupBy('viewable_type', 'viewable_id')
->orderByDesc('total')
->limit($limit)
->get();
$previous = ContentView::query()
->whereBetween('day', [$this->previousFrom()->toDateString(), $this->from()->subDay()->toDateString()])
->where(function ($q) use ($current): void {
foreach ($current as $row) {
$q->orWhere(fn ($q) => $q->where('viewable_type', $row->viewable_type)->where('viewable_id', $row->viewable_id));
}
})
->selectRaw('viewable_type, viewable_id, SUM(count) as total')
->groupBy('viewable_type', 'viewable_id')
->get()
->keyBy(fn ($row) => $row->viewable_type.':'.$row->viewable_id);
return $current
->map(function ($row) use ($previous): ?array {
$record = $row->viewable;
if (! $record instanceof Model) {
return null;
}
return [
'type' => array_search($record::class, self::TYPES, true) ?: class_basename($record),
'title' => $record instanceof Member ? $record->name : ContentTypes::title($record),
'url' => self::url($record),
'views' => (int) $row->total,
'previous' => (int) ($previous[$row->viewable_type.':'.$row->viewable_id]->total ?? 0),
];
})
->filter()
->values();
}
/**
* Reads per language, now and in the period before.
*
* @return Collection<string, array{views: int, previous: int, share: float}>
*/
public function byLocale(): Collection
{
$sum = fn (string $from, string $to) => ContentView::query()
->whereBetween('day', [$from, $to])
->selectRaw('locale, SUM(count) as total')
->groupBy('locale')
->pluck('total', 'locale');
$now = $sum($this->from()->toDateString(), CarbonImmutable::today()->toDateString());
$before = $sum($this->previousFrom()->toDateString(), $this->from()->subDay()->toDateString());
$total = max(1, (int) $now->sum());
return collect(array_keys((array) config('locales.supported')))
->mapWithKeys(fn (string $locale) => [$locale => [
'views' => (int) ($now[$locale] ?? 0),
'previous' => (int) ($before[$locale] ?? 0),
'share' => round(((int) ($now[$locale] ?? 0)) * 100 / $total, 1),
]]);
}
/**
* Day by day, per language, for the chart.
*
* @return array{labels: array<int, string>, series: array<string, array<int, int>>}
*/
public function daily(): array
{
$rows = ContentView::query()
->where('day', '>=', $this->from()->toDateString())
->selectRaw('day, locale, SUM(count) as total')
->groupBy('day', 'locale')
->get()
->groupBy(fn ($row) => CarbonImmutable::parse($row->day)->toDateString());
$labels = [];
$series = array_fill_keys(array_keys((array) config('locales.supported')), []);
for ($day = $this->from(); $day->lte(CarbonImmutable::today()); $day = $day->addDay()) {
$labels[] = $day->isoFormat('D MMM');
$today = ($rows[$day->toDateString()] ?? collect())->pluck('total', 'locale');
foreach (array_keys($series) as $locale) {
$series[$locale][] = (int) ($today[$locale] ?? 0);
}
}
return ['labels' => $labels, 'series' => $series];
}
/**
* On the site and read by nobody in the period: the content that is
* either hard to find or not worth finding.
*
* @return Collection<int, array{type: string, title: string, url: ?string}>
*/
public function unread(int $limit = 50): Collection
{
$items = collect();
foreach (self::TYPES as $type => $model) {
$read = ContentView::query()
->where('viewable_type', (new $model)->getMorphClass())
->where('day', '>=', $this->from()->toDateString())
->distinct()
->pluck('viewable_id');
$model::query()
->when($model === Call::class, fn ($q) => $q->open())
->when($model === Member::class, fn ($q) => $q->public())
->unless(in_array($model, [Call::class, Member::class], true), fn ($q) => $q->published())
->whereNotIn((new $model)->getKeyName(), $read)
->limit($limit)
->get()
->each(fn (Model $record) => $items->push([
'type' => $type,
'title' => $record instanceof Member ? $record->name : ContentTypes::title($record),
'url' => self::url($record),
]));
}
return $items->take($limit)->values();
}
protected static function url(Model $record): ?string
{
return match (true) {
$record instanceof Call => CallResource::getUrl('edit', ['record' => $record]),
$record instanceof Member => MemberResource::getUrl('edit', ['record' => $record]),
$record instanceof Page, $record instanceof Project, $record instanceof Service => ReviewTarget::url($record),
default => null,
};
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Support\Webhooks;
use App\Services\Media\HostResolver;
/**
* Whether a URL is somewhere this server may send a request to: HTTPS, and a
* host whose every address is public.
*
* The same rule the image downloader follows, for the same reason — the
* request leaves from inside the stack, and without it a webhook pointed at
* `http://postgres:5432` or the cloud's metadata service would be a way of
* reaching them.
*/
final class PublicEndpoint
{
public function __construct(protected HostResolver $resolver) {}
/**
* The address to connect to, or why not: `https_only`, `unresolvable` or
* `private`.
*
* @return array{0: ?string, 1: ?string} [address, reason]
*/
public function check(string $url): array
{
$parts = parse_url($url);
$host = $parts['host'] ?? null;
if (($parts['scheme'] ?? null) !== 'https' || blank($host)) {
return [null, 'https_only'];
}
$host = trim((string) $host, '[]');
$addresses = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : $this->resolver->resolve($host);
if ($addresses === []) {
return [null, 'unresolvable'];
}
foreach ($addresses as $address) {
if (! filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)) {
return [null, 'private'];
}
}
return [$addresses[0], null];
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Support\Webhooks;
use App\Jobs\DeliverWebhook;
use App\Models\Application;
use App\Models\ReviewComment;
use App\Models\Revision;
use App\Models\Webhook;
use App\Support\AdminContent;
use App\Support\ContentTypes;
use App\Support\SiteUrls;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Str;
/**
* Turning what happens here into deliveries for whoever listens.
*
* Fed by model events rather than by the controllers, so a page published from
* the panel, the API or a batch is announced the same way.
*/
final class Webhooks
{
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $personal Sent only to webhooks that opted into personal data.
*/
public static function dispatch(string $event, array $data, array $personal = []): void
{
$webhooks = Webhook::query()->active()->get()->filter(fn (Webhook $w) => $w->listensTo($event));
foreach ($webhooks as $webhook) {
$delivery = $webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => $event,
'payload' => $webhook->include_personal_data ? [...$data, ...$personal] : $data,
]);
DeliverWebhook::dispatch($delivery)->afterCommit();
}
}
public static function applicationReceived(Application $application): void
{
self::dispatch('application.received', [
'id' => $application->id,
'kind' => $application->kind->value,
'call_id' => $application->call_id,
'subject' => $application->subject(),
'locale' => $application->locale,
'created_at' => $application->created_at?->toIso8601String(),
], [
'name' => $application->name,
'email' => $application->email,
'organisation' => $application->organisation,
'message' => $application->message,
]);
}
/**
* Publishing and unpublishing are already told apart by the history, for
* every kind of record and from wherever it happened; this listens there.
*/
public static function revisionRecorded(Revision $revision): void
{
$model = Relation::getMorphedModel($revision->revisionable_type) ?? $revision->revisionable_type;
$record = $model::query()->find($revision->revisionable_id);
if ($record === null) {
return;
}
$event = match (true) {
$revision->event === 'published' => 'content.published',
$revision->event === 'unpublished' => 'content.unpublished',
$revision->event === 'created' && method_exists($record, 'isPublishedNow') && $record->isPublishedNow() => 'content.published',
default => null,
};
if ($event !== null) {
self::dispatch($event, self::describe($record));
}
}
public static function reviewRecorded(ReviewComment $comment): void
{
$event = match ($comment->event->value) {
'requested' => 'review.requested',
'returned' => 'review.returned',
default => null,
};
$record = $comment->reviewable;
if ($event === null || $record === null) {
return;
}
self::dispatch($event, [
...self::describe($record),
'by' => $comment->user_name,
'note' => $comment->body,
]);
}
/**
* @return array<string, mixed>
*/
protected static function describe(Model $record): array
{
$type = AdminContent::nameOf($record);
$siteType = AdminContent::PREVIEWABLE[$type] ?? null;
$locale = (string) config('locales.default');
return [
'type' => $type,
'id' => $record->getKey(),
'title' => ContentTypes::title($record),
'url' => $siteType && method_exists($record, 'getTranslation')
? (SiteUrls::absoluteAlternates($record, $siteType)[$locale] ?? null)
: null,
];
}
}

20
config/audit.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Retention
|--------------------------------------------------------------------------
|
| Days an entry is kept. It names people and the addresses they worked
| from, so it is not kept forever; `php artisan model:prune` removes what
| is older, and the schedule runs it daily.
|
*/
'retention_days' => (int) env('AUDIT_RETENTION_DAYS', 365),
];

View File

@@ -115,4 +115,11 @@ return [
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
], ],
// Where answers to the inbox's replies go back to. Unset, they go to the
// "from" address.
'reply_to' => [
'address' => env('MAIL_REPLY_TO_ADDRESS'),
'name' => env('MAIL_REPLY_TO_NAME', env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel'))),
],
]; ];

View File

@@ -105,7 +105,10 @@ return [
* Set this to false if you want to implement custom logic for checking permissions. * Set this to false if you want to implement custom logic for checking permissions.
*/ */
'register_permission_check_method' => true, // Off: App\Providers\AuthServiceProvider registers the one check, so a
// machine token's narrower abilities are asked before any permission is
// granted rather than after.
'register_permission_check_method' => false,
/* /*
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
@@ -123,7 +126,8 @@ return [
* *
* To enable, set to true, and then create listeners to watch these events. * To enable, set to true, and then create listeners to watch these events.
*/ */
'events_enabled' => false, // On: role changes are written to the audit log (AppServiceProvider).
'events_enabled' => true,
/* /*
* Teams Feature. * Teams Feature.

View File

@@ -165,4 +165,17 @@ return [
// the notification bell is always on, and mail needs a working mailer. // the notification bell is always on, and mail needs a working mailer.
'notify_by_mail' => (bool) env('SITE_SUBMISSION_MAIL', false), 'notify_by_mail' => (bool) env('SITE_SUBMISSION_MAIL', false),
], ],
/*
|--------------------------------------------------------------------------
| Expiry reminders
|--------------------------------------------------------------------------
|
| Days ahead of a call closing, or of published content coming off the
| site, on which `content:expiring` tells the people who edit it.
|
*/
'expiry_reminders' => [7, 1],
]; ];

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ReplyTemplate;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ReplyTemplate>
*/
class ReplyTemplateFactory extends Factory
{
public function definition(): array
{
return [
'name' => $this->faker->words(3, true),
'kind' => null,
'subject' => ['es' => 'Gracias, {name}', 'en' => 'Thank you, {name}', 'ca' => 'Gràcies, {name}'],
'body' => [
'es' => "Hola {name}:\n\nGracias por escribirnos sobre «{subject}».\n\n{sender}",
'en' => "Hi {name},\n\nThank you for writing to us about \"{subject}\".\n\n{sender}",
'ca' => "Hola {name}:\n\nGràcies per escriure'ns sobre «{subject}».\n\n{sender}",
],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Tag;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Tag>
*/
class TagFactory extends Factory
{
public function definition(): array
{
$name = $this->faker->unique()->word();
return [
'name' => ['es' => $name, 'en' => $name, 'ca' => $name],
'slug' => ['es' => Str::slug($name), 'en' => Str::slug($name), 'ca' => Str::slug($name)],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Webhook;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Webhook>
*/
class WebhookFactory extends Factory
{
public function definition(): array
{
return [
'name' => $this->faker->words(2, true),
'url' => 'https://hooks.example.org/'.Str::random(8),
'secret' => Str::random(40),
'events' => ['*'],
'include_personal_data' => false,
'is_active' => true,
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Access tokens for machines: scripts, CI, an integration — anything that
* should reach the admin API without a person's password and with less than
* that person may do.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('api_tokens', function (Blueprint $table): void {
$table->id();
// Whose permissions it borrows from. Deleting the person takes
// their tokens with them.
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
// Only the hash is kept: the token is shown once, when it is made.
$table->string('token_hash', 64)->unique();
// The first characters, to tell tokens apart in a list.
$table->string('prefix', 12);
$table->jsonb('abilities');
$table->timestamp('expires_at');
$table->timestamp('last_used_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('api_tokens');
}
};

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Outgoing webhooks: telling another system that something happened here —
* a submission arrived, a page went live — instead of it having to ask.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('webhooks', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('url', 2048);
// Signs every delivery. Encrypted at rest: it is a credential.
$table->text('secret');
$table->jsonb('events');
// Submissions carry names, addresses and messages from people
// outside the collective; sending them elsewhere is opted into.
$table->boolean('include_personal_data')->default(false);
$table->boolean('is_active')->default(true);
$table->unsignedSmallInteger('consecutive_failures')->default(0);
$table->timestamp('last_delivered_at')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
Schema::create('webhook_deliveries', function (Blueprint $table): void {
$table->id();
$table->foreignId('webhook_id')->constrained()->cascadeOnDelete();
$table->uuid('uuid')->unique();
$table->string('event', 60);
$table->jsonb('payload');
$table->string('status', 20)->default('pending');
$table->unsignedSmallInteger('attempts')->default(0);
$table->unsignedSmallInteger('response_status')->nullable();
$table->text('error')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
$table->index(['webhook_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('webhook_deliveries');
Schema::dropIfExists('webhooks');
}
};

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