Compare commits
22 Commits
main
...
4c4a2b3299
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c4a2b3299 | |||
| 8d0f2e3639 | |||
| 4c65604f04 | |||
| b02464a7ed | |||
| 96747abdc3 | |||
| 3daa40570b | |||
| 82ef2b38dd | |||
| 6b67c7afd4 | |||
| 7e672a5e62 | |||
| ac546eba2b | |||
| a49816eb46 | |||
| daf6098203 | |||
| 748b485c9b | |||
| 5ca084bd4a | |||
| 5558db6d56 | |||
| 1e925a2b07 | |||
| 080c4238e8 | |||
| ffde884f5b | |||
| 2a58833190 | |||
| c30863ab9b | |||
| 2cca79cf04 | |||
| 033cec6573 |
@@ -20,3 +20,6 @@ database/*.sqlite-journal
|
|||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
docs/*.pdf
|
docs/*.pdf
|
||||||
public/storage
|
public/storage
|
||||||
|
|
||||||
|
# Runs on a developer machine, next to Claude Code, never in the image.
|
||||||
|
mcp
|
||||||
|
|||||||
@@ -2,10 +2,17 @@
|
|||||||
# Production environment for the Docker stack.
|
# Production environment for the Docker stack.
|
||||||
#
|
#
|
||||||
# cp .env.production.example .env.production
|
# cp .env.production.example .env.production
|
||||||
# docker compose run --rm --no-deps app php artisan key:generate --show
|
# # fill in at least DB_PASSWORD below — docker-compose.yml refuses to
|
||||||
|
# # interpolate at all without it, even for the next command
|
||||||
|
# docker compose --env-file .env.production run --rm --no-deps landing-app php artisan key:generate --show
|
||||||
#
|
#
|
||||||
# Everything the containers need is here; docker-compose.yml sets the database,
|
# Everything the containers need is here; docker-compose.yml sets the database,
|
||||||
# cache, session and queue drivers itself so they cannot drift.
|
# cache, session and queue drivers itself so they cannot drift.
|
||||||
|
#
|
||||||
|
# This file only matters for a manual deploy. A platform that injects its own
|
||||||
|
# "Environment Variables" (Dokploy, Coolify, ...) exports them as real process
|
||||||
|
# environment variables instead, which docker-compose.yml's `${VAR}`
|
||||||
|
# interpolation reads directly — no file needed on the server at all.
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
APP_NAME="Pireon Admin"
|
APP_NAME="Pireon Admin"
|
||||||
@@ -116,3 +123,18 @@ MAIL_USERNAME=
|
|||||||
MAIL_PASSWORD=
|
MAIL_PASSWORD=
|
||||||
MAIL_FROM_ADDRESS="admin@example.org"
|
MAIL_FROM_ADDRESS="admin@example.org"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# MCP server (landing-mcp)
|
||||||
|
#
|
||||||
|
# Holds no credentials: every caller signs in with their own. Its domain is
|
||||||
|
# added in Dokploy's "Domains" tab, pointing at port 3000.
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
# "true" refuses callers without credentials instead of offering them the
|
||||||
|
# public API — which is already public, so the default is to allow it.
|
||||||
|
LANDING_MCP_REQUIRE_AUTH=false
|
||||||
|
|
||||||
|
# Empty on purpose. Set to SITE_PREVIEW_TOKEN's value and anyone who can sign
|
||||||
|
# in sees every draft through the public tools, whatever their role.
|
||||||
|
LANDING_MCP_PREVIEW_TOKEN=
|
||||||
|
|||||||
21
.github/workflows/tests.yml
vendored
21
.github/workflows/tests.yml
vendored
@@ -79,3 +79,24 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- run: docker compose -f docker-compose.test.yml run --rm test
|
- run: docker compose -f docker-compose.test.yml run --rm test
|
||||||
|
|
||||||
|
# The MCP server derives its tools from openapi.yaml, so a change to the
|
||||||
|
# document can break it without touching a line under mcp/ — two operations
|
||||||
|
# that map to the same tool name, say. Cheap enough to run on every push.
|
||||||
|
mcp:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: mcp
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: mcp/package-lock.json
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- run: npm test
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,3 +27,4 @@ _ide_helper.php
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/mcp/node_modules
|
||||||
|
|||||||
15
.mcp.json
Normal file
15
.mcp.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"landing": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["mcp/server.mjs"],
|
||||||
|
"env": {
|
||||||
|
"LANDING_URL": "${LANDING_URL:-http://localhost:8000}",
|
||||||
|
"LANDING_USERNAME": "${LANDING_USERNAME:-}",
|
||||||
|
"LANDING_PASSWORD": "${LANDING_PASSWORD:-}",
|
||||||
|
"LANDING_TOKEN": "${LANDING_TOKEN:-}",
|
||||||
|
"LANDING_PREVIEW_TOKEN": "${LANDING_PREVIEW_TOKEN:-}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,9 +113,9 @@ RUN php artisan package:discover --ansi \
|
|||||||
|
|
||||||
RUN chown -R www-data:www-data storage bootstrap/cache
|
RUN chown -R www-data:www-data storage bootstrap/cache
|
||||||
|
|
||||||
ENV SERVER_NAME=:80
|
ENV SERVER_NAME=:8080
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["entrypoint"]
|
ENTRYPOINT ["entrypoint"]
|
||||||
|
|
||||||
|
|||||||
212
README.md
212
README.md
@@ -390,14 +390,52 @@ Filtros para las cuatro preguntas que no se pueden responder en ningún otro
|
|||||||
sitio: qué está **sin procesar**, qué **falló**, qué **no usa nadie** (lo mismo
|
sitio: qué está **sin procesar**, qué **falló**, qué **no usa nadie** (lo mismo
|
||||||
que se llevaría `content:prune --media`) y qué **ya no está en disco**.
|
que se llevaría `content:prune --media`) y qué **ya no está en disco**.
|
||||||
|
|
||||||
No se sube desde ahí: los archivos llegan al subirlos en una ficha. Y **no se
|
No se sube desde ahí: los archivos llegan al subirlos en una ficha (o por la
|
||||||
puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que
|
API, abajo). Y **no se puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que
|
||||||
es justo de lo que se queja la página de salud. El botón solo aparece en los
|
es justo de lo que se queja la página de salud. El botón solo aparece en los
|
||||||
huérfanos.
|
huérfanos.
|
||||||
|
|
||||||
«Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la
|
«Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la
|
||||||
tabla es derivada, así que reconstruirla siempre es seguro.
|
tabla es derivada, así que reconstruirla siempre es seguro.
|
||||||
|
|
||||||
|
### Subir por la API
|
||||||
|
|
||||||
|
`POST /api/v1/media` es el campo de subida de una ficha, sin la ficha: guarda la
|
||||||
|
imagen donde el panel pone las de ese tipo de ficha y contesta con el `path` que
|
||||||
|
se manda como `featured_image` de una página o `logo` de una tecnología. `for`
|
||||||
|
dice para qué es (`pages`, por defecto, `technologies`, `projects`, `members`,
|
||||||
|
`clients` o `testimonials`), y con ello la carpeta y el permiso que hace falta. Existe para el [MCP](#servidor-mcp), que sin él
|
||||||
|
podía crear una página pero nunca ponerle una foto. Pasa por `Media::for()`,
|
||||||
|
así que se procesa, se sirve y se poda igual que una subida desde el panel.
|
||||||
|
|
||||||
|
Dos formas de entrada, una por petición: **multipart** con `file`, o **JSON**
|
||||||
|
con una `url` que la API descarga ella misma. JPEG, PNG, WebP, GIF, AVIF y SVG,
|
||||||
|
decidido por los bytes y no por el nombre ni el tipo declarado. 20 MB como mucho
|
||||||
|
(`MEDIA_UPLOAD_MAX_KB`).
|
||||||
|
|
||||||
|
**El SVG entra, pero nunca tal cual**: es lo que es un logo, y también un
|
||||||
|
documento que puede llevar script servido desde este dominio. Pasa antes por
|
||||||
|
[`enshrined/svg-sanitize`](https://github.com/darylldoyle/svg-sanitizer), que
|
||||||
|
quita scripts, manejadores de eventos y referencias a cualquier cosa fuera del
|
||||||
|
fichero; y Caddy sirve los `/storage/*.svg` con una `Content-Security-Policy`
|
||||||
|
que pararía un script igualmente si se colara.
|
||||||
|
|
||||||
|
**La URL es la parte delicada**, porque la petición la hace este servidor desde
|
||||||
|
dentro de la red del stack: sin control, sería una forma de pedirle
|
||||||
|
`http://postgres:5432` o el servicio de metadatos de la nube.
|
||||||
|
[`RemoteImage`](app/Services/Media/RemoteImage.php) exige HTTPS, resuelve el
|
||||||
|
dominio y **rechaza si una sola de sus direcciones no es pública** (privadas,
|
||||||
|
reservadas, loopback, link-local, 100.64/10, IPv4 mapeada en IPv6), **fija la
|
||||||
|
conexión a la dirección comprobada** con `CURLOPT_RESOLVE` para que un DNS que
|
||||||
|
cambie entre la comprobación y la petición no cuele otra, **no sigue
|
||||||
|
redirecciones** y deja de leer al pasar del límite, diga lo que diga
|
||||||
|
`Content-Length`.
|
||||||
|
|
||||||
|
El permiso es escribir el tipo de ficha de `for` (`pages.create` o
|
||||||
|
`pages.update`, `technologies.create` o…), no ninguno de `media.*`: esos siempre describieron cuidar ficheros que ya existen, y
|
||||||
|
`MediaPolicy::create` sigue siendo `false` para que el panel no gane un botón de
|
||||||
|
«nuevo fichero» suelto.
|
||||||
|
|
||||||
### Galerías
|
### Galerías
|
||||||
|
|
||||||
Un proyecto tiene además una galería —capturas, el antes y el después— que se
|
Un proyecto tiene además una galería —capturas, el antes y el después— que se
|
||||||
@@ -1225,8 +1263,53 @@ POST /api/v1/pages/{page}/restore
|
|||||||
GET /api/v1/users PATCH para roles, is_active, locale
|
GET /api/v1/users PATCH para roles, is_active, locale
|
||||||
GET /api/v1/roles solo lectura
|
GET /api/v1/roles solo lectura
|
||||||
GET /api/v1/roles/catalogue catálogo de permisos agrupado
|
GET /api/v1/roles/catalogue catálogo de permisos agrupado
|
||||||
|
/api/v1/projects CRUD + restore; relaciones por ids:
|
||||||
|
technology_ids, service_ids, members[{id, role}]
|
||||||
|
/api/v1/members CRUD + restore; links (etiqueta → URL), technology_ids
|
||||||
|
/api/v1/services CRUD + restore; technology_ids, project_ids
|
||||||
|
/api/v1/technologies CRUD completo; filtros: category,
|
||||||
|
radar_ring (none = fuera del radar), search
|
||||||
|
/api/v1/clients CRUD + restore; filtros: is_public, search
|
||||||
|
/api/v1/testimonials CRUD + restore; filtros: is_public, client_id,
|
||||||
|
project_id, service_id, search
|
||||||
|
/api/v1/talks CRUD (sin papelera, como en el panel)
|
||||||
|
/api/v1/calls CRUD; technology_ids; filtros: status,
|
||||||
|
project_id, search
|
||||||
|
GET /api/v1/applications el buzón; filtros: status (sin él, sin spam),
|
||||||
|
kind, call_id, search, trashed
|
||||||
|
PATCH /api/v1/applications/{id} solo status y notes
|
||||||
|
DELETE /api/v1/applications/{id} + POST …/restore
|
||||||
|
/api/v1/redirects CRUD; from/to normalizados, únicos por idioma
|
||||||
|
GET /api/v1/missing-urls las 404 que avisó el frontend (pending por defecto)
|
||||||
|
PATCH /api/v1/missing-urls/{id} pending ⇄ ignored
|
||||||
|
POST /api/v1/missing-urls/{id}/redirect crear la redirección que la resuelve
|
||||||
|
/api/v1/repositories CRUD (solo lo que no viene del proveedor)
|
||||||
|
POST /api/v1/repositories/{id}/sync encola una sincronización (202)
|
||||||
|
GET /api/v1/settings ajustes; PATCH /settings/{clave} sustituye el valor
|
||||||
|
GET /api/v1/maintenance modo mantenimiento de la web pública
|
||||||
|
PUT /api/v1/maintenance {enabled, message, until}
|
||||||
|
POST /api/v1/media subir una imagen (fichero o URL)
|
||||||
|
GET /api/v1/media la biblioteca; filtros: for, mime, search,
|
||||||
|
pending, failed, orphans; cada una dice si
|
||||||
|
algo la usa (referenced)
|
||||||
|
DELETE /api/v1/media/{id} solo si nada la usa (409 si no), con sus versiones
|
||||||
|
POST /api/v1/media/{id}/reprocess volver a derivar versiones (cola)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Publicar sigue siendo un permiso aparte, como en el panel (donde esos campos
|
||||||
|
salen desactivados): sin `projects.publish` o `services.publish`, la API rechaza
|
||||||
|
`published_at`, `unpublish_at` e `is_published`, así que quien puede escribir
|
||||||
|
algo no puede sacarlo a la web solo. Las relaciones enviadas sustituyen a las
|
||||||
|
que había, y las no enviadas no se tocan; todo se guarda en una transacción.
|
||||||
|
|
||||||
|
Los ajustes siguen los permisos del panel: `settings.view` para leer,
|
||||||
|
`settings.update` para cambiar y `settings.maintenance` para `site.maintenance`
|
||||||
|
y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo
|
||||||
|
rechaza: puesto a mano, el frontend serviría páginas viejas o las pediría de
|
||||||
|
nuevo para nada. Sincronizar un repositorio pide `repositories.sync`, porque
|
||||||
|
gasta un límite del proveedor que comparte toda la aplicación, y va a la cola
|
||||||
|
en vez de hacer esperar a quien llama.
|
||||||
|
|
||||||
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
|
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
|
||||||
cliente en vez de comprobar nombres de rol.
|
cliente en vez de comprobar nombres de rol.
|
||||||
|
|
||||||
@@ -1332,6 +1415,105 @@ Va **commiteado** —quien lo necesita debería poder leerlo sin ejecutar nada
|
|||||||
viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su
|
viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su
|
||||||
prosa, y un tipo a secas no la conserva.
|
prosa, y un tipo a secas no la conserva.
|
||||||
|
|
||||||
|
## Servidor MCP
|
||||||
|
|
||||||
|
[`mcp/`](mcp/) es un servidor MCP (Node ≥ 20) que da a Claude —o a cualquier
|
||||||
|
cliente MCP— las dos APIs como herramientas: `public_projects_index`,
|
||||||
|
`admin_pages_update`, `admin_roles_catalogue`… Una por operación documentada,
|
||||||
|
con el nombre de la ruta de Laravel en la que acaba.
|
||||||
|
|
||||||
|
**No hay ninguna escrita a mano.** Lee el `openapi.yaml` que sirve la
|
||||||
|
aplicación a la que apunta y las genera de ahí, así que describe la API con la
|
||||||
|
que está hablando —local, staging o producción— y no la de este checkout. Por lo
|
||||||
|
mismo que los tipos: el documento lo sujetan los tests de contrato, y una
|
||||||
|
segunda descripción de la API escrita a mano no la sujetaría nada. La
|
||||||
|
introducción del documento (las dos APIs, idiomas, campos traducibles) viaja
|
||||||
|
como instrucciones del servidor.
|
||||||
|
|
||||||
|
### Desplegado (HTTP)
|
||||||
|
|
||||||
|
Va en el stack como un contenedor más, `landing-mcp`, que habla con
|
||||||
|
`landing-app` por la red interna. En Dokploy, añádele un dominio en la pestaña
|
||||||
|
«Domains» apuntando al puerto 3000; el endpoint es `https://<dominio>/mcp`
|
||||||
|
(Streamable HTTP, sin estado).
|
||||||
|
|
||||||
|
**No tiene credenciales propias.** Cada petición trae las de quien la hace, y
|
||||||
|
la API aplica los permisos de esa persona, igual que en el panel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add --transport http landing https://mcp.example.org/mcp \
|
||||||
|
--header "Authorization: Basic $(printf 'tu@email.com:contraseña' | base64)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`Basic`** con el email y la contraseña de Pireon: el servidor hace login la
|
||||||
|
primera vez, guarda la sesión en memoria (indexada por un hash, nunca en
|
||||||
|
disco ni en logs), refresca el token antes de que caduque y la olvida tras
|
||||||
|
12 horas sin uso. Es lo recomendable, porque los tokens de Pireon caducan.
|
||||||
|
- **`Bearer`** con un token de Pireon: funciona hasta que caduca. Se valida
|
||||||
|
contra `GET /v1/me` y se revalida cada minuto, como hace la propia app.
|
||||||
|
- **Sin cabecera**: solo la API pública, que ya es pública.
|
||||||
|
`LANDING_MCP_REQUIRE_AUTH=true` lo cierra.
|
||||||
|
|
||||||
|
Unas credenciales rechazadas se recuerdan un minuto, sin volver a preguntar a
|
||||||
|
la app. Por eso mismo `POST /v1/auth/token` limita **por cuenta y dirección**
|
||||||
|
(6/min) y no solo por dirección (con un techo de 30/min por dirección): todos
|
||||||
|
los logins del MCP salen del mismo contenedor, y un límite por IP haría que la
|
||||||
|
contraseña mal escrita de uno —o los intentos de un extraño— dejaran fuera a
|
||||||
|
todos los demás.
|
||||||
|
|
||||||
|
El documento se relee cada minuto, así que un endpoint nuevo desplegado en la
|
||||||
|
app aparece como herramienta sin redesplegar el MCP; si la app está caída, se
|
||||||
|
sigue usando el último que cargó.
|
||||||
|
|
||||||
|
`LANDING_MCP_PREVIEW_TOKEN` está vacío a propósito: con el valor de
|
||||||
|
`SITE_PREVIEW_TOKEN`, cualquiera que pueda iniciar sesión ve todos los
|
||||||
|
borradores por las herramientas públicas, sea cual sea su rol. Nunca se manda
|
||||||
|
a quien llega sin credenciales.
|
||||||
|
|
||||||
|
### En local (stdio)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mcp && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
En este repo ya está registrado en [`.mcp.json`](.mcp.json), que lo lanza por
|
||||||
|
stdio con la configuración del entorno. Aquí sí las credenciales van en
|
||||||
|
variables, porque quien lo lanza es quien inicia sesión:
|
||||||
|
|
||||||
|
```
|
||||||
|
LANDING_URL raíz de la aplicación (por defecto http://localhost:8000)
|
||||||
|
LANDING_USERNAME credenciales de Pireon para la API de administración;
|
||||||
|
LANDING_PASSWORD el token se pide y se refresca solo
|
||||||
|
LANDING_TOKEN o un bearer token fijo en vez de credenciales
|
||||||
|
LANDING_PREVIEW_TOKEN opcional: las herramientas públicas ven borradores
|
||||||
|
LANDING_OPENAPI opcional: ruta o URL de la especificación
|
||||||
|
```
|
||||||
|
|
||||||
|
`node mcp/server.mjs --http` arranca el modo HTTP en local (en `127.0.0.1:3000`
|
||||||
|
salvo `LANDING_MCP_HOST`/`LANDING_MCP_PORT`), para probarlo sin Docker.
|
||||||
|
|
||||||
|
### En los dos modos
|
||||||
|
|
||||||
|
- **Sin credenciales solo aparece la mitad pública.** Ofrecer herramientas que
|
||||||
|
solo pueden fallar le cuesta al modelo una llamada cada una para descubrirlo.
|
||||||
|
- **Fuera a propósito**: `POST /v1/auth/token` (es cosa del cliente), la
|
||||||
|
telemetría (la manda el frontend con su propio secreto) y los dos formularios
|
||||||
|
del buzón — una llamada a `contact` o `apply` mete una fila en el buzón y
|
||||||
|
avisa a gente real.
|
||||||
|
- Las cabeceras no se exponen como parámetros: son secretos, y salen de la
|
||||||
|
configuración, no del modelo.
|
||||||
|
- **Subir imágenes** (`admin_media_store`): en local el modelo pasa una ruta de
|
||||||
|
tu disco (`file_path`) y el servidor la lee y la sube como multipart —en
|
||||||
|
base64 una foto normal serían cientos de miles de tokens—. **Desplegado no hay
|
||||||
|
`file_path`**: el servidor leería su propio disco a petición de cualquiera, así
|
||||||
|
que solo se ofrece `body.url`, que descarga la API con las protecciones de
|
||||||
|
[Subir por la API](#subir-por-la-api).
|
||||||
|
|
||||||
|
`npm test` (en `mcp/`, y en CI) genera las herramientas desde el
|
||||||
|
`openapi.yaml` de este repo —un cambio en el documento que haga chocar dos
|
||||||
|
nombres rompe el build aquí, no la sesión de alguien— y prueba el modo HTTP de
|
||||||
|
punta a punta contra una app simulada.
|
||||||
|
|
||||||
## Comandos
|
## Comandos
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1364,7 +1546,7 @@ php artisan site:maintenance --up
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan test # 394 tests, 1128 aserciones
|
php artisan test # 391 tests, 1121 aserciones
|
||||||
```
|
```
|
||||||
|
|
||||||
En cada push y cada pull request, [`.github/workflows/tests.yml`](.github/workflows/tests.yml)
|
En cada push y cada pull request, [`.github/workflows/tests.yml`](.github/workflows/tests.yml)
|
||||||
@@ -1458,7 +1640,7 @@ párrafo aparte sin relación programática con él.
|
|||||||
## Estructura
|
## Estructura
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose.yml stack de producción (app, worker, scheduler, postgres)
|
docker-compose.yml stack de producción (landing-app, worker, scheduler, landing-mcp, postgres)
|
||||||
docker-compose.test.yml la suite contra Postgres, desechable
|
docker-compose.test.yml la suite contra Postgres, desechable
|
||||||
Dockerfile imagen única para los tres contenedores PHP
|
Dockerfile imagen única para los tres contenedores PHP
|
||||||
docker/ Caddyfile, php.ini y entrypoint
|
docker/ Caddyfile, php.ini y entrypoint
|
||||||
@@ -1491,6 +1673,7 @@ config/site.php frontend público, preview, caché, rate
|
|||||||
config/media.php disco, anchos y formatos de imagen
|
config/media.php disco, anchos y formatos de imagen
|
||||||
config/git.php proveedores de git y etiquetas de issues
|
config/git.php proveedores de git y etiquetas de issues
|
||||||
public/openapi.yaml especificación de ambas APIs
|
public/openapi.yaml especificación de ambas APIs
|
||||||
|
mcp/ servidor MCP (stdio y HTTP) generado desde la anterior
|
||||||
```
|
```
|
||||||
|
|
||||||
## Despliegue con Docker
|
## Despliegue con Docker
|
||||||
@@ -1499,16 +1682,29 @@ Cuatro contenedores y una base de datos:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.production.example .env.production
|
cp .env.production.example .env.production
|
||||||
docker compose run --rm --no-deps app php artisan key:generate --show # pégalo en APP_KEY
|
# Ahora edita .env.production: al menos DB_PASSWORD tiene que tener un valor
|
||||||
|
# real, porque docker-compose.yml no arranca sin él — ni siquiera para el
|
||||||
|
# comando siguiente, que solo interpola el fichero entero antes de ejecutar
|
||||||
|
# nada. APP_KEY puede quedarse vacío por ahora, se rellena con este paso:
|
||||||
|
|
||||||
|
docker compose --env-file .env.production run --rm --no-deps landing-app php artisan key:generate --show # pégalo en APP_KEY
|
||||||
docker compose --env-file .env.production up -d --build
|
docker compose --env-file .env.production up -d --build
|
||||||
|
|
||||||
# el primer administrador, igual que en local
|
# el primer administrador, igual que en local
|
||||||
docker compose exec app php artisan user:role tu@email.com super_admin
|
docker compose exec landing-app php artisan user:role tu@email.com super_admin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Todas las variables de `docker-compose.yml` llegan por interpolación `${VAR}`,
|
||||||
|
no por `env_file:` — así que esto funciona igual con `--env-file`, con
|
||||||
|
variables ya exportadas en el shell, o con el panel de "Environment
|
||||||
|
Variables" de una plataforma como Dokploy o Coolify, que las inyecta como
|
||||||
|
variables de entorno reales del proceso que lanza `docker compose`. Ninguna
|
||||||
|
de las tres formas necesita que exista un fichero `.env.production` en el
|
||||||
|
servidor.
|
||||||
|
|
||||||
| Contenedor | Qué hace |
|
| Contenedor | Qué hace |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app` | FrankenPHP sirviendo el panel y la API |
|
| `landing-app` | FrankenPHP sirviendo el panel y la API |
|
||||||
| `worker` | procesa imágenes y manda los avisos del buzón |
|
| `worker` | procesa imágenes y manda los avisos del buzón |
|
||||||
| `scheduler` | cierra convocatorias, barre imágenes pendientes, sincroniza repos |
|
| `scheduler` | cierra convocatorias, barre imágenes pendientes, sincroniza repos |
|
||||||
| `postgres` | la base de datos |
|
| `postgres` | la base de datos |
|
||||||
@@ -1587,7 +1783,7 @@ docker compose -f docker-compose.test.yml run --rm test
|
|||||||
|
|
||||||
Levanta un Postgres en memoria, corre la suite entera contra él y lo tira. Es la
|
Levanta un Postgres en memoria, corre la suite entera contra él y lo tira. Es la
|
||||||
comprobación que convierte «debería funcionar» en «funciona», y conviene hacerla
|
comprobación que convierte «debería funcionar» en «funciona», y conviene hacerla
|
||||||
antes del primer despliegue. **394 tests en verde contra Postgres 17.**
|
antes del primer despliegue. **391 tests en verde contra Postgres 17.**
|
||||||
|
|
||||||
Usa una etapa propia de la imagen: la de producción se construye con `--no-dev`
|
Usa una etapa propia de la imagen: la de producción se construye con `--no-dev`
|
||||||
y ahí no está ni el runner de tests.
|
y ahí no está ni el runner de tests.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Filament\Resources\Applications\Tables;
|
|||||||
|
|
||||||
use App\Enums\ApplicationKind;
|
use App\Enums\ApplicationKind;
|
||||||
use App\Enums\ApplicationStatus;
|
use App\Enums\ApplicationStatus;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@@ -93,6 +94,7 @@ class ApplicationsTable
|
|||||||
SelectFilter::make('call')
|
SelectFilter::make('call')
|
||||||
->label(__('panel.applications.fields.call'))
|
->label(__('panel.applications.fields.call'))
|
||||||
->relationship('call', 'title->'.config('locales.default'))
|
->relationship('call', 'title->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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\PreviewAction;
|
use App\Filament\Support\PreviewAction;
|
||||||
use App\Filament\Support\TranslatableTabs;
|
use App\Filament\Support\TranslatableTabs;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Models\Call;
|
use App\Models\Call;
|
||||||
use App\Rules\UniqueTranslatedSlug;
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@@ -141,6 +142,7 @@ class CallResource extends Resource
|
|||||||
Select::make('project_id')
|
Select::make('project_id')
|
||||||
->label(__('panel.calls.fields.project'))
|
->label(__('panel.calls.fields.project'))
|
||||||
->relationship('project', 'title->'.config('locales.default'))
|
->relationship('project', 'title->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload()
|
->preload()
|
||||||
->helperText(__('panel.calls.fields.project_hint')),
|
->helperText(__('panel.calls.fields.project_hint')),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Filament\Resources\Pages\Schemas;
|
|||||||
use App\Enums\PageStatus;
|
use App\Enums\PageStatus;
|
||||||
use App\Filament\Resources\Pages\PageResource;
|
use App\Filament\Resources\Pages\PageResource;
|
||||||
use App\Filament\Support\TranslatableTabs;
|
use App\Filament\Support\TranslatableTabs;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
use App\Rules\UniqueTranslatedSlug;
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
use App\Support\DuplicateTitles;
|
use App\Support\DuplicateTitles;
|
||||||
@@ -74,6 +75,15 @@ class PageForm
|
|||||||
|
|
||||||
RichEditor::make("body.{$locale}")
|
RichEditor::make("body.{$locale}")
|
||||||
->label(__('panel.pages.fields.body'))
|
->label(__('panel.pages.fields.body'))
|
||||||
|
// Images in the text go where every other upload
|
||||||
|
// does, public: the HTML travels to the frontend
|
||||||
|
// as it is, with each image's URL written in, and
|
||||||
|
// a private file would be a broken image there.
|
||||||
|
// Media::referencedPaths() finds them in the body,
|
||||||
|
// so pruning never takes one a page still shows.
|
||||||
|
->fileAttachmentsDisk(config('media.disk'))
|
||||||
|
->fileAttachmentsDirectory('pages/content')
|
||||||
|
->fileAttachmentsVisibility('public')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
@@ -112,6 +122,7 @@ class PageForm
|
|||||||
Select::make('category_id')
|
Select::make('category_id')
|
||||||
->label(__('panel.pages.fields.category'))
|
->label(__('panel.pages.fields.category'))
|
||||||
->relationship('category', 'name->'.config('locales.default'))
|
->relationship('category', 'name->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Enums\PageStatus;
|
|||||||
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;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Filament\Support\TranslationStatusColumn;
|
use App\Filament\Support\TranslationStatusColumn;
|
||||||
use App\Filament\Support\ViewsColumn;
|
use App\Filament\Support\ViewsColumn;
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
@@ -127,6 +128,7 @@ class PagesTable
|
|||||||
SelectFilter::make('category')
|
SelectFilter::make('category')
|
||||||
->label(__('panel.pages.fields.category'))
|
->label(__('panel.pages.fields.category'))
|
||||||
->relationship('category', 'name->'.config('locales.default'))
|
->relationship('category', 'name->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Filament\Resources\Repositories\Pages\CreateRepository;
|
|||||||
use App\Filament\Resources\Repositories\Pages\EditRepository;
|
use App\Filament\Resources\Repositories\Pages\EditRepository;
|
||||||
use App\Filament\Resources\Repositories\Pages\ListRepositories;
|
use App\Filament\Resources\Repositories\Pages\ListRepositories;
|
||||||
use App\Filament\Resources\Repositories\Tables\RepositoriesTable;
|
use App\Filament\Resources\Repositories\Tables\RepositoriesTable;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Models\Repository;
|
use App\Models\Repository;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@@ -81,6 +82,7 @@ class RepositoryResource extends Resource
|
|||||||
Select::make('project_id')
|
Select::make('project_id')
|
||||||
->label(__('panel.repositories.fields.project'))
|
->label(__('panel.repositories.fields.project'))
|
||||||
->relationship('project', 'title->'.config('locales.default'))
|
->relationship('project', 'title->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Filament\Resources\Talks\Pages\CreateTalk;
|
|||||||
use App\Filament\Resources\Talks\Pages\EditTalk;
|
use App\Filament\Resources\Talks\Pages\EditTalk;
|
||||||
use App\Filament\Resources\Talks\Pages\ListTalks;
|
use App\Filament\Resources\Talks\Pages\ListTalks;
|
||||||
use App\Filament\Support\TranslatableTabs;
|
use App\Filament\Support\TranslatableTabs;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Models\Talk;
|
use App\Models\Talk;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
@@ -126,6 +127,7 @@ class TalkResource extends Resource
|
|||||||
Select::make('project_id')
|
Select::make('project_id')
|
||||||
->label(__('panel.talks.fields.project'))
|
->label(__('panel.talks.fields.project'))
|
||||||
->relationship('project', 'title->'.config('locales.default'))
|
->relationship('project', 'title->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Filament\Resources\Testimonials\Pages\CreateTestimonial;
|
|||||||
use App\Filament\Resources\Testimonials\Pages\EditTestimonial;
|
use App\Filament\Resources\Testimonials\Pages\EditTestimonial;
|
||||||
use App\Filament\Resources\Testimonials\Pages\ListTestimonials;
|
use App\Filament\Resources\Testimonials\Pages\ListTestimonials;
|
||||||
use App\Filament\Support\TranslatableTabs;
|
use App\Filament\Support\TranslatableTabs;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Models\Testimonial;
|
use App\Models\Testimonial;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
@@ -137,12 +138,14 @@ class TestimonialResource extends Resource
|
|||||||
Select::make('project_id')
|
Select::make('project_id')
|
||||||
->label(__('panel.testimonials.fields.project'))
|
->label(__('panel.testimonials.fields.project'))
|
||||||
->relationship('project', 'title->'.config('locales.default'))
|
->relationship('project', 'title->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
Select::make('service_id')
|
Select::make('service_id')
|
||||||
->label(__('panel.testimonials.fields.service'))
|
->label(__('panel.testimonials.fields.service'))
|
||||||
->relationship('service', 'title->'.config('locales.default'))
|
->relationship('service', 'title->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
|
|||||||
29
app/Filament/Support/TranslatedOptionLabel.php
Normal file
29
app/Filament/Support/TranslatedOptionLabel.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Support;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label of a relationship option whose title is translatable.
|
||||||
|
*
|
||||||
|
* `->relationship('category', 'name->es')` builds the list fine — that path
|
||||||
|
* goes through a query — but to validate a choice Filament reads the title
|
||||||
|
* back off the record as a property, `$record->{'name->es'}`, which does not
|
||||||
|
* exist. Every choice then came back without a label and was refused as "not
|
||||||
|
* in the list of allowed values", whichever one it was. Given this, Filament
|
||||||
|
* asks the record instead, in the language of whoever is using the panel.
|
||||||
|
*
|
||||||
|
* The JSON path stays in `relationship()`: it is still what the search box
|
||||||
|
* queries and the list is ordered by.
|
||||||
|
*/
|
||||||
|
final class TranslatedOptionLabel
|
||||||
|
{
|
||||||
|
public static function using(string $attribute): Closure
|
||||||
|
{
|
||||||
|
return fn (Model $record): ?string => $record->getTranslation($attribute, app()->getLocale());
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/Http/Controllers/Api/V1/ApplicationController.php
Normal file
104
app/Http/Controllers/Api/V1/ApplicationController.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationStatus;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\ApplicationRequest;
|
||||||
|
use App\Http\Resources\ApplicationResource;
|
||||||
|
use App\Models\Application;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inbox: what people sent from the public site.
|
||||||
|
*
|
||||||
|
* Nothing is created here — submissions arrive through the public API — and
|
||||||
|
* what can be changed is what the panel changes: the status and the private
|
||||||
|
* notes.
|
||||||
|
*/
|
||||||
|
class ApplicationController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['call', 'handler'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: status, kind, call_id, search (name, e-mail or organisation), trashed.
|
||||||
|
*
|
||||||
|
* Spam stays out unless asked for by `status=spam`, as in the panel.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Application::class);
|
||||||
|
|
||||||
|
$applications = Application::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when(
|
||||||
|
$request->filled('status'),
|
||||||
|
fn ($q) => $q->where('status', $request->string('status')),
|
||||||
|
fn ($q) => $q->notSpam(),
|
||||||
|
)
|
||||||
|
->when($request->filled('kind'), fn ($q) => $q->where('kind', $request->string('kind')))
|
||||||
|
->when($request->filled('call_id'), fn ($q) => $q->where('call_id', $request->integer('call_id')))
|
||||||
|
->when($request->filled('search'), function ($q) use ($request): void {
|
||||||
|
$term = '%'.$request->string('search').'%';
|
||||||
|
|
||||||
|
$q->where(fn ($q) => $q
|
||||||
|
->where('name', 'like', $term)
|
||||||
|
->orWhere('email', 'like', $term)
|
||||||
|
->orWhere('organisation', 'like', $term));
|
||||||
|
})
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->latest()
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ApplicationResource::collection($applications);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Application $application): ApplicationResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $application);
|
||||||
|
|
||||||
|
return ApplicationResource::make($application->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ApplicationRequest $request, Application $application): ApplicationResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $application);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
// Notes are outside the model's fillable list so the public form can
|
||||||
|
// never reach them; this is the one other place that writes them.
|
||||||
|
if (array_key_exists('notes', $data)) {
|
||||||
|
$application->forceFill(['notes' => $data['notes']])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['status'])) {
|
||||||
|
$application->markAs(ApplicationStatus::from($data['status']), $request->user());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplicationResource::make($application->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Application $application): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $application);
|
||||||
|
|
||||||
|
$application->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Application $application): ApplicationResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $application);
|
||||||
|
|
||||||
|
$application->restore();
|
||||||
|
|
||||||
|
return ApplicationResource::make($application->load(self::WITH));
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/Http/Controllers/Api/V1/CallController.php
Normal file
95
app/Http/Controllers/Api/V1/CallController.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\CallRequest;
|
||||||
|
use App\Http\Resources\CallResource;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open calls for contributors. Deleted for good, like the panel does.
|
||||||
|
*/
|
||||||
|
class CallController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['technologies'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: status, project_id, search (title in the active language).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Call::class);
|
||||||
|
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
$calls = Call::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->withCount('applications')
|
||||||
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||||
|
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
|
||||||
|
->latest('id')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return CallResource::collection($calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(CallRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Call::class);
|
||||||
|
|
||||||
|
return CallResource::make($this->save(new Call, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Call $call): CallResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $call);
|
||||||
|
|
||||||
|
return CallResource::make($call->load(self::WITH)->loadCount('applications'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(CallRequest $request, Call $call): CallResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $call);
|
||||||
|
|
||||||
|
return CallResource::make($this->save($call, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Call $call): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $call);
|
||||||
|
|
||||||
|
$call->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Call $call, CallRequest $request): Call
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($call, $data): void {
|
||||||
|
$call->fill(TranslatablePayload::merge(
|
||||||
|
$call,
|
||||||
|
collect($data)->except(['technology_ids'])->all(),
|
||||||
|
$call->translatable,
|
||||||
|
));
|
||||||
|
$call->save();
|
||||||
|
|
||||||
|
if (array_key_exists('technology_ids', $data)) {
|
||||||
|
$call->technologies()->sync($data['technology_ids']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $call->load(self::WITH)->loadCount('applications');
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/Http/Controllers/Api/V1/ClientController.php
Normal file
90
app/Http/Controllers/Api/V1/ClientController.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\ClientRequest;
|
||||||
|
use App\Http\Resources\ClientResource;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The organisations the work was for, as the panel edits them.
|
||||||
|
*/
|
||||||
|
class ClientController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['logoMedia'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: is_public, search (name), trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Client::class);
|
||||||
|
|
||||||
|
$clients = Client::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->withCount('projects')
|
||||||
|
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ClientResource::collection($clients);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ClientRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Client::class);
|
||||||
|
|
||||||
|
return ClientResource::make($this->save(new Client, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Client $client): ClientResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $client);
|
||||||
|
|
||||||
|
return ClientResource::make($client->load(self::WITH)->loadCount('projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ClientRequest $request, Client $client): ClientResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $client);
|
||||||
|
|
||||||
|
return ClientResource::make($this->save($client, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Client $client): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $client);
|
||||||
|
|
||||||
|
$client->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Client $client): ClientResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $client);
|
||||||
|
|
||||||
|
$client->restore();
|
||||||
|
|
||||||
|
return ClientResource::make($client->load(self::WITH)->loadCount('projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Client $client, ClientRequest $request): Client
|
||||||
|
{
|
||||||
|
$client->fill(TranslatablePayload::merge($client, $request->validated(), $client->translatable));
|
||||||
|
$client->save();
|
||||||
|
|
||||||
|
return $client->load(self::WITH)->loadCount('projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Controllers/Api/V1/MaintenanceController.php
Normal file
68
app/Http/Controllers/Api/V1/MaintenanceController.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Support\Maintenance;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public site's maintenance mode — the panel's maintenance screen, as two
|
||||||
|
* calls. Only the public site goes down; the panel and this API keep working.
|
||||||
|
*/
|
||||||
|
class MaintenanceController extends Controller
|
||||||
|
{
|
||||||
|
public function show(): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('settings.view');
|
||||||
|
|
||||||
|
return $this->state();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `enabled` switches it; `message` (language → text) and `until` are kept
|
||||||
|
* either way, so the wording can be prepared before the site goes down.
|
||||||
|
*/
|
||||||
|
public function update(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('settings.maintenance');
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'enabled' => ['required', 'boolean'],
|
||||||
|
'message' => ['sometimes', 'nullable', 'array'],
|
||||||
|
'message.*' => ['nullable', 'string', 'max:500'],
|
||||||
|
'until' => ['sometimes', 'nullable', 'date', 'after:now'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$message = array_key_exists('message', $data)
|
||||||
|
? array_filter((array) $data['message'], fn ($text) => filled($text))
|
||||||
|
: Maintenance::messages();
|
||||||
|
|
||||||
|
if ($data['enabled']) {
|
||||||
|
Maintenance::enable(
|
||||||
|
message: $message,
|
||||||
|
until: filled($data['until'] ?? null) ? CarbonImmutable::parse($data['until']) : null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Switching it off must not take the prepared wording with it.
|
||||||
|
Setting::set(Maintenance::KEY, [...Maintenance::state(), 'enabled' => false, 'until' => null, 'message' => $message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->state();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function state(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(['data' => [
|
||||||
|
'enabled' => Maintenance::isEnabled(),
|
||||||
|
'message' => (object) Maintenance::messages(),
|
||||||
|
'until' => Maintenance::until()?->toIso8601String(),
|
||||||
|
'started_at' => Maintenance::startedAt()?->toIso8601String(),
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
app/Http/Controllers/Api/V1/MediaController.php
Normal file
187
app/Http/Controllers/Api/V1/MediaController.php
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\MediaRequest;
|
||||||
|
use App\Http\Resources\MediaResource;
|
||||||
|
use App\Jobs\ProcessMedia;
|
||||||
|
use App\Models\Media;
|
||||||
|
use App\Services\Media\RemoteImage;
|
||||||
|
use enshrined\svgSanitize\Sanitizer;
|
||||||
|
use finfo;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media library: every image on the media disk, and uploading new ones.
|
||||||
|
*
|
||||||
|
* `store` stores an image and answers with the path a record points at — what the
|
||||||
|
* panel's upload field does on a form, without the form. It exists for the MCP
|
||||||
|
* server, which otherwise could create a page but never give it a picture.
|
||||||
|
*
|
||||||
|
* The file lands where the panel puts a page's images and goes through
|
||||||
|
* `Media::for()`, so it is processed, served and eventually pruned exactly
|
||||||
|
* like one uploaded there.
|
||||||
|
*/
|
||||||
|
class MediaController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Extension by detected type. Chosen from the bytes, never from the name
|
||||||
|
* the client sent.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected const EXTENSIONS = [
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/webp' => 'webp',
|
||||||
|
'image/gif' => 'gif',
|
||||||
|
'image/avif' => 'avif',
|
||||||
|
'image/svg+xml' => 'svg',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: for (the kind of record it was uploaded for), mime, search
|
||||||
|
* (path), pending, failed, orphans (what `content:prune --media` would
|
||||||
|
* take away).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Media::class);
|
||||||
|
|
||||||
|
// Worked out once for the page rather than once per file: it reads
|
||||||
|
// every record that can point at an image.
|
||||||
|
$referenced = array_flip(Media::referencedPaths());
|
||||||
|
|
||||||
|
$directory = config('media.upload.for.'.$request->string('for'));
|
||||||
|
|
||||||
|
$media = Media::query()
|
||||||
|
->when(is_string($directory), fn ($q) => $q->where('path', 'like', $directory.'/%'))
|
||||||
|
->when($request->filled('mime'), fn ($q) => $q->where('mime', $request->string('mime')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('path', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('pending'), fn ($q) => $q->pending())
|
||||||
|
->when($request->boolean('failed'), fn ($q) => $q->whereNotNull('error'))
|
||||||
|
->when($request->boolean('orphans'), fn ($q) => $q->whereNotIn('path', array_keys($referenced)))
|
||||||
|
->latest('id')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
$media->getCollection()->each(fn (Media $item) => $item->setAttribute('referenced', isset($referenced[$item->path])));
|
||||||
|
|
||||||
|
return MediaResource::collection($media);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Media $media): MediaResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $media);
|
||||||
|
|
||||||
|
return MediaResource::make($media->setAttribute('referenced', $media->isReferenced()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only a file nothing points at: deleting one in use would put a broken
|
||||||
|
* image on the public site. Its derived versions go with it.
|
||||||
|
*/
|
||||||
|
public function destroy(Media $media): JsonResponse
|
||||||
|
{
|
||||||
|
// The permission first, so whether a file is in use is only told to
|
||||||
|
// someone who could have deleted it.
|
||||||
|
$this->authorize('media.delete');
|
||||||
|
|
||||||
|
abort_if($media->isReferenced(), 409, __('rules.media.in_use'));
|
||||||
|
|
||||||
|
$media->deleteDerived();
|
||||||
|
Storage::disk($media->disk)->delete($media->path);
|
||||||
|
$media->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the smaller versions and the placeholder again, on the queue.
|
||||||
|
*/
|
||||||
|
public function reprocess(Media $media): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('view', $media);
|
||||||
|
|
||||||
|
ProcessMedia::dispatch($media);
|
||||||
|
|
||||||
|
return MediaResource::make($media)->response()->setStatusCode(202);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(MediaRequest $request, RemoteImage $remote): JsonResponse
|
||||||
|
{
|
||||||
|
$for = (string) $request->input('for', 'pages');
|
||||||
|
|
||||||
|
$this->authorize('upload', [Media::class, $for]);
|
||||||
|
|
||||||
|
$field = $request->hasFile('file') ? 'file' : 'url';
|
||||||
|
|
||||||
|
$contents = $field === 'file'
|
||||||
|
? (string) $request->file('file')->get()
|
||||||
|
: $remote->fetch((string) $request->string('url'));
|
||||||
|
|
||||||
|
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($contents) ?: null;
|
||||||
|
|
||||||
|
if (! in_array($mime, (array) config('media.upload.mimes'), true)) {
|
||||||
|
$this->refuse($field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mime === 'image/svg+xml') {
|
||||||
|
$contents = $this->sanitize($contents) ?? $this->refuse($field);
|
||||||
|
// A vector has no pixel size; the processor copies it through.
|
||||||
|
$size = [null, null];
|
||||||
|
} else {
|
||||||
|
// The type is read from the magic bytes; this catches a file that
|
||||||
|
// has them but does not decode, which nobody could render either.
|
||||||
|
$size = @getimagesizefromstring($contents) ?: $this->refuse($field);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = config("media.upload.for.{$for}").'/'.Str::random(40).'.'.self::EXTENSIONS[$mime];
|
||||||
|
|
||||||
|
Storage::disk((string) config('media.disk'))->put($path, $contents);
|
||||||
|
|
||||||
|
$media = Media::for($path);
|
||||||
|
|
||||||
|
// What is already known, so the answer carries dimensions straight
|
||||||
|
// away rather than after the queue has run. The processor writes the
|
||||||
|
// same values, and the rest, when it does.
|
||||||
|
if (! $media->isProcessed()) {
|
||||||
|
$media->forceFill([
|
||||||
|
'mime' => $mime,
|
||||||
|
'size' => strlen($contents),
|
||||||
|
'width' => $size[0],
|
||||||
|
'height' => $size[1],
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaResource::make($media->fresh())->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An SVG with what could run taken out: scripts, event handlers, and
|
||||||
|
* references to anything outside the file. Null when what is left is not
|
||||||
|
* an SVG at all.
|
||||||
|
*/
|
||||||
|
protected function sanitize(string $svg): ?string
|
||||||
|
{
|
||||||
|
$sanitizer = new Sanitizer;
|
||||||
|
$sanitizer->removeRemoteReferences(true);
|
||||||
|
|
||||||
|
$clean = $sanitizer->sanitize($svg);
|
||||||
|
|
||||||
|
return is_string($clean) && str_contains($clean, '<svg') ? $clean : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function refuse(string $field): never
|
||||||
|
{
|
||||||
|
throw ValidationException::withMessages([$field => [__('rules.media.not_an_image')]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Http/Controllers/Api/V1/MemberController.php
Normal file
103
app/Http/Controllers/Api/V1/MemberController.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\MemberRequest;
|
||||||
|
use App\Http\Resources\MemberResource;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The people in the collective, as the panel edits them. MemberPolicy lets
|
||||||
|
* someone edit their own profile without `members.update`, as in the panel.
|
||||||
|
*/
|
||||||
|
class MemberController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['avatarMedia', 'technologies', 'projects'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: search (name), is_public, trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Member::class);
|
||||||
|
|
||||||
|
$members = Member::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return MemberResource::collection($members);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(MemberRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Member::class);
|
||||||
|
|
||||||
|
return MemberResource::make($this->save(new Member, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Member $member): MemberResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $member);
|
||||||
|
|
||||||
|
return MemberResource::make($member->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(MemberRequest $request, Member $member): MemberResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $member);
|
||||||
|
|
||||||
|
return MemberResource::make($this->save($member, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Member $member): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $member);
|
||||||
|
|
||||||
|
$member->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Member $member): MemberResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $member);
|
||||||
|
|
||||||
|
$member->restore();
|
||||||
|
|
||||||
|
return MemberResource::make($member->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Member $member, MemberRequest $request): Member
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($member, $data): void {
|
||||||
|
$member->fill(TranslatablePayload::merge(
|
||||||
|
$member,
|
||||||
|
collect($data)->except('technology_ids')->all(),
|
||||||
|
$member->translatable,
|
||||||
|
));
|
||||||
|
$member->save();
|
||||||
|
|
||||||
|
if (array_key_exists('technology_ids', $data)) {
|
||||||
|
$member->technologies()->sync($data['technology_ids']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $member->load(self::WITH);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/Http/Controllers/Api/V1/MissingUrlController.php
Normal file
95
app/Http/Controllers/Api/V1/MissingUrlController.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\MissingUrlStatus;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\MissingUrlRedirectRequest;
|
||||||
|
use App\Http\Resources\MissingUrlResource;
|
||||||
|
use App\Http\Resources\RedirectResource;
|
||||||
|
use App\Models\MissingUrl;
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Addresses the public site was asked for and could not find, reported by the
|
||||||
|
* frontend. What can be done with one is what the panel does: ignore it, or
|
||||||
|
* give it a destination.
|
||||||
|
*/
|
||||||
|
class MissingUrlController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: status (pending by default), locale, search (path).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', MissingUrl::class);
|
||||||
|
|
||||||
|
$missing = MissingUrl::query()
|
||||||
|
->where('status', $request->string('status', MissingUrlStatus::Pending->value))
|
||||||
|
->when($request->filled('locale'), fn ($q) => $q->where('locale', $request->string('locale')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('path', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->orderByDesc('hits')
|
||||||
|
->orderByDesc('last_seen_at')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return MissingUrlResource::collection($missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(MissingUrl $missingUrl): MissingUrlResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $missingUrl);
|
||||||
|
|
||||||
|
return MissingUrlResource::make($missingUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only between pending and ignored: `resolved` is what writing a redirect
|
||||||
|
* makes it, and claiming it without one would be a lie the next sighting
|
||||||
|
* undoes anyway.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, MissingUrl $missingUrl): MissingUrlResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $missingUrl);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'status' => ['required', Rule::in([MissingUrlStatus::Pending->value, MissingUrlStatus::Ignored->value])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$missingUrl->update($data);
|
||||||
|
|
||||||
|
return MissingUrlResource::make($missingUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(MissingUrl $missingUrl): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $missingUrl);
|
||||||
|
|
||||||
|
$missingUrl->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give the address a destination. The report is marked resolved by the
|
||||||
|
* redirect being created, as from anywhere else.
|
||||||
|
*/
|
||||||
|
public function redirect(MissingUrlRedirectRequest $request, MissingUrl $missingUrl): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('redirect', $missingUrl);
|
||||||
|
|
||||||
|
$redirect = Redirect::create([
|
||||||
|
'from' => $missingUrl->path,
|
||||||
|
'is_enabled' => true,
|
||||||
|
...$request->validated(),
|
||||||
|
])->refresh();
|
||||||
|
|
||||||
|
return RedirectResource::make($redirect)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ class PageController extends Controller
|
|||||||
$locale = app()->getLocale();
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
$pages = Page::query()
|
$pages = Page::query()
|
||||||
->with(['category', 'author'])
|
->with(['category', 'author', 'featuredMedia'])
|
||||||
->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')))
|
||||||
@@ -67,7 +67,7 @@ class PageController extends Controller
|
|||||||
|
|
||||||
$page->save();
|
$page->save();
|
||||||
|
|
||||||
return PageResource::make($page->load(['category', 'author']))
|
return PageResource::make($page->load(['category', 'author', 'featuredMedia']))
|
||||||
->response()
|
->response()
|
||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ class PageController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorize('view', $page);
|
$this->authorize('view', $page);
|
||||||
|
|
||||||
return PageResource::make($page->load(['category', 'author']));
|
return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(PageRequest $request, Page $page): PageResource
|
public function update(PageRequest $request, Page $page): PageResource
|
||||||
@@ -91,7 +91,7 @@ class PageController extends Controller
|
|||||||
|
|
||||||
$page->save();
|
$page->save();
|
||||||
|
|
||||||
return PageResource::make($page->load(['category', 'author']));
|
return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Page $page): JsonResponse
|
public function destroy(Page $page): JsonResponse
|
||||||
@@ -109,7 +109,7 @@ class PageController extends Controller
|
|||||||
|
|
||||||
$page->restore();
|
$page->restore();
|
||||||
|
|
||||||
return PageResource::make($page->load(['category', 'author']));
|
return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
122
app/Http/Controllers/Api/V1/ProjectController.php
Normal file
122
app/Http/Controllers/Api/V1/ProjectController.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\ProjectRequest;
|
||||||
|
use App\Http\Resources\ProjectResource;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects, as the panel edits them — so the MCP server can load the
|
||||||
|
* collective's work without anyone copying it into a form by hand.
|
||||||
|
*/
|
||||||
|
class ProjectController extends Controller
|
||||||
|
{
|
||||||
|
/** Relations every answer carries, so a client can PATCH back what it read. */
|
||||||
|
protected const WITH = ['coverMedia', 'technologies', 'services', 'members'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: status, search (title in the active language), trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Project::class);
|
||||||
|
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
$projects = Project::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ProjectResource::collection($projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ProjectRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Project::class);
|
||||||
|
|
||||||
|
$project = $this->save(new Project, $request);
|
||||||
|
|
||||||
|
return ProjectResource::make($project)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Project $project): ProjectResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $project);
|
||||||
|
|
||||||
|
return ProjectResource::make($project->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ProjectRequest $request, Project $project): ProjectResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $project);
|
||||||
|
|
||||||
|
return ProjectResource::make($this->save($project, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Project $project): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $project);
|
||||||
|
|
||||||
|
$project->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Project $project): ProjectResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $project);
|
||||||
|
|
||||||
|
$project->restore();
|
||||||
|
|
||||||
|
return ProjectResource::make($project->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The record and its relations in one transaction: a project that saved
|
||||||
|
* but lost its team halfway would be worse than one that did not save.
|
||||||
|
*/
|
||||||
|
protected function save(Project $project, ProjectRequest $request): Project
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($project, $data): void {
|
||||||
|
$project->fill(TranslatablePayload::merge(
|
||||||
|
$project,
|
||||||
|
collect($data)->except(['technology_ids', 'service_ids', 'members'])->all(),
|
||||||
|
$project->translatable,
|
||||||
|
));
|
||||||
|
$project->save();
|
||||||
|
|
||||||
|
if (array_key_exists('technology_ids', $data)) {
|
||||||
|
$project->technologies()->sync($data['technology_ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('service_ids', $data)) {
|
||||||
|
$project->services()->sync($data['service_ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('members', $data)) {
|
||||||
|
$project->members()->sync(collect($data['members'])->mapWithKeys(
|
||||||
|
fn (array $member) => [$member['id'] => ['role' => $member['role'] ?? null]],
|
||||||
|
)->all());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $project->load(self::WITH);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/Http/Controllers/Api/V1/RedirectController.php
Normal file
77
app/Http/Controllers/Api/V1/RedirectController.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\RedirectRequest;
|
||||||
|
use App\Http\Resources\RedirectResource;
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Old addresses and where they go now.
|
||||||
|
*/
|
||||||
|
class RedirectController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: is_enabled, locale, automatic (left behind by a slug rename),
|
||||||
|
* search (either end).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Redirect::class);
|
||||||
|
|
||||||
|
$redirects = Redirect::query()
|
||||||
|
->when($request->has('is_enabled'), fn ($q) => $q->where('is_enabled', $request->boolean('is_enabled')))
|
||||||
|
->when($request->filled('locale'), fn ($q) => $q->where('locale', $request->string('locale')))
|
||||||
|
->when($request->has('automatic'), fn ($q) => $request->boolean('automatic')
|
||||||
|
? $q->whereNotNull('source_type')
|
||||||
|
: $q->whereNull('source_type'))
|
||||||
|
->when($request->filled('search'), function ($q) use ($request): void {
|
||||||
|
$term = '%'.$request->string('search').'%';
|
||||||
|
|
||||||
|
$q->where(fn ($q) => $q->where('from', 'like', $term)->orWhere('to', 'like', $term));
|
||||||
|
})
|
||||||
|
->latest()
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return RedirectResource::collection($redirects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(RedirectRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Redirect::class);
|
||||||
|
|
||||||
|
return RedirectResource::make(Redirect::create($request->validated())->refresh())->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Redirect $redirect): RedirectResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $redirect);
|
||||||
|
|
||||||
|
return RedirectResource::make($redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(RedirectRequest $request, Redirect $redirect): RedirectResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $redirect);
|
||||||
|
|
||||||
|
$redirect->update($request->validated());
|
||||||
|
|
||||||
|
return RedirectResource::make($redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Redirect $redirect): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $redirect);
|
||||||
|
|
||||||
|
$redirect->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Http/Controllers/Api/V1/RepositoryController.php
Normal file
87
app/Http/Controllers/Api/V1/RepositoryController.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\RepositoryRequest;
|
||||||
|
use App\Http\Resources\RepositoryResource;
|
||||||
|
use App\Jobs\SyncRepository;
|
||||||
|
use App\Models\Repository;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collective's git repositories. Only which repository, whose project and
|
||||||
|
* whether to show it are written here; everything else is what the provider
|
||||||
|
* last reported.
|
||||||
|
*/
|
||||||
|
class RepositoryController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: provider, project_id, show_publicly, failing, search (full name).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Repository::class);
|
||||||
|
|
||||||
|
$repositories = Repository::query()
|
||||||
|
->when($request->filled('provider'), fn ($q) => $q->where('provider', $request->string('provider')))
|
||||||
|
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
|
||||||
|
->when($request->has('show_publicly'), fn ($q) => $q->where('show_publicly', $request->boolean('show_publicly')))
|
||||||
|
->when($request->boolean('failing'), fn ($q) => $q->failing())
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('full_name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->orderBy('full_name')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return RepositoryResource::collection($repositories);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(RepositoryRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Repository::class);
|
||||||
|
|
||||||
|
return RepositoryResource::make(Repository::create($request->validated())->refresh())->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Repository $repository): RepositoryResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $repository);
|
||||||
|
|
||||||
|
return RepositoryResource::make($repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(RepositoryRequest $request, Repository $repository): RepositoryResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $repository);
|
||||||
|
|
||||||
|
$repository->update($request->validated());
|
||||||
|
|
||||||
|
return RepositoryResource::make($repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Repository $repository): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $repository);
|
||||||
|
|
||||||
|
$repository->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the provider again, on the queue: a sync spends a rate limit the
|
||||||
|
* whole application shares, and a caller should not wait on it.
|
||||||
|
*/
|
||||||
|
public function sync(Repository $repository): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('sync', $repository);
|
||||||
|
|
||||||
|
SyncRepository::dispatch($repository);
|
||||||
|
|
||||||
|
return RepositoryResource::make($repository)->response()->setStatusCode(202);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/Http/Controllers/Api/V1/ServiceController.php
Normal file
107
app/Http/Controllers/Api/V1/ServiceController.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\ServiceRequest;
|
||||||
|
use App\Http\Resources\ServiceResource;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the collective offers, as the panel edits it.
|
||||||
|
*/
|
||||||
|
class ServiceController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['technologies', 'projects'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: is_published, search (title in the active language), trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Service::class);
|
||||||
|
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
$services = Service::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when($request->has('is_published'), fn ($q) => $q->where('is_published', $request->boolean('is_published')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ServiceResource::collection($services);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ServiceRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Service::class);
|
||||||
|
|
||||||
|
return ServiceResource::make($this->save(new Service, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Service $service): ServiceResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $service);
|
||||||
|
|
||||||
|
return ServiceResource::make($service->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ServiceRequest $request, Service $service): ServiceResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $service);
|
||||||
|
|
||||||
|
return ServiceResource::make($this->save($service, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Service $service): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $service);
|
||||||
|
|
||||||
|
$service->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Service $service): ServiceResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $service);
|
||||||
|
|
||||||
|
$service->restore();
|
||||||
|
|
||||||
|
return ServiceResource::make($service->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Service $service, ServiceRequest $request): Service
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($service, $data): void {
|
||||||
|
$service->fill(TranslatablePayload::merge(
|
||||||
|
$service,
|
||||||
|
collect($data)->except(['technology_ids', 'project_ids'])->all(),
|
||||||
|
$service->translatable,
|
||||||
|
));
|
||||||
|
$service->save();
|
||||||
|
|
||||||
|
if (array_key_exists('technology_ids', $data)) {
|
||||||
|
$service->technologies()->sync($data['technology_ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('project_ids', $data)) {
|
||||||
|
$service->projects()->sync($data['project_ids']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $service->load(self::WITH);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Http/Controllers/Api/V1/SettingController.php
Normal file
56
app/Http/Controllers/Api/V1/SettingController.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\SettingResource;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Support\ContentVersion;
|
||||||
|
use App\Support\Maintenance;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime settings, as the panel's settings screen shows them: the ones that
|
||||||
|
* exist, each value as the JSON it is. None is created or deleted from here.
|
||||||
|
*/
|
||||||
|
class SettingController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Written by the application, never by hand: a hand-set content version
|
||||||
|
* would leave the frontend's cache serving stale pages or refetching them
|
||||||
|
* for nothing.
|
||||||
|
*/
|
||||||
|
protected const READ_ONLY = [ContentVersion::KEY];
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('settings.view');
|
||||||
|
|
||||||
|
return SettingResource::collection(Setting::query()->orderBy('key')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Setting $setting): SettingResource
|
||||||
|
{
|
||||||
|
$this->authorize('settings.view');
|
||||||
|
|
||||||
|
return SettingResource::make($setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Setting $setting): SettingResource
|
||||||
|
{
|
||||||
|
// Taking the site down is its own permission, in the panel as here.
|
||||||
|
$this->authorize($setting->key === Maintenance::KEY ? 'settings.maintenance' : 'settings.update');
|
||||||
|
|
||||||
|
abort_if(in_array($setting->key, self::READ_ONLY, true), 403, __('rules.settings.read_only'));
|
||||||
|
|
||||||
|
// `present` rather than `required`: null is a value a setting may hold.
|
||||||
|
$request->validate(['value' => ['present']]);
|
||||||
|
|
||||||
|
$setting->update(['value' => $request->input('value')]);
|
||||||
|
|
||||||
|
return SettingResource::make($setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Http/Controllers/Api/V1/TalkController.php
Normal file
81
app/Http/Controllers/Api/V1/TalkController.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\TalkRequest;
|
||||||
|
use App\Http\Resources\TalkResource;
|
||||||
|
use App\Models\Talk;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks given elsewhere. Deleted for good: there is no bin for them.
|
||||||
|
*/
|
||||||
|
class TalkController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: is_public, member_id, project_id, search (title or event).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Talk::class);
|
||||||
|
|
||||||
|
$talks = Talk::query()
|
||||||
|
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
|
||||||
|
->when($request->filled('member_id'), fn ($q) => $q->where('member_id', $request->integer('member_id')))
|
||||||
|
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
|
||||||
|
->when($request->filled('search'), function ($q) use ($request): void {
|
||||||
|
$term = '%'.$request->string('search').'%';
|
||||||
|
|
||||||
|
$q->where(fn ($q) => $q->where('title', 'like', $term)->orWhere('event', 'like', $term));
|
||||||
|
})
|
||||||
|
->recentFirst()
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return TalkResource::collection($talks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(TalkRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Talk::class);
|
||||||
|
|
||||||
|
return TalkResource::make($this->save(new Talk, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Talk $talk): TalkResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $talk);
|
||||||
|
|
||||||
|
return TalkResource::make($talk);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(TalkRequest $request, Talk $talk): TalkResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $talk);
|
||||||
|
|
||||||
|
return TalkResource::make($this->save($talk, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Talk $talk): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $talk);
|
||||||
|
|
||||||
|
$talk->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Talk $talk, TalkRequest $request): Talk
|
||||||
|
{
|
||||||
|
$talk->fill(TranslatablePayload::merge($talk, $request->validated(), $talk->translatable));
|
||||||
|
$talk->save();
|
||||||
|
|
||||||
|
return $talk;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/Http/Controllers/Api/V1/TechnologyController.php
Normal file
93
app/Http/Controllers/Api/V1/TechnologyController.php
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\TechnologyCategory;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\TechnologyRequest;
|
||||||
|
use App\Http\Resources\TechnologyResource;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The technologies the collective uses, and their place on the radar.
|
||||||
|
*
|
||||||
|
* Same rules as the panel: TechnologyPolicy refuses to delete one a project,
|
||||||
|
* member or call still points at.
|
||||||
|
*/
|
||||||
|
class TechnologyController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: category, radar_ring (`none` for those off the radar), search.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Technology::class);
|
||||||
|
|
||||||
|
$technologies = Technology::query()
|
||||||
|
->with('logoMedia')
|
||||||
|
->withCount('projects')
|
||||||
|
->when($request->filled('category'), fn ($q) => $q->where('category', $request->string('category')))
|
||||||
|
->when($request->filled('radar_ring'), fn ($q) => $request->string('radar_ring')->toString() === 'none'
|
||||||
|
? $q->whereNull('radar_ring')
|
||||||
|
: $q->where('radar_ring', $request->string('radar_ring')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return TechnologyResource::collection($technologies);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(TechnologyRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Technology::class);
|
||||||
|
|
||||||
|
$technology = new Technology;
|
||||||
|
$technology->fill(TranslatablePayload::merge($technology, $request->validated(), $technology->translatable));
|
||||||
|
// The column's default, and the panel's, stated here so the model
|
||||||
|
// knows it too: otherwise the answer to a create without a category
|
||||||
|
// would be missing the one the row was given.
|
||||||
|
$technology->category ??= TechnologyCategory::Tool;
|
||||||
|
$technology->save();
|
||||||
|
|
||||||
|
return TechnologyResource::make($this->loaded($technology))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Technology $technology): TechnologyResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $technology);
|
||||||
|
|
||||||
|
return TechnologyResource::make($this->loaded($technology));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(TechnologyRequest $request, Technology $technology): TechnologyResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $technology);
|
||||||
|
|
||||||
|
$technology->fill(TranslatablePayload::merge($technology, $request->validated(), $technology->translatable));
|
||||||
|
$technology->save();
|
||||||
|
|
||||||
|
return TechnologyResource::make($this->loaded($technology));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Technology $technology): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $technology);
|
||||||
|
|
||||||
|
$technology->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loaded(Technology $technology): Technology
|
||||||
|
{
|
||||||
|
return $technology->load('logoMedia')->loadCount('projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Http/Controllers/Api/V1/TestimonialController.php
Normal file
92
app/Http/Controllers/Api/V1/TestimonialController.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\TestimonialRequest;
|
||||||
|
use App\Http\Resources\TestimonialResource;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What people the collective worked with said about it.
|
||||||
|
*/
|
||||||
|
class TestimonialController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['avatarMedia'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: is_public, client_id, project_id, service_id, search (author), trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Testimonial::class);
|
||||||
|
|
||||||
|
$testimonials = Testimonial::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
|
||||||
|
->when($request->filled('client_id'), fn ($q) => $q->where('client_id', $request->integer('client_id')))
|
||||||
|
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
|
||||||
|
->when($request->filled('service_id'), fn ($q) => $q->where('service_id', $request->integer('service_id')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('author_name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->latest('id')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return TestimonialResource::collection($testimonials);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(TestimonialRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Testimonial::class);
|
||||||
|
|
||||||
|
return TestimonialResource::make($this->save(new Testimonial, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Testimonial $testimonial): TestimonialResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $testimonial);
|
||||||
|
|
||||||
|
return TestimonialResource::make($testimonial->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(TestimonialRequest $request, Testimonial $testimonial): TestimonialResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $testimonial);
|
||||||
|
|
||||||
|
return TestimonialResource::make($this->save($testimonial, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Testimonial $testimonial): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $testimonial);
|
||||||
|
|
||||||
|
$testimonial->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Testimonial $testimonial): TestimonialResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $testimonial);
|
||||||
|
|
||||||
|
$testimonial->restore();
|
||||||
|
|
||||||
|
return TestimonialResource::make($testimonial->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Testimonial $testimonial, TestimonialRequest $request): Testimonial
|
||||||
|
{
|
||||||
|
$testimonial->fill(TranslatablePayload::merge($testimonial, $request->validated(), $testimonial->translatable));
|
||||||
|
$testimonial->save();
|
||||||
|
|
||||||
|
return $testimonial->load(self::WITH);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Requests/Api/V1/ApplicationRequest.php
Normal file
32
app/Http/Requests/Api/V1/ApplicationRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationStatus;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the inbox lets anyone change about a submission: nothing the sender
|
||||||
|
* wrote, only how it is being handled.
|
||||||
|
*/
|
||||||
|
class ApplicationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => ['sometimes', Rule::enum(ApplicationStatus::class)],
|
||||||
|
'notes' => ['sometimes', 'nullable', 'string', 'max:10000'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Http/Requests/Api/V1/CallRequest.php
Normal file
58
app/Http/Requests/Api/V1/CallRequest.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\CallStatus;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's call form, as rules. `technology_ids` is replaced whole when
|
||||||
|
* sent.
|
||||||
|
*/
|
||||||
|
class CallRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public const SENIORITIES = ['any', 'junior', 'mid', 'senior'];
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$call = $this->route('call');
|
||||||
|
$creating = ! $call instanceof Call;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
new Translatable(requireDefaultLocale: $creating, max: 255),
|
||||||
|
new UniqueTranslatedSlug(Call::class, $call?->id),
|
||||||
|
],
|
||||||
|
'description' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'what_you_get' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
|
||||||
|
'status' => ['sometimes', Rule::enum(CallStatus::class)],
|
||||||
|
'hours_per_week' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:40'],
|
||||||
|
'duration_weeks' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
|
'seniority' => ['sometimes', 'nullable', Rule::in(self::SENIORITIES)],
|
||||||
|
'opens_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'closes_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:opens_at'],
|
||||||
|
|
||||||
|
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
'technology_ids' => ['sometimes', 'array'],
|
||||||
|
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Requests/Api/V1/ClientRequest.php
Normal file
47
app/Http/Requests/Api/V1/ClientRequest.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's client form, as rules.
|
||||||
|
*/
|
||||||
|
class ClientRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$client = $this->route('client');
|
||||||
|
$creating = ! $client instanceof Client;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
'alpha_dash:ascii',
|
||||||
|
Rule::unique('clients', 'slug')->ignore($client?->id),
|
||||||
|
],
|
||||||
|
'sector' => ['sometimes', 'nullable', new Translatable(max: 120)],
|
||||||
|
'logo' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
|
'url' => ['sometimes', 'nullable', 'url', 'max:2048'],
|
||||||
|
'is_public' => ['sometimes', 'boolean'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Requests/Api/V1/Concerns/GuardsPublication.php
Normal file
33
app/Http/Requests/Api/V1/Concerns/GuardsPublication.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishing is a permission of its own, as in the panel — where these fields
|
||||||
|
* are disabled for whoever lacks it. Here they are refused, so someone who may
|
||||||
|
* write a record still cannot put it on the site, or take it off, alone.
|
||||||
|
*/
|
||||||
|
trait GuardsPublication
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $fields
|
||||||
|
*/
|
||||||
|
protected function guardPublication(Validator $validator, string $permission, array $fields): void
|
||||||
|
{
|
||||||
|
$validator->after(function (Validator $validator) use ($permission, $fields): void {
|
||||||
|
if ($this->user()?->can($permission)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if ($this->has($field)) {
|
||||||
|
$validator->errors()->add($field, __('rules.publication.forbidden'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Http/Requests/Api/V1/MediaRequest.php
Normal file
46
app/Http/Requests/Api/V1/MediaRequest.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An image arrives one of two ways: as the request's `file` (multipart), or as
|
||||||
|
* a `url` the API downloads itself (JSON). Exactly one.
|
||||||
|
*
|
||||||
|
* What the declared type says is only a first filter. The controller looks at
|
||||||
|
* the bytes, because a client names the type of what it sends and a URL's
|
||||||
|
* server names the type of what it answers — and neither has to be telling
|
||||||
|
* the truth.
|
||||||
|
*/
|
||||||
|
class MediaRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Authorisation is the controller's, as everywhere else in this API — and
|
||||||
|
* it has to happen before a URL is fetched, which is after validation.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => [
|
||||||
|
'required_without:url',
|
||||||
|
'prohibits:url',
|
||||||
|
'file',
|
||||||
|
'max:'.(int) config('media.upload.max_kb'),
|
||||||
|
'mimetypes:'.implode(',', (array) config('media.upload.mimes')),
|
||||||
|
],
|
||||||
|
'url' => ['required_without:file', 'string', 'url:https', 'max:2048'],
|
||||||
|
'for' => ['sometimes', 'string', 'in:'.implode(',', array_keys((array) config('media.upload.for')))],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Http/Requests/Api/V1/MemberRequest.php
Normal file
66
app/Http/Requests/Api/V1/MemberRequest.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's member form, as rules. A name is a person's, the same in every
|
||||||
|
* language, and so is the slug built from it; only the headline and the bio
|
||||||
|
* are translatable.
|
||||||
|
*
|
||||||
|
* `links` is a label → URL map (`{"github": "https://…"}`). `technology_ids`
|
||||||
|
* is replaced whole when sent. Which projects someone is on is set from the
|
||||||
|
* project, where the role belongs.
|
||||||
|
*/
|
||||||
|
class MemberRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$member = $this->route('member');
|
||||||
|
$creating = ! $member instanceof Member;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
'alpha_dash:ascii',
|
||||||
|
Rule::unique('members', 'slug')->ignore($member?->id),
|
||||||
|
],
|
||||||
|
'headline' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
'bio' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
|
||||||
|
'avatar' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
|
// Never shown on the site; the panel keeps it for the collective.
|
||||||
|
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
|
||||||
|
'github_username' => ['sometimes', 'nullable', 'string', 'max:120'],
|
||||||
|
'links' => ['sometimes', 'nullable', 'array', 'max:10'],
|
||||||
|
'links.*' => ['nullable', 'url', 'max:2048'],
|
||||||
|
|
||||||
|
'is_public' => ['sometimes', 'boolean'],
|
||||||
|
'is_founder' => ['sometimes', 'boolean'],
|
||||||
|
'joined_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'left_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:joined_at'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
|
||||||
|
'technology_ids' => ['sometimes', 'array'],
|
||||||
|
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Requests/Api/V1/MissingUrlRedirectRequest.php
Normal file
68
app/Http/Requests/Api/V1/MissingUrlRedirectRequest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\MissingUrl;
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a missing address should go. The origin is the address itself.
|
||||||
|
*/
|
||||||
|
class MissingUrlRedirectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('to')) && filled($this->input('to'))) {
|
||||||
|
$this->merge(['to' => Redirect::normalise($this->input('to'))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The language it was seen in, unless told otherwise; null for all.
|
||||||
|
if (! $this->exists('locale')) {
|
||||||
|
$this->merge(['locale' => $this->missingUrl()->locale]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$path = $this->missingUrl()->path;
|
||||||
|
$locale = $this->input('locale');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'to' => ['required', 'string', 'max:500', Rule::notIn([$path])],
|
||||||
|
'status' => ['sometimes', 'integer', Rule::in([301, 302])],
|
||||||
|
'locale' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::in(array_keys((array) config('locales.supported'))),
|
||||||
|
// Already answered by a rule: edit that one instead.
|
||||||
|
function (string $attribute, mixed $value, \Closure $fail) use ($path, $locale): void {
|
||||||
|
$exists = Redirect::query()
|
||||||
|
->where('from', $path)
|
||||||
|
->when($locale === null, fn ($q) => $q->whereNull('locale'), fn ($q) => $q->where('locale', $locale))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$fail(__('validation.unique', ['attribute' => 'from']));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function missingUrl(): MissingUrl
|
||||||
|
{
|
||||||
|
return $this->route('missing_url');
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Http/Requests/Api/V1/ProjectRequest.php
Normal file
84
app/Http/Requests/Api/V1/ProjectRequest.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ProjectStatus;
|
||||||
|
use App\Http\Requests\Api\V1\Concerns\GuardsPublication;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's project form, as rules. On update every field is optional
|
||||||
|
* (PATCH semantics) but keeps the same shape.
|
||||||
|
*
|
||||||
|
* Relations travel as ids and are replaced whole when sent: `technology_ids`,
|
||||||
|
* `service_ids`, and `members` with each one's role on the project.
|
||||||
|
*/
|
||||||
|
class ProjectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use GuardsPublication;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$project = $this->route('project');
|
||||||
|
$creating = ! $project instanceof Project;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
new Translatable(requireDefaultLocale: $creating, max: 255),
|
||||||
|
new UniqueTranslatedSlug(Project::class, $project?->id),
|
||||||
|
],
|
||||||
|
'tagline' => ['sometimes', 'nullable', new Translatable(max: 300)],
|
||||||
|
'description' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'decisions' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'postmortem' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'meta_title' => ['sometimes', 'nullable', new Translatable(max: 60)],
|
||||||
|
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
|
||||||
|
|
||||||
|
'status' => ['sometimes', Rule::enum(ProjectStatus::class)],
|
||||||
|
'is_featured' => ['sometimes', 'boolean'],
|
||||||
|
'needs_help' => ['sometimes', 'boolean'],
|
||||||
|
'demo_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
|
||||||
|
'cover_image' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
|
'cover_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
'started_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'ended_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:started_at'],
|
||||||
|
'published_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'unpublish_at' => ['sometimes', 'nullable', 'date', 'after:published_at'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
|
||||||
|
'client_id' => ['sometimes', 'nullable', 'integer', Rule::exists('clients', 'id')->whereNull('deleted_at')],
|
||||||
|
'lead_member_id' => ['sometimes', 'nullable', 'integer', Rule::exists('members', 'id')->whereNull('deleted_at')],
|
||||||
|
|
||||||
|
'technology_ids' => ['sometimes', 'array'],
|
||||||
|
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
|
||||||
|
'service_ids' => ['sometimes', 'array'],
|
||||||
|
'service_ids.*' => ['integer', 'distinct', Rule::exists('services', 'id')->whereNull('deleted_at')],
|
||||||
|
'members' => ['sometimes', 'array'],
|
||||||
|
'members.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')],
|
||||||
|
'members.*.role' => ['nullable', 'string', 'max:120'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$this->guardPublication($validator, 'projects.publish', ['published_at', 'unpublish_at']);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Http/Requests/Api/V1/RedirectRequest.php
Normal file
67
app/Http/Requests/Api/V1/RedirectRequest.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's redirect form, as rules. Both ends are normalised before they
|
||||||
|
* are checked, so `/a/` and `/a` are the same rule however it was typed.
|
||||||
|
*/
|
||||||
|
class RedirectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
foreach (['from', 'to'] as $end) {
|
||||||
|
if (is_string($this->input($end)) && filled($this->input($end))) {
|
||||||
|
$this->merge([$end => Redirect::normalise($this->input($end))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$redirect = $this->route('redirect');
|
||||||
|
$creating = ! $redirect instanceof Redirect;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
$from = $this->input('from', $redirect?->from);
|
||||||
|
$locale = $this->exists('locale') ? $this->input('locale') : $redirect?->locale;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'from' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:500',
|
||||||
|
// One rule per address and language, as the table's index says.
|
||||||
|
Rule::unique('redirects', 'from')
|
||||||
|
->ignore($redirect?->id)
|
||||||
|
->where(fn ($q) => $locale === null ? $q->whereNull('locale') : $q->where('locale', $locale)),
|
||||||
|
],
|
||||||
|
'to' => [$sometimes, 'string', 'max:500', Rule::notIn([$from])],
|
||||||
|
'status' => ['sometimes', 'integer', Rule::in([301, 302])],
|
||||||
|
'locale' => ['sometimes', 'nullable', 'string', Rule::in(array_keys((array) config('locales.supported')))],
|
||||||
|
'is_enabled' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return ['to.not_in' => __('validation.different', ['attribute' => 'to', 'other' => 'from'])];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Requests/Api/V1/RepositoryRequest.php
Normal file
50
app/Http/Requests/Api/V1/RepositoryRequest.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\GitProvider;
|
||||||
|
use App\Models\Repository;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's repository form, as rules. The synced columns are not here on
|
||||||
|
* purpose: nothing a caller sends may fake a repository's numbers.
|
||||||
|
*/
|
||||||
|
class RepositoryRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$repository = $this->route('repository');
|
||||||
|
$creating = ! $repository instanceof Repository;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
$provider = $this->input('provider', $repository?->provider?->value);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'provider' => [$sometimes, Rule::enum(GitProvider::class)],
|
||||||
|
'full_name' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
'regex:/^[\w.-]+\/[\w.-]+$|^[\w.-]+$/',
|
||||||
|
Rule::unique('repositories', 'full_name')
|
||||||
|
->ignore($repository?->id)
|
||||||
|
->where('provider', $provider),
|
||||||
|
],
|
||||||
|
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
'is_primary' => ['sometimes', 'boolean'],
|
||||||
|
'show_publicly' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Requests/Api/V1/ServiceRequest.php
Normal file
68
app/Http/Requests/Api/V1/ServiceRequest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Requests\Api\V1\Concerns\GuardsPublication;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's service form, as rules. `technology_ids` and `project_ids` are
|
||||||
|
* replaced whole when sent.
|
||||||
|
*/
|
||||||
|
class ServiceRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use GuardsPublication;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$service = $this->route('service');
|
||||||
|
$creating = ! $service instanceof Service;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
new Translatable(requireDefaultLocale: $creating, max: 255),
|
||||||
|
new UniqueTranslatedSlug(Service::class, $service?->id),
|
||||||
|
],
|
||||||
|
'tagline' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
'description' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'meta_title' => ['sometimes', 'nullable', new Translatable(max: 60)],
|
||||||
|
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
|
||||||
|
|
||||||
|
// A name from the icon set the frontend resolves, e.g. `support`.
|
||||||
|
'icon' => ['sometimes', 'nullable', 'string', 'max:120'],
|
||||||
|
'is_featured' => ['sometimes', 'boolean'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'is_published' => ['sometimes', 'boolean'],
|
||||||
|
'published_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'unpublish_at' => ['sometimes', 'nullable', 'date', 'after:published_at'],
|
||||||
|
|
||||||
|
'technology_ids' => ['sometimes', 'array'],
|
||||||
|
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
|
||||||
|
'project_ids' => ['sometimes', 'array'],
|
||||||
|
'project_ids.*' => ['integer', 'distinct', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$this->guardPublication($validator, 'services.publish', ['is_published', 'published_at', 'unpublish_at']);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Http/Requests/Api/V1/TalkRequest.php
Normal file
45
app/Http/Requests/Api/V1/TalkRequest.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Talk;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's talk form, as rules. The title is the one the talk was given
|
||||||
|
* under, in `language`; only the summary is translated.
|
||||||
|
*/
|
||||||
|
class TalkRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$creating = ! $this->route('talk') instanceof Talk;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'language' => [$sometimes, 'string', Rule::in(array_keys((array) config('locales.supported')))],
|
||||||
|
'summary' => ['sometimes', 'nullable', new Translatable(max: 500)],
|
||||||
|
'event' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'city' => ['sometimes', 'nullable', 'string', 'max:120'],
|
||||||
|
'held_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'video_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
|
||||||
|
'slides_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
|
||||||
|
'member_id' => ['sometimes', 'nullable', 'integer', Rule::exists('members', 'id')->whereNull('deleted_at')],
|
||||||
|
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
'is_public' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Http/Requests/Api/V1/TechnologyRequest.php
Normal file
59
app/Http/Requests/Api/V1/TechnologyRequest.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\RadarRing;
|
||||||
|
use App\Enums\TechnologyCategory;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's technology form, as rules. On update every field is optional
|
||||||
|
* (PATCH semantics) but keeps the same shape.
|
||||||
|
*
|
||||||
|
* `name` and `slug` are plain strings: a technology's name is a proper noun,
|
||||||
|
* the same in every language. Only the prose about it is translatable.
|
||||||
|
*/
|
||||||
|
class TechnologyRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$technology = $this->route('technology');
|
||||||
|
$creating = ! $technology instanceof Technology;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => [$sometimes, 'string', 'max:120'],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:120',
|
||||||
|
'alpha_dash:ascii',
|
||||||
|
Rule::unique('technologies', 'slug')->ignore($technology?->id),
|
||||||
|
],
|
||||||
|
'description' => ['sometimes', 'nullable', new Translatable(max: 1000)],
|
||||||
|
'radar_note' => ['sometimes', 'nullable', new Translatable(max: 1000)],
|
||||||
|
|
||||||
|
'category' => ['sometimes', Rule::enum(TechnologyCategory::class)],
|
||||||
|
// Null keeps it off the radar while projects can still be tagged with it.
|
||||||
|
'radar_ring' => ['sometimes', 'nullable', Rule::enum(RadarRing::class)],
|
||||||
|
'color' => ['sometimes', 'nullable', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
||||||
|
'logo' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
// 255, not the panel's 2048: that is the column's width.
|
||||||
|
'website' => ['sometimes', 'nullable', 'url', 'max:255'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Requests/Api/V1/TestimonialRequest.php
Normal file
47
app/Http/Requests/Api/V1/TestimonialRequest.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's testimonial form, as rules.
|
||||||
|
*
|
||||||
|
* `original_locale` says which language the person actually spoke in; the
|
||||||
|
* other entries of `quote` are translations of it.
|
||||||
|
*/
|
||||||
|
class TestimonialRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$creating = ! $this->route('testimonial') instanceof Testimonial;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'quote' => [$sometimes, new Translatable(max: 1000)],
|
||||||
|
'original_locale' => [$sometimes, 'string', Rule::in(array_keys((array) config('locales.supported')))],
|
||||||
|
'author_name' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'author_role' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
'avatar' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
|
'client_id' => ['sometimes', 'nullable', 'integer', Rule::exists('clients', 'id')->whereNull('deleted_at')],
|
||||||
|
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
'service_id' => ['sometimes', 'nullable', 'integer', Rule::exists('services', 'id')->whereNull('deleted_at')],
|
||||||
|
'is_public' => ['sometimes', 'boolean'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'given_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Resources/ApplicationResource.php
Normal file
49
app/Http/Resources/ApplicationResource.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Application
|
||||||
|
*/
|
||||||
|
class ApplicationResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* `ip_hash` stays out: it exists to spot the same sender twice, not to be
|
||||||
|
* handed around.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'kind' => $this->kind->value,
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'call_id' => $this->call_id,
|
||||||
|
'subject' => $this->resource->subject(),
|
||||||
|
'name' => $this->name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'organisation' => $this->organisation,
|
||||||
|
'message' => $this->message,
|
||||||
|
'hours_per_week' => $this->hours_per_week,
|
||||||
|
'links' => (object) ($this->links ?? []),
|
||||||
|
'locale' => $this->locale,
|
||||||
|
'notes' => $this->notes,
|
||||||
|
'handled_by' => $this->whenLoaded('handler', fn () => $this->handler ? [
|
||||||
|
'id' => $this->handler->id,
|
||||||
|
'name' => $this->handler->name,
|
||||||
|
] : null),
|
||||||
|
'handled_at' => $this->handled_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Resources/CallResource.php
Normal file
49
app/Http/Resources/CallResource.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Call
|
||||||
|
*/
|
||||||
|
class CallResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
|
||||||
|
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||||
|
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'hours_per_week' => $this->hours_per_week,
|
||||||
|
'duration_weeks' => $this->duration_weeks,
|
||||||
|
'seniority' => $this->seniority,
|
||||||
|
'opens_at' => $this->opens_at?->toDateString(),
|
||||||
|
'closes_at' => $this->closes_at?->toDateString(),
|
||||||
|
'project_id' => $this->project_id,
|
||||||
|
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
|
||||||
|
'applications_count' => $this->whenCounted('applications'),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Http/Resources/ClientResource.php
Normal file
54
app/Http/Resources/ClientResource.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Support\ImagePayload;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Client
|
||||||
|
*/
|
||||||
|
class ClientResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
'name' => $this->name,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
|
||||||
|
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||||
|
|
||||||
|
'logo' => $this->logo,
|
||||||
|
'logo_media' => ImagePayload::make(
|
||||||
|
$this->logo,
|
||||||
|
$this->resource->relationLoaded('logoMedia') ? $this->resource->getRelation('logoMedia') : null,
|
||||||
|
$this->name,
|
||||||
|
),
|
||||||
|
'url' => $this->url,
|
||||||
|
'is_public' => (bool) $this->is_public,
|
||||||
|
'position' => $this->position,
|
||||||
|
'projects_count' => $this->whenCounted('projects'),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Http/Resources/MediaResource.php
Normal file
43
app/Http/Resources/MediaResource.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Media;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Media
|
||||||
|
*/
|
||||||
|
class MediaResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
// What a record stores — `featured_image` on a page — and so the one
|
||||||
|
// field a caller has to keep.
|
||||||
|
'path' => $this->path,
|
||||||
|
'url' => $this->url(),
|
||||||
|
'mime' => $this->mime,
|
||||||
|
'size' => $this->size,
|
||||||
|
'width' => $this->width,
|
||||||
|
'height' => $this->height,
|
||||||
|
// False until the queue has derived the smaller versions and the
|
||||||
|
// placeholder. The image serves either way.
|
||||||
|
'processed' => $this->isProcessed(),
|
||||||
|
'error' => $this->error,
|
||||||
|
// Only in the library, where it decides whether a file may go.
|
||||||
|
'referenced' => $this->when(
|
||||||
|
array_key_exists('referenced', $this->resource->getAttributes()),
|
||||||
|
fn () => (bool) $this->resource->getAttribute('referenced'),
|
||||||
|
),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/Http/Resources/MemberResource.php
Normal file
65
app/Http/Resources/MemberResource.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Support\ImagePayload;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Member
|
||||||
|
*/
|
||||||
|
class MemberResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
'name' => $this->name,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
|
||||||
|
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||||
|
|
||||||
|
'avatar' => $this->avatar,
|
||||||
|
'avatar_media' => ImagePayload::make(
|
||||||
|
$this->avatar,
|
||||||
|
$this->resource->relationLoaded('avatarMedia') ? $this->resource->getRelation('avatarMedia') : null,
|
||||||
|
$this->name,
|
||||||
|
),
|
||||||
|
'email' => $this->email,
|
||||||
|
'github_username' => $this->github_username,
|
||||||
|
'links' => (object) ($this->links ?? []),
|
||||||
|
'is_public' => (bool) $this->is_public,
|
||||||
|
'is_founder' => (bool) $this->is_founder,
|
||||||
|
'joined_at' => $this->joined_at?->toDateString(),
|
||||||
|
'left_at' => $this->left_at?->toDateString(),
|
||||||
|
'position' => $this->position,
|
||||||
|
|
||||||
|
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
|
||||||
|
'projects' => $this->whenLoaded('projects', fn () => $this->projects->map(fn ($project) => [
|
||||||
|
'id' => $project->id,
|
||||||
|
'title' => $project->getTranslation('title', $locale),
|
||||||
|
'role' => $project->pivot->role,
|
||||||
|
])->values()),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Resources/MissingUrlResource.php
Normal file
35
app/Http/Resources/MissingUrlResource.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\MissingUrl;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin MissingUrl
|
||||||
|
*/
|
||||||
|
class MissingUrlResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'path' => $this->path,
|
||||||
|
'locale' => $this->locale,
|
||||||
|
'referrer' => $this->referrer,
|
||||||
|
// Our own page links here: more urgent than someone else's stale link.
|
||||||
|
'internal_referrer' => $this->resource->isInternalReferrer(),
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'hits' => (int) $this->hits,
|
||||||
|
'first_seen_at' => $this->first_seen_at?->toIso8601String(),
|
||||||
|
'last_seen_at' => $this->last_seen_at?->toIso8601String(),
|
||||||
|
'resolved_at' => $this->resolved_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Http/Resources/ProjectResource.php
Normal file
70
app/Http/Resources/ProjectResource.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Support\ImagePayload;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Project
|
||||||
|
*/
|
||||||
|
class ProjectResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
$resolved = TranslatablePayload::resolve($this->resource, $this->translatable, $locale);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
|
||||||
|
...$resolved,
|
||||||
|
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'is_featured' => (bool) $this->is_featured,
|
||||||
|
'needs_help' => (bool) $this->needs_help,
|
||||||
|
'demo_url' => $this->demo_url,
|
||||||
|
// The stored path, which is what a client PATCHes back; what was
|
||||||
|
// derived from it travels alongside, as on a page.
|
||||||
|
'cover_image' => $this->cover_image,
|
||||||
|
'cover_image_media' => ImagePayload::make(
|
||||||
|
$this->cover_image,
|
||||||
|
$this->resource->relationLoaded('coverMedia') ? $this->resource->getRelation('coverMedia') : null,
|
||||||
|
$resolved['cover_image_alt'] ?? null,
|
||||||
|
),
|
||||||
|
'started_at' => $this->started_at?->toDateString(),
|
||||||
|
'ended_at' => $this->ended_at?->toDateString(),
|
||||||
|
'published_at' => $this->published_at?->toIso8601String(),
|
||||||
|
'unpublish_at' => $this->unpublish_at?->toIso8601String(),
|
||||||
|
'position' => $this->position,
|
||||||
|
|
||||||
|
'client_id' => $this->client_id,
|
||||||
|
'lead_member_id' => $this->lead_member_id,
|
||||||
|
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
|
||||||
|
'service_ids' => $this->whenLoaded('services', fn () => $this->services->pluck('id')->values()),
|
||||||
|
'members' => $this->whenLoaded('members', fn () => $this->members->map(fn ($member) => [
|
||||||
|
'id' => $member->id,
|
||||||
|
'name' => $member->name,
|
||||||
|
'role' => $member->pivot->role,
|
||||||
|
])->values()),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Resources/RedirectResource.php
Normal file
35
app/Http/Resources/RedirectResource.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Redirect
|
||||||
|
*/
|
||||||
|
class RedirectResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'from' => $this->from,
|
||||||
|
'to' => $this->to,
|
||||||
|
'status' => $this->status,
|
||||||
|
'locale' => $this->locale,
|
||||||
|
'is_enabled' => (bool) $this->is_enabled,
|
||||||
|
'automatic' => $this->resource->wasAutomatic(),
|
||||||
|
'hits' => (int) $this->hits,
|
||||||
|
'last_used_at' => $this->last_used_at?->toIso8601String(),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Resources/RepositoryResource.php
Normal file
51
app/Http/Resources/RepositoryResource.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Repository;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Repository
|
||||||
|
*/
|
||||||
|
class RepositoryResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'provider' => $this->provider->value,
|
||||||
|
'full_name' => $this->full_name,
|
||||||
|
'project_id' => $this->project_id,
|
||||||
|
'is_primary' => (bool) $this->is_primary,
|
||||||
|
'show_publicly' => (bool) $this->show_publicly,
|
||||||
|
|
||||||
|
// What the provider last reported.
|
||||||
|
'html_url' => $this->html_url,
|
||||||
|
'description' => $this->description,
|
||||||
|
'homepage' => $this->homepage,
|
||||||
|
'default_branch' => $this->default_branch,
|
||||||
|
'primary_language' => $this->primary_language,
|
||||||
|
'license' => $this->license,
|
||||||
|
'topics' => $this->topics ?? [],
|
||||||
|
'stars' => (int) $this->stars,
|
||||||
|
'forks' => (int) $this->forks,
|
||||||
|
'open_issues_count' => (int) $this->open_issues_count,
|
||||||
|
'is_archived' => (bool) $this->is_archived,
|
||||||
|
'pushed_at' => $this->pushed_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'last_synced_at' => $this->last_synced_at?->toIso8601String(),
|
||||||
|
'sync_error' => $this->sync_error,
|
||||||
|
'sync_failures' => (int) $this->sync_failures,
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Resources/ServiceResource.php
Normal file
50
app/Http/Resources/ServiceResource.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Service
|
||||||
|
*/
|
||||||
|
class ServiceResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
|
||||||
|
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||||
|
|
||||||
|
'icon' => $this->icon,
|
||||||
|
'is_featured' => (bool) $this->is_featured,
|
||||||
|
'is_published' => (bool) $this->is_published,
|
||||||
|
'published_at' => $this->published_at?->toIso8601String(),
|
||||||
|
'unpublish_at' => $this->unpublish_at?->toIso8601String(),
|
||||||
|
'position' => $this->position,
|
||||||
|
|
||||||
|
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
|
||||||
|
'project_ids' => $this->whenLoaded('projects', fn () => $this->projects->pluck('id')->values()),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Resources/SettingResource.php
Normal file
27
app/Http/Resources/SettingResource.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Setting
|
||||||
|
*/
|
||||||
|
class SettingResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'key' => $this->key,
|
||||||
|
'value' => $this->value,
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Resources/TalkResource.php
Normal file
50
app/Http/Resources/TalkResource.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Talk;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Talk
|
||||||
|
*/
|
||||||
|
class TalkResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
'title' => $this->title,
|
||||||
|
'language' => $this->language,
|
||||||
|
|
||||||
|
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||||
|
|
||||||
|
'event' => $this->event,
|
||||||
|
'city' => $this->city,
|
||||||
|
'held_at' => $this->held_at?->toDateString(),
|
||||||
|
'video_url' => $this->video_url,
|
||||||
|
'slides_url' => $this->slides_url,
|
||||||
|
'member_id' => $this->member_id,
|
||||||
|
'project_id' => $this->project_id,
|
||||||
|
'is_public' => (bool) $this->is_public,
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Http/Resources/TechnologyResource.php
Normal file
56
app/Http/Resources/TechnologyResource.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Support\ImagePayload;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Technology
|
||||||
|
*/
|
||||||
|
class TechnologyResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
'name' => $this->name,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
|
||||||
|
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||||
|
|
||||||
|
'category' => $this->category->value,
|
||||||
|
'radar_ring' => $this->radar_ring?->value,
|
||||||
|
'color' => $this->color,
|
||||||
|
// The stored path, which is what a client PATCHes back; what was
|
||||||
|
// derived from it travels alongside, as on a page.
|
||||||
|
'logo' => $this->logo,
|
||||||
|
'logo_media' => ImagePayload::make(
|
||||||
|
$this->logo,
|
||||||
|
$this->resource->relationLoaded('logoMedia') ? $this->resource->getRelation('logoMedia') : null,
|
||||||
|
$this->name,
|
||||||
|
),
|
||||||
|
'website' => $this->website,
|
||||||
|
'position' => $this->position,
|
||||||
|
'projects_count' => $this->whenCounted('projects'),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Http/Resources/TestimonialResource.php
Normal file
56
app/Http/Resources/TestimonialResource.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Support\ImagePayload;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Testimonial
|
||||||
|
*/
|
||||||
|
class TestimonialResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
|
||||||
|
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||||
|
|
||||||
|
'original_locale' => $this->original_locale,
|
||||||
|
'author_name' => $this->author_name,
|
||||||
|
'avatar' => $this->avatar,
|
||||||
|
'avatar_media' => ImagePayload::make(
|
||||||
|
$this->avatar,
|
||||||
|
$this->resource->relationLoaded('avatarMedia') ? $this->resource->getRelation('avatarMedia') : null,
|
||||||
|
$this->author_name,
|
||||||
|
),
|
||||||
|
'client_id' => $this->client_id,
|
||||||
|
'project_id' => $this->project_id,
|
||||||
|
'service_id' => $this->service_id,
|
||||||
|
'is_public' => (bool) $this->is_public,
|
||||||
|
'position' => $this->position,
|
||||||
|
'given_at' => $this->given_at?->toDateString(),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,6 +183,7 @@ class Media extends Model
|
|||||||
[Member::class, 'avatar'],
|
[Member::class, 'avatar'],
|
||||||
[Client::class, 'logo'],
|
[Client::class, 'logo'],
|
||||||
[Testimonial::class, 'avatar'],
|
[Testimonial::class, 'avatar'],
|
||||||
|
[Technology::class, 'logo'],
|
||||||
[ImageAttachment::class, 'path'],
|
[ImageAttachment::class, 'path'],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -198,9 +199,52 @@ class Media extends Model
|
|||||||
$paths = [...$paths, ...$query->pluck($column)->all()];
|
$paths = [...$paths, ...$query->pluck($column)->all()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Images placed inside rich text — attached in the panel's editor, or
|
||||||
|
// uploaded through the API and written into a body as `<img src>`. No
|
||||||
|
// column holds their path, so without this they would look orphaned
|
||||||
|
// and `content:prune --media` would delete them from under the page.
|
||||||
|
$richText = [
|
||||||
|
[Page::class, ['body']],
|
||||||
|
[Project::class, ['description', 'decisions', 'postmortem']],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($richText as [$model, $columns]) {
|
||||||
|
$model::query()
|
||||||
|
->withTrashed()
|
||||||
|
->select(['id', ...$columns])
|
||||||
|
->chunkById(200, function ($records) use ($columns, &$paths): void {
|
||||||
|
foreach ($records as $record) {
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
foreach ($record->getTranslations($column) as $html) {
|
||||||
|
$paths = [...$paths, ...static::pathsEmbeddedIn((string) $html)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return array_values(array_unique(array_filter($paths)));
|
return array_values(array_unique(array_filter($paths)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored paths of every image on the media disk a piece of HTML links
|
||||||
|
* to, whether written with the disk's absolute URL or its path alone.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function pathsEmbeddedIn(string $html): array
|
||||||
|
{
|
||||||
|
$base = (string) parse_url(Storage::disk((string) config('media.disk', 'public'))->url(''), PHP_URL_PATH);
|
||||||
|
|
||||||
|
if ($base === '' || ! str_contains($html, $base)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
preg_match_all('#'.preg_quote(rtrim($base, '/'), '#').'/([^"\'\s?\#<>]+)#', $html, $matches);
|
||||||
|
|
||||||
|
return array_map('rawurldecode', $matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether any record still points at this file.
|
* Whether any record still points at this file.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -21,6 +21,20 @@ class MediaPolicy extends ResourcePolicy
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploading through the API, which has no form to attach a file to.
|
||||||
|
*
|
||||||
|
* Still true to the rule above in spirit: an upload says which kind of
|
||||||
|
* record it is for — a page, a technology — and whoever may write that kind
|
||||||
|
* of record may upload its image. Nobody else, whatever their `media.*`
|
||||||
|
* permissions, which only ever described looking after files that already
|
||||||
|
* exist.
|
||||||
|
*/
|
||||||
|
public function upload(User $user, string $for = 'pages'): bool
|
||||||
|
{
|
||||||
|
return $user->can("{$for}.create") || $user->can("{$for}.update");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A file something still points at cannot be deleted from here: doing so
|
* A file something still points at cannot be deleted from here: doing so
|
||||||
* would leave a broken image on the public site, which is precisely what
|
* would leave a broken image on the public site, which is precisely what
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)
|
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)
|
||||||
->by($request->user()?->id ?: $request->ip()));
|
->by($request->user()?->id ?: $request->ip()));
|
||||||
|
|
||||||
|
// Backs POST /api/v1/auth/token. Budgeted per account and address
|
||||||
|
// rather than per address alone, because the deployed MCP server
|
||||||
|
// (mcp/) signs everyone in from one container: per IP, one person's
|
||||||
|
// wrong password — or a stranger's guesses — would lock every other
|
||||||
|
// user out of it. The per-address ceiling still bounds how many
|
||||||
|
// accounts a single client can try.
|
||||||
|
RateLimiter::for('auth-token', fn (Request $request) => [
|
||||||
|
Limit::perMinute(6)->by('auth-token:'.sha1(
|
||||||
|
Str::lower((string) ($request->input('username') ?: $request->input('refresh_token')))
|
||||||
|
.'|'.$request->ip()
|
||||||
|
)),
|
||||||
|
|
||||||
|
Limit::perMinute(30)->by('auth-token-ip:'.$request->ip()),
|
||||||
|
]);
|
||||||
|
|
||||||
// The public API is a different problem. The public frontend renders
|
// The public API is a different problem. The public frontend renders
|
||||||
// server-side, so every visitor's page view arrives from the same IP:
|
// server-side, so every visitor's page view arrives from the same IP:
|
||||||
// a per-IP budget sized for one person would throttle the entire
|
// a per-IP budget sized for one person would throttle the entire
|
||||||
|
|||||||
@@ -86,14 +86,6 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
])
|
])
|
||||||
|
|
||||||
// Language switcher, rendered next to the user menu.
|
// Language switcher, rendered next to the user menu.
|
||||||
// First render hook to fire on every page, panel and login alike:
|
|
||||||
// the one place that has to be true regardless of which screen
|
|
||||||
// somebody lands on first.
|
|
||||||
->renderHook(
|
|
||||||
PanelsRenderHook::BODY_START,
|
|
||||||
fn (): string => view('filament.partials.environment-banner')->render(),
|
|
||||||
)
|
|
||||||
|
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::USER_MENU_BEFORE,
|
PanelsRenderHook::USER_MENU_BEFORE,
|
||||||
fn (): string => view('filament.partials.language-switcher')->render(),
|
fn (): string => view('filament.partials.language-switcher')->render(),
|
||||||
|
|||||||
26
app/Services/Media/HostResolver.php
Normal file
26
app/Services/Media/HostResolver.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Media;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every address a hostname resolves to, IPv4 and IPv6.
|
||||||
|
*
|
||||||
|
* Its own class so the tests can answer for hosts that do not exist, and so
|
||||||
|
* `RemoteImage` can refuse a name that points anywhere private before a single
|
||||||
|
* byte is requested.
|
||||||
|
*/
|
||||||
|
class HostResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function resolve(string $host): array
|
||||||
|
{
|
||||||
|
$v4 = gethostbynamel($host) ?: [];
|
||||||
|
$v6 = array_column(@dns_get_record($host, DNS_AAAA) ?: [], 'ipv6');
|
||||||
|
|
||||||
|
return array_values(array_unique([...$v4, ...$v6]));
|
||||||
|
}
|
||||||
|
}
|
||||||
149
app/Services/Media/RemoteImage.php
Normal file
149
app/Services/Media/RemoteImage.php
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Media;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads an image from a URL someone handed the API, without letting that
|
||||||
|
* URL reach anything it should not.
|
||||||
|
*
|
||||||
|
* The request is made by this server, from inside the stack's network, so an
|
||||||
|
* unchecked URL is a way to make it fetch `http://postgres:5432`, the cloud
|
||||||
|
* metadata service, or the application itself. Hence:
|
||||||
|
*
|
||||||
|
* - HTTPS only.
|
||||||
|
* - Every address the host resolves to must be a public one — a single private
|
||||||
|
* address among several is enough to refuse.
|
||||||
|
* - The connection is pinned to the address that was checked (CURLOPT_RESOLVE),
|
||||||
|
* so a DNS answer that changes between the check and the request — DNS
|
||||||
|
* rebinding — cannot swap in a private one.
|
||||||
|
* - Redirects are not followed: the target of one would need all of the above
|
||||||
|
* again, and "use the final address" is an easy thing to ask for.
|
||||||
|
* - The body is read to a ceiling and no further, whatever the headers claim.
|
||||||
|
*
|
||||||
|
* It says who is asking. Hosts such as Wikimedia refuse Guzzle's anonymous
|
||||||
|
* default outright, and a User-Agent with a contact address is what their
|
||||||
|
* policies ask of anything that fetches automatically.
|
||||||
|
*/
|
||||||
|
class RemoteImage
|
||||||
|
{
|
||||||
|
public function __construct(protected HostResolver $resolver) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string The file's bytes.
|
||||||
|
*
|
||||||
|
* @throws ValidationException With the reason, on the `url` field.
|
||||||
|
*/
|
||||||
|
public function fetch(string $url): string
|
||||||
|
{
|
||||||
|
$parts = parse_url($url);
|
||||||
|
$host = $parts['host'] ?? null;
|
||||||
|
|
||||||
|
if (($parts['scheme'] ?? null) !== 'https' || blank($host)) {
|
||||||
|
$this->fail('https_only');
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = trim((string) $host, '[]');
|
||||||
|
$port = (int) ($parts['port'] ?? 443);
|
||||||
|
$address = $this->publicAddressFor($host);
|
||||||
|
$maxBytes = (int) config('media.upload.max_kb') * 1024;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withOptions([
|
||||||
|
'allow_redirects' => false,
|
||||||
|
'stream' => true,
|
||||||
|
'connect_timeout' => 5,
|
||||||
|
'timeout' => (int) config('media.upload.remote_timeout', 15),
|
||||||
|
'curl' => [CURLOPT_RESOLVE => [sprintf(
|
||||||
|
'%s:%d:%s',
|
||||||
|
$host,
|
||||||
|
$port,
|
||||||
|
str_contains($address, ':') ? "[{$address}]" : $address,
|
||||||
|
)]],
|
||||||
|
])
|
||||||
|
->withHeaders(['Accept' => 'image/*'])
|
||||||
|
->withUserAgent($this->userAgent())
|
||||||
|
->get($url);
|
||||||
|
} catch (ConnectionException) {
|
||||||
|
$this->fail('unreachable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->redirect()) {
|
||||||
|
$this->fail('redirect');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->status() !== 200) {
|
||||||
|
$this->fail('status', ['status' => $response->status()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $response->header('Content-Length') > $maxBytes) {
|
||||||
|
$this->fail('too_large', ['max' => (int) config('media.upload.max_kb')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $response->toPsrResponse()->getBody();
|
||||||
|
$contents = '';
|
||||||
|
|
||||||
|
while (! $body->eof()) {
|
||||||
|
$contents .= $body->read(65536);
|
||||||
|
|
||||||
|
if (strlen($contents) > $maxBytes) {
|
||||||
|
$this->fail('too_large', ['max' => (int) config('media.upload.max_kb')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `pireon-admin/1.0 (+https://admin.example.org; media fetcher)`: a product
|
||||||
|
* token without spaces, and the installation's own address to reach it by.
|
||||||
|
*/
|
||||||
|
protected function userAgent(): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'%s/1.0 (+%s; media fetcher)',
|
||||||
|
Str::slug((string) config('app.name')) ?: 'landing',
|
||||||
|
rtrim((string) config('app.url'), '/'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address to connect to, once every address the host has is known to
|
||||||
|
* be public.
|
||||||
|
*/
|
||||||
|
protected function publicAddressFor(string $host): string
|
||||||
|
{
|
||||||
|
$addresses = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : $this->resolver->resolve($host);
|
||||||
|
|
||||||
|
if ($addresses === []) {
|
||||||
|
$this->fail('unresolvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($addresses as $address) {
|
||||||
|
// GLOBAL_RANGE is the strict one: besides the private and reserved
|
||||||
|
// blocks it refuses shared (100.64/10), link-local and the
|
||||||
|
// IPv4-mapped IPv6 spelling of any of them.
|
||||||
|
if (! filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)) {
|
||||||
|
$this->fail('private');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $addresses[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $replace
|
||||||
|
*/
|
||||||
|
protected function fail(string $reason, array $replace = []): never
|
||||||
|
{
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'url' => [__("rules.media.remote.{$reason}", $replace)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.4",
|
"php": "^8.4",
|
||||||
|
"enshrined/svg-sanitize": "^1.0",
|
||||||
"filament/filament": "^5.7",
|
"filament/filament": "^5.7",
|
||||||
"laravel/framework": "^13.17",
|
"laravel/framework": "^13.17",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
|
|||||||
47
composer.lock
generated
47
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "e9bbf5ad6f9f32f9f506caf56db81710",
|
"content-hash": "6f1347758fcd5a7f4c56b93c83b60453",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -989,6 +989,51 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"time": "2025-03-06T22:45:56+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "enshrined/svg-sanitize",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/darylldoyle/svg-sanitizer.git",
|
||||||
|
"reference": "f3300fcd1bbf67d205b52217c75d0f7d6a8c47ff"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/darylldoyle/svg-sanitizer/zipball/f3300fcd1bbf67d205b52217c75d0f7d6a8c47ff",
|
||||||
|
"reference": "f3300fcd1bbf67d205b52217c75d0f7d6a8c47ff",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^6.5 || ^8.5"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"enshrined\\svgSanitize\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"GPL-2.0-or-later"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Daryll Doyle",
|
||||||
|
"email": "daryll@enshrined.co.uk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "An SVG sanitizer for PHP",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/darylldoyle/svg-sanitizer/issues",
|
||||||
|
"source": "https://github.com/darylldoyle/svg-sanitizer/tree/1.0.0"
|
||||||
|
},
|
||||||
|
"time": "2026-09-01T09:35:47+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/actions",
|
"name": "filament/actions",
|
||||||
"version": "v5.7.6",
|
"version": "v5.7.6",
|
||||||
|
|||||||
@@ -76,4 +76,43 @@ return [
|
|||||||
// Formats that are copied through untouched. Resampling a vector loses the
|
// Formats that are copied through untouched. Resampling a vector loses the
|
||||||
// point of it.
|
// point of it.
|
||||||
'passthrough_mimes' => ['image/svg+xml', 'image/gif'],
|
'passthrough_mimes' => ['image/svg+xml', 'image/gif'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Uploads through the API
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| POST /api/v1/media, which the MCP server uses to put an image on a page.
|
||||||
|
|
|
||||||
|
| SVG is accepted because it is what a logo is, but never as sent: an SVG
|
||||||
|
| is a document that can carry script, served from this application's
|
||||||
|
| domain, and this endpoint also fetches from arbitrary URLs. It goes
|
||||||
|
| through enshrined/svg-sanitize first — scripts, event handlers and
|
||||||
|
| remote references removed — and Caddy serves stored SVGs under a
|
||||||
|
| Content-Security-Policy that would stop a script anyway.
|
||||||
|
|
|
||||||
|
| The size ceiling sits under php.ini's `upload_max_filesize` (24M) so the
|
||||||
|
| answer is a readable 422 rather than PHP silently dropping the file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'upload' => [
|
||||||
|
'mimes' => ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif', 'image/svg+xml'],
|
||||||
|
'max_kb' => (int) env('MEDIA_UPLOAD_MAX_KB', 20 * 1024),
|
||||||
|
|
||||||
|
// What the image is for, and so where it lands — the same directories
|
||||||
|
// the panel's own upload fields use — and whose permission it takes.
|
||||||
|
'for' => [
|
||||||
|
'pages' => 'pages',
|
||||||
|
'technologies' => 'technologies',
|
||||||
|
'projects' => 'projects',
|
||||||
|
'members' => 'members',
|
||||||
|
'clients' => 'clients',
|
||||||
|
'testimonials' => 'testimonials',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Fetching from a URL: how long to wait, in seconds. The request that
|
||||||
|
// triggered it is waiting too.
|
||||||
|
'remote_timeout' => (int) env('MEDIA_REMOTE_TIMEOUT', 15),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,36 +1,59 @@
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
# Production stack.
|
# Production stack.
|
||||||
#
|
#
|
||||||
# Four containers and one database. Deliberately no Redis: Postgres holds the
|
# Five containers and one database. Deliberately no Redis: Postgres holds the
|
||||||
# cache, the sessions and the queue, which for a site this size is plenty and
|
# cache, the sessions and the queue, which for a site this size is plenty and
|
||||||
# is one less thing to run, back up and forget to secure. Swapping it in later
|
# is one less thing to run, back up and forget to secure. Swapping it in later
|
||||||
# is three environment variables.
|
# is three environment variables.
|
||||||
#
|
#
|
||||||
|
# Every application value below comes from `${VAR}` interpolation, not
|
||||||
|
# `env_file:` — so this works the same way whichever way the variables reach
|
||||||
|
# `docker compose`: a real shell environment, a platform's own "Environment
|
||||||
|
# Variables" panel (Dokploy, Coolify, ...), or an explicit `--env-file`:
|
||||||
|
#
|
||||||
# docker compose --env-file .env.production up -d --build
|
# docker compose --env-file .env.production up -d --build
|
||||||
#
|
#
|
||||||
|
# `APP_KEY` is deliberately not required here even though the application
|
||||||
|
# cannot run without it: `docker/entrypoint.sh` checks it instead, with a
|
||||||
|
# message that says how to generate one. Requiring it at this level would also
|
||||||
|
# break generating it in the first place — `docker compose run --rm --no-deps
|
||||||
|
# landing-app php artisan key:generate --show` has to work before `APP_KEY`
|
||||||
|
# exists.
|
||||||
|
#
|
||||||
|
# The web service is named `landing-app`, not `app`: Compose aliases a
|
||||||
|
# service to its own name on every network it joins, `dokploy-network`
|
||||||
|
# included, and that network is shared with whatever else Dokploy runs on the
|
||||||
|
# same host. A service called plain `app` collides with the equally common
|
||||||
|
# `app` another compose project puts on the same network — its containers
|
||||||
|
# start resolving `app` to *this* one, on a port nothing here is listening on.
|
||||||
|
# That is exactly what broke `oauth-iam`'s PHP-FPM upstream the moment both
|
||||||
|
# stacks were up together, and it stops happening the moment the name isn't
|
||||||
|
# generic enough to collide.
|
||||||
|
#
|
||||||
|
# `landing-app` is not published on a host port either: it only joins the
|
||||||
|
# external `dokploy-network` so Dokploy's Traefik can reach it, and TLS is
|
||||||
|
# terminated there before anything reaches this container — same reason
|
||||||
|
# `docker/Caddyfile` trusts `X-Forwarded-*` only from private ranges. The
|
||||||
|
# domain itself is configured through Dokploy's own "Domains" tab, which
|
||||||
|
# injects its own Traefik labels — don't also add them here, Traefik ends up
|
||||||
|
# with two routers for the same Host() and picks one unpredictably.
|
||||||
|
#
|
||||||
|
# A manual deploy without Dokploy needs its own reverse proxy joined to that
|
||||||
|
# network (with its own labels/config), or `landing-app` added back to
|
||||||
|
# `default` with a published port.
|
||||||
|
#
|
||||||
# The web container is the only one that migrates, so a deploy runs them once
|
# The web container is the only one that migrates, so a deploy runs them once
|
||||||
# rather than once per replica.
|
# rather than once per replica.
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
name: landing
|
||||||
|
|
||||||
x-app: &app
|
x-app: &app
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: pireon-admin:latest
|
image: pireon-admin:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env.production
|
|
||||||
environment:
|
|
||||||
DB_CONNECTION: pgsql
|
|
||||||
DB_HOST: postgres
|
|
||||||
DB_PORT: 5432
|
|
||||||
|
|
||||||
# The maintenance flag and the health badge are read on nearly every
|
|
||||||
# request, so the cache has to be shared by every container — a per-process
|
|
||||||
# one would have the panel and the API disagreeing about whether the site
|
|
||||||
# is down.
|
|
||||||
CACHE_STORE: database
|
|
||||||
SESSION_DRIVER: database
|
|
||||||
QUEUE_CONNECTION: database
|
|
||||||
volumes:
|
volumes:
|
||||||
# Uploaded images live here, and they are the one thing in this stack that
|
# Uploaded images live here, and they are the one thing in this stack that
|
||||||
# cannot be rebuilt from the repository.
|
# cannot be rebuilt from the repository.
|
||||||
@@ -39,23 +62,95 @@ x-app: &app
|
|||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
services:
|
x-app-env: &app-env
|
||||||
app:
|
APP_NAME: ${APP_NAME:-Pireon Admin}
|
||||||
<<: *app
|
APP_ENV: ${APP_ENV:-production}
|
||||||
environment:
|
APP_KEY: ${APP_KEY}
|
||||||
|
APP_DEBUG: ${APP_DEBUG:-false}
|
||||||
|
APP_URL: ${APP_URL}
|
||||||
|
APP_LOCALE: ${APP_LOCALE:-es}
|
||||||
|
APP_FALLBACK_LOCALE: ${APP_FALLBACK_LOCALE:-es}
|
||||||
|
|
||||||
|
LOG_CHANNEL: ${LOG_CHANNEL:-stack}
|
||||||
|
LOG_STACK: ${LOG_STACK:-single}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-warning}
|
||||||
|
|
||||||
|
# Fixed by the service names below, not something a deploy should override —
|
||||||
|
# otherwise the panel and the API could disagree about the site's state.
|
||||||
DB_CONNECTION: pgsql
|
DB_CONNECTION: pgsql
|
||||||
DB_HOST: postgres
|
DB_HOST: postgres
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
|
DB_DATABASE: ${DB_DATABASE:-pireon}
|
||||||
|
DB_USERNAME: ${DB_USERNAME:-pireon}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required. Set it wherever this stack's other variables come from.}
|
||||||
CACHE_STORE: database
|
CACHE_STORE: database
|
||||||
SESSION_DRIVER: database
|
SESSION_DRIVER: database
|
||||||
QUEUE_CONNECTION: database
|
QUEUE_CONNECTION: database
|
||||||
|
|
||||||
|
PIREON_SERVER: ${PIREON_SERVER:-https://oauth.pireon.dev}
|
||||||
|
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID}
|
||||||
|
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET}
|
||||||
|
PIREON_REDIRECT_URI: ${PIREON_REDIRECT_URI}
|
||||||
|
PIREON_SCOPES: ${PIREON_SCOPES:-account:read}
|
||||||
|
PIREON_PASSWORD_GRANT: ${PIREON_PASSWORD_GRANT:-true}
|
||||||
|
PIREON_INTROSPECTION_TTL: ${PIREON_INTROSPECTION_TTL:-60}
|
||||||
|
PIREON_DEFAULT_ROLE: ${PIREON_DEFAULT_ROLE:-viewer}
|
||||||
|
PIREON_REQUIRE_ACTIVE: ${PIREON_REQUIRE_ACTIVE:-true}
|
||||||
|
PIREON_REQUIRE_APPROVED: ${PIREON_REQUIRE_APPROVED:-true}
|
||||||
|
|
||||||
|
SITE_URL: ${SITE_URL}
|
||||||
|
SITE_PREFIX_DEFAULT_LOCALE: ${SITE_PREFIX_DEFAULT_LOCALE:-false}
|
||||||
|
SITE_CACHE_SECONDS: ${SITE_CACHE_SECONDS:-60}
|
||||||
|
SITE_PREVIEW_TOKEN: ${SITE_PREVIEW_TOKEN}
|
||||||
|
SITE_RATE_LIMIT: ${SITE_RATE_LIMIT:-600}
|
||||||
|
SITE_TRUSTED_IPS: ${SITE_TRUSTED_IPS:-}
|
||||||
|
SITE_INGEST_TOKEN: ${SITE_INGEST_TOKEN}
|
||||||
|
SITE_TELEMETRY_RATE_LIMIT: ${SITE_TELEMETRY_RATE_LIMIT:-60}
|
||||||
|
SITE_TELEMETRY_MAX_BATCH: ${SITE_TELEMETRY_MAX_BATCH:-50}
|
||||||
|
SITE_ACCEPT_SUBMISSIONS: ${SITE_ACCEPT_SUBMISSIONS:-true}
|
||||||
|
SITE_HONEYPOT_FIELD: ${SITE_HONEYPOT_FIELD:-website}
|
||||||
|
SITE_SUBMISSION_RATE_LIMIT: ${SITE_SUBMISSION_RATE_LIMIT:-3}
|
||||||
|
SITE_SUBMISSION_HOURLY_LIMIT: ${SITE_SUBMISSION_HOURLY_LIMIT:-60}
|
||||||
|
SITE_SUBMISSION_DEDUPE_MINUTES: ${SITE_SUBMISSION_DEDUPE_MINUTES:-1440}
|
||||||
|
SITE_SUBMISSION_MAIL: ${SITE_SUBMISSION_MAIL:-false}
|
||||||
|
|
||||||
|
MEDIA_DISK: ${MEDIA_DISK:-public}
|
||||||
|
MEDIA_MAX_PIXELS: ${MEDIA_MAX_PIXELS:-50000000}
|
||||||
|
|
||||||
|
GIT_PROVIDER: ${GIT_PROVIDER:-gitea}
|
||||||
|
GITEA_URL: ${GITEA_URL:-}
|
||||||
|
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
||||||
|
GITEA_ORG: ${GITEA_ORG:-}
|
||||||
|
GIT_SYNC_CRON: ${GIT_SYNC_CRON:-hourly}
|
||||||
|
|
||||||
|
MAIL_MAILER: ${MAIL_MAILER:-log}
|
||||||
|
MAIL_HOST: ${MAIL_HOST:-}
|
||||||
|
MAIL_PORT: ${MAIL_PORT:-587}
|
||||||
|
MAIL_USERNAME: ${MAIL_USERNAME:-}
|
||||||
|
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
||||||
|
MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-admin@example.org}
|
||||||
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Pireon Admin}
|
||||||
|
|
||||||
|
services:
|
||||||
|
landing-app:
|
||||||
|
<<: *app
|
||||||
|
environment:
|
||||||
|
<<: *app-env
|
||||||
# Only here: migrations run once per deploy, not once per container.
|
# Only here: migrations run once per deploy, not once per container.
|
||||||
RUN_MIGRATIONS: "true"
|
RUN_MIGRATIONS: "true"
|
||||||
ports:
|
expose:
|
||||||
- "${APP_PORT:-8000}:80"
|
- "8080"
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
- default
|
||||||
|
|
||||||
|
# No Traefik labels here on purpose: the domain is configured through
|
||||||
|
# Dokploy's own "Domains" tab for this app, which injects its own labels
|
||||||
|
# at deploy time. Hand-writing another set on top of those, for the same
|
||||||
|
# Host(), gave Traefik two routers to choose between — it only broke once
|
||||||
|
# both existed together, which is exactly what happened.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost/up"]
|
test: ["CMD", "curl", "-fsS", "http://localhost:8080/up"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
@@ -67,6 +162,7 @@ services:
|
|||||||
worker:
|
worker:
|
||||||
<<: *app
|
<<: *app
|
||||||
command: ["php", "artisan", "queue:work", "--tries=3", "--max-time=3600", "--sleep=3"]
|
command: ["php", "artisan", "queue:work", "--tries=3", "--max-time=3600", "--sleep=3"]
|
||||||
|
environment: *app-env
|
||||||
|
|
||||||
# Waits for the web container, not just the database: it is the one that
|
# Waits for the web container, not just the database: it is the one that
|
||||||
# migrates, and a worker that starts against an empty schema spends its
|
# migrates, and a worker that starts against an empty schema spends its
|
||||||
@@ -74,7 +170,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
app:
|
landing-app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "php", "artisan", "queue:monitor", "default"]
|
test: ["CMD", "php", "artisan", "queue:monitor", "default"]
|
||||||
@@ -89,6 +185,7 @@ services:
|
|||||||
scheduler:
|
scheduler:
|
||||||
<<: *app
|
<<: *app
|
||||||
command: ["php", "artisan", "schedule:work"]
|
command: ["php", "artisan", "schedule:work"]
|
||||||
|
environment: *app-env
|
||||||
|
|
||||||
# The FrankenPHP base image ships a healthcheck that probes Caddy's admin
|
# The FrankenPHP base image ships a healthcheck that probes Caddy's admin
|
||||||
# API, and this container does not serve HTTP — inherited, it could never
|
# API, and this container does not serve HTTP — inherited, it could never
|
||||||
@@ -109,17 +206,56 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
app:
|
landing-app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
|
|
||||||
|
# The MCP server (mcp/), so Claude and other MCP clients can use the API
|
||||||
|
# from anywhere, not only from a checkout of this repository. It reaches the
|
||||||
|
# application over the stack's own network rather than the public domain:
|
||||||
|
# one hop, no TLS, and it keeps working while the domain is misconfigured.
|
||||||
|
#
|
||||||
|
# Holds no credentials of its own. Every request brings its caller's Pireon
|
||||||
|
# credentials, and the API applies that person's permissions — see the
|
||||||
|
# README's "Servidor MCP" section for how to connect.
|
||||||
|
#
|
||||||
|
# Like `landing-app`, published only through `dokploy-network`: add its
|
||||||
|
# domain in Dokploy's "Domains" tab, pointing at port 3000, and the endpoint
|
||||||
|
# is https://<that domain>/mcp.
|
||||||
|
landing-mcp:
|
||||||
|
build:
|
||||||
|
context: ./mcp
|
||||||
|
image: landing-mcp:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
LANDING_URL: http://landing-app:8080
|
||||||
|
LANDING_MCP_REQUIRE_AUTH: ${LANDING_MCP_REQUIRE_AUTH:-false}
|
||||||
|
# Deliberately not SITE_PREVIEW_TOKEN by default: with it, anyone who can
|
||||||
|
# sign in sees every draft through the public tools, whatever their role
|
||||||
|
# would let them see in the panel. Set it to that value knowingly.
|
||||||
|
LANDING_PREVIEW_TOKEN: ${LANDING_MCP_PREVIEW_TOKEN:-}
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
- default
|
||||||
|
depends_on:
|
||||||
|
landing-app:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/up').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${DB_DATABASE:-pireon}
|
POSTGRES_DB: ${DB_DATABASE:-pireon}
|
||||||
POSTGRES_USER: ${DB_USERNAME:-pireon}
|
POSTGRES_USER: ${DB_USERNAME:-pireon}
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env.production}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required. Set it wherever this stack's other variables come from.}
|
||||||
|
|
||||||
# Content is written by a handful of people and read constantly; the
|
# Content is written by a handful of people and read constantly; the
|
||||||
# default collation rules are what the JSON slug lookups sort under.
|
# default collation rules are what the JSON slug lookups sort under.
|
||||||
@@ -141,3 +277,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
storage:
|
storage:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
|
|||||||
@@ -21,6 +21,21 @@
|
|||||||
@media path /storage/*
|
@media path /storage/*
|
||||||
header @media Cache-Control "public, max-age=31536000, immutable"
|
header @media Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# Uploaded SVGs are sanitized on the way in (MediaController). This is the
|
||||||
|
# second line: opened directly rather than through an <img>, an SVG is a
|
||||||
|
# document, and nothing stored here has any business running script or
|
||||||
|
# loading anything from elsewhere.
|
||||||
|
#
|
||||||
|
# Two matchers, which Caddy ANDs, rather than `path /storage/*.svg`: a `*`
|
||||||
|
# in the middle of a path does not cross a `/`, so that pattern only ever
|
||||||
|
# matched files directly under /storage — and every upload is a directory
|
||||||
|
# below it.
|
||||||
|
@svg {
|
||||||
|
path /storage/*
|
||||||
|
path_regexp (?i)\.svg$
|
||||||
|
}
|
||||||
|
header @svg Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||||
|
|
||||||
# The frontend is a separate application on another origin.
|
# The frontend is a separate application on another origin.
|
||||||
@api path /api/*
|
@api path /api/*
|
||||||
header @api {
|
header @api {
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'environment' => [
|
|
||||||
'production' => 'Producció — això és real',
|
|
||||||
'other' => 'Entorn de :name',
|
|
||||||
],
|
|
||||||
|
|
||||||
'concurrency' => [
|
'concurrency' => [
|
||||||
'title' => 'No s\'ha desat',
|
'title' => 'No s\'ha desat',
|
||||||
'body' => 'Algú altre ha canviat això mentre ho editaves. Recarrega la pàgina per veure la versió actual: si ho deses ara, perdries aquest canvi.',
|
'body' => 'Algú altre ha canviat això mentre ho editaves. Recarrega la pàgina per veure la versió actual: si ho deses ara, perdries aquest canvi.',
|
||||||
|
|||||||
@@ -28,4 +28,26 @@ return [
|
|||||||
'pages' => [
|
'pages' => [
|
||||||
'cannot_publish' => 'No tens permís per publicar pàgines.',
|
'cannot_publish' => 'No tens permís per publicar pàgines.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'settings' => [
|
||||||
|
'read_only' => 'Aquest ajust l\'escriu l\'aplicació i no es pot editar a mà.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'publication' => [
|
||||||
|
'forbidden' => 'No tens permís per publicar ni despublicar això.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'media' => [
|
||||||
|
'in_use' => 'La imatge s\'està fent servir: treu-la primer d\'on apareix.',
|
||||||
|
'not_an_image' => 'El fitxer no és una imatge JPEG, PNG, WebP, GIF, AVIF o SVG.',
|
||||||
|
'remote' => [
|
||||||
|
'https_only' => 'La URL de la imatge ha de començar per https://.',
|
||||||
|
'unresolvable' => 'No s\'ha pogut resoldre el domini de la URL.',
|
||||||
|
'private' => 'La URL apunta a una adreça privada o reservada: només es descarreguen imatges d\'adreces públiques.',
|
||||||
|
'unreachable' => 'No s\'ha pogut connectar amb la URL.',
|
||||||
|
'redirect' => 'La URL redirigeix a una altra adreça. Fes servir l\'adreça final de la imatge.',
|
||||||
|
'status' => 'La URL ha respost :status en lloc de la imatge.',
|
||||||
|
'too_large' => 'La imatge supera el màxim de :max KB.',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'environment' => [
|
|
||||||
'production' => 'Production — this is real',
|
|
||||||
'other' => ':name environment',
|
|
||||||
],
|
|
||||||
|
|
||||||
'concurrency' => [
|
'concurrency' => [
|
||||||
'title' => 'Not saved',
|
'title' => 'Not saved',
|
||||||
'body' => 'Somebody else changed this while you were editing it. Reload the page to see the current version — saving now would overwrite that change.',
|
'body' => 'Somebody else changed this while you were editing it. Reload the page to see the current version — saving now would overwrite that change.',
|
||||||
|
|||||||
@@ -28,4 +28,26 @@ return [
|
|||||||
'pages' => [
|
'pages' => [
|
||||||
'cannot_publish' => 'You do not have permission to publish pages.',
|
'cannot_publish' => 'You do not have permission to publish pages.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'settings' => [
|
||||||
|
'read_only' => 'This setting is written by the application and cannot be edited by hand.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'publication' => [
|
||||||
|
'forbidden' => 'You do not have permission to publish or unpublish this.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'media' => [
|
||||||
|
'in_use' => 'The image is in use: remove it from where it appears first.',
|
||||||
|
'not_an_image' => 'The file is not a JPEG, PNG, WebP, GIF, AVIF or SVG image.',
|
||||||
|
'remote' => [
|
||||||
|
'https_only' => 'The image URL must start with https://.',
|
||||||
|
'unresolvable' => 'The URL\'s domain could not be resolved.',
|
||||||
|
'private' => 'The URL points at a private or reserved address: images are only downloaded from public ones.',
|
||||||
|
'unreachable' => 'Could not connect to the URL.',
|
||||||
|
'redirect' => 'The URL redirects elsewhere. Use the image\'s final address.',
|
||||||
|
'status' => 'The URL answered :status instead of the image.',
|
||||||
|
'too_large' => 'The image is larger than the :max KB limit.',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'environment' => [
|
|
||||||
'production' => 'Producción — esto es real',
|
|
||||||
'other' => 'Entorno de :name',
|
|
||||||
],
|
|
||||||
|
|
||||||
'concurrency' => [
|
'concurrency' => [
|
||||||
'title' => 'No se ha guardado',
|
'title' => 'No se ha guardado',
|
||||||
'body' => 'Alguien más ha cambiado esto mientras lo editabas. Recarga la página para ver la versión actual: si guardas ahora, perderías ese cambio.',
|
'body' => 'Alguien más ha cambiado esto mientras lo editabas. Recarga la página para ver la versión actual: si guardas ahora, perderías ese cambio.',
|
||||||
|
|||||||
@@ -28,4 +28,26 @@ return [
|
|||||||
'pages' => [
|
'pages' => [
|
||||||
'cannot_publish' => 'No tienes permiso para publicar páginas.',
|
'cannot_publish' => 'No tienes permiso para publicar páginas.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'settings' => [
|
||||||
|
'read_only' => 'Este ajuste lo escribe la aplicación y no se puede editar a mano.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'publication' => [
|
||||||
|
'forbidden' => 'No tienes permiso para publicar ni despublicar esto.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'media' => [
|
||||||
|
'in_use' => 'La imagen se está usando: quítala antes de donde aparece.',
|
||||||
|
'not_an_image' => 'El fichero no es una imagen JPEG, PNG, WebP, GIF, AVIF o SVG.',
|
||||||
|
'remote' => [
|
||||||
|
'https_only' => 'La URL de la imagen tiene que empezar por https://.',
|
||||||
|
'unresolvable' => 'No se ha podido resolver el dominio de la URL.',
|
||||||
|
'private' => 'La URL apunta a una dirección privada o reservada: solo se descargan imágenes de direcciones públicas.',
|
||||||
|
'unreachable' => 'No se ha podido conectar con la URL.',
|
||||||
|
'redirect' => 'La URL redirige a otra dirección. Usa la dirección final de la imagen.',
|
||||||
|
'status' => 'La URL ha respondido :status en lugar de la imagen.',
|
||||||
|
'too_large' => 'La imagen supera el máximo de :max KB.',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
4
mcp/.dockerignore
Normal file
4
mcp/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
test
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
26
mcp/Dockerfile
Normal file
26
mcp/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# The MCP server, over HTTP. Its own image rather than a fourth command in the
|
||||||
|
# PHP one: it shares no code with the application, only the API between them,
|
||||||
|
# and Node is a runtime that image has no other reason to carry.
|
||||||
|
###############################################################################
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
LANDING_MCP_HOST=0.0.0.0 \
|
||||||
|
LANDING_MCP_PORT=3000
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
COPY server.mjs ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.mjs", "--http"]
|
||||||
1222
mcp/package-lock.json
generated
Normal file
1222
mcp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
mcp/package.json
Normal file
20
mcp/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "landing-mcp",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "MCP server exposing the landing REST API as tools, generated from its OpenAPI document.",
|
||||||
|
"bin": {
|
||||||
|
"landing-mcp": "server.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.mjs",
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
||||||
|
"yaml": "^2.9.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
96
mcp/server.mjs
Normal file
96
mcp/server.mjs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* MCP server for the landing API.
|
||||||
|
*
|
||||||
|
* Reads the OpenAPI document the running application serves and offers one
|
||||||
|
* tool per documented operation, so it always describes the API it is talking
|
||||||
|
* to — local, staging or production — rather than the one in this checkout.
|
||||||
|
*
|
||||||
|
* node server.mjs stdio, for a client that launches it locally
|
||||||
|
* node server.mjs --http Streamable HTTP on /mcp, for deploying
|
||||||
|
*
|
||||||
|
* Configuration, all through the environment:
|
||||||
|
*
|
||||||
|
* LANDING_URL Application root. Default http://localhost:8000
|
||||||
|
* LANDING_OPENAPI Path or URL of the spec, if not LANDING_URL/openapi.yaml.
|
||||||
|
* LANDING_PREVIEW_TOKEN Lets the public tools see unpublished content. Over
|
||||||
|
* HTTP, only for signed-in callers.
|
||||||
|
*
|
||||||
|
* stdio only — the one person running it is the one it signs in as:
|
||||||
|
*
|
||||||
|
* LANDING_USERNAME Pireon credentials for the admin API (/v1). The
|
||||||
|
* LANDING_PASSWORD token is obtained and refreshed automatically.
|
||||||
|
* LANDING_TOKEN Or a fixed bearer token instead.
|
||||||
|
*
|
||||||
|
* HTTP only — every request brings its own credentials (see src/http.mjs):
|
||||||
|
*
|
||||||
|
* LANDING_MCP_PORT Default 3000.
|
||||||
|
* LANDING_MCP_HOST Default 127.0.0.1; the image sets 0.0.0.0.
|
||||||
|
* LANDING_MCP_REQUIRE_AUTH "true" refuses anonymous callers instead of
|
||||||
|
* offering them the public API.
|
||||||
|
*
|
||||||
|
* stdout is the stdio protocol channel: anything for a human goes to stderr.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import { Catalogue } from './src/catalogue.mjs';
|
||||||
|
import { LandingClient } from './src/client.mjs';
|
||||||
|
import { startHttpServer } from './src/http.mjs';
|
||||||
|
import { createMcpServer } from './src/mcp.mjs';
|
||||||
|
|
||||||
|
const env = (name) => process.env[name]?.trim() || undefined;
|
||||||
|
|
||||||
|
const baseUrl = (env('LANDING_URL') ?? 'http://localhost:8000').replace(/\/+$/, '');
|
||||||
|
const http = process.argv.includes('--http');
|
||||||
|
|
||||||
|
const catalogue = new Catalogue({
|
||||||
|
source: env('LANDING_OPENAPI') ?? `${baseUrl}/openapi.yaml`,
|
||||||
|
baseUrl,
|
||||||
|
// A deployment outlives many deploys of the application; a stdio process
|
||||||
|
// lives for one editor session and describes its tools once anyway.
|
||||||
|
ttlMs: http ? 60_000 : Infinity,
|
||||||
|
// Over stdio the disk the server reads is the caller's own.
|
||||||
|
localFiles: !http,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (http) {
|
||||||
|
// Credentials in the environment would sign every anonymous caller in as
|
||||||
|
// whoever they belong to.
|
||||||
|
for (const name of ['LANDING_USERNAME', 'LANDING_PASSWORD', 'LANDING_TOKEN']) {
|
||||||
|
if (env(name)) console.error(`landing-mcp: ${name} is ignored over HTTP; each request brings its own credentials`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tried now only to report a misconfiguration early. The application may
|
||||||
|
// well still be starting, so a failure here is not fatal: the catalogue
|
||||||
|
// retries on the first request.
|
||||||
|
catalogue.get().catch((error) => console.error(`landing-mcp: OpenAPI document not available yet: ${error.message}`));
|
||||||
|
|
||||||
|
startHttpServer({
|
||||||
|
catalogue,
|
||||||
|
port: Number(env('LANDING_MCP_PORT') ?? 3000),
|
||||||
|
host: env('LANDING_MCP_HOST') ?? '127.0.0.1',
|
||||||
|
previewToken: env('LANDING_PREVIEW_TOKEN'),
|
||||||
|
requireAuth: env('LANDING_MCP_REQUIRE_AUTH') === 'true',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let current;
|
||||||
|
|
||||||
|
try {
|
||||||
|
current = await catalogue.get();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`landing-mcp: could not load the OpenAPI document from ${catalogue.source}: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new LandingClient({
|
||||||
|
apiBase: current.apiBase,
|
||||||
|
token: env('LANDING_TOKEN'),
|
||||||
|
username: env('LANDING_USERNAME'),
|
||||||
|
password: env('LANDING_PASSWORD'),
|
||||||
|
previewToken: env('LANDING_PREVIEW_TOKEN'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMcpServer({ spec: current.spec, tools: current.tools, client }).connect(new StdioServerTransport());
|
||||||
|
|
||||||
|
console.error(`landing-mcp: stdio against ${current.apiBase}${client.canAuthenticate() ? '' : ' (public API only: no credentials)'}`);
|
||||||
|
}
|
||||||
80
mcp/src/catalogue.mjs
Normal file
80
mcp/src/catalogue.mjs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* The OpenAPI document and the tools derived from it.
|
||||||
|
*
|
||||||
|
* Loaded from the application the server talks to, and — for a long-running
|
||||||
|
* deployment — reloaded now and then, so that deploying a new endpoint to the
|
||||||
|
* application makes it a tool here without also redeploying this.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { parse } from 'yaml';
|
||||||
|
import { buildTools } from './tools.mjs';
|
||||||
|
|
||||||
|
export class Catalogue {
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {string} options.source Path or URL of openapi.yaml.
|
||||||
|
* @param {string} options.baseUrl Application root the document's server URL is relative to.
|
||||||
|
* @param {number} [options.ttlMs] How long a loaded document is trusted. Infinity: load once.
|
||||||
|
* @param {boolean} [options.localFiles] Offer uploads from a local path; see `buildTools`.
|
||||||
|
*/
|
||||||
|
constructor({ source, baseUrl, ttlMs = Infinity, localFiles = false }) {
|
||||||
|
this.source = source;
|
||||||
|
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||||
|
this.ttlMs = ttlMs;
|
||||||
|
this.localFiles = localFiles;
|
||||||
|
this.current = null;
|
||||||
|
this.loadedAt = 0;
|
||||||
|
this.pending = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<{spec: object, tools: import('./tools.mjs').Tool[], apiBase: string}>} */
|
||||||
|
async get() {
|
||||||
|
if (this.current && Date.now() - this.loadedAt < this.ttlMs) return this.current;
|
||||||
|
|
||||||
|
this.pending ??= this.load().finally(() => { this.pending = null; });
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.pending;
|
||||||
|
} catch (error) {
|
||||||
|
// An application that is redeploying, or briefly down, should not take
|
||||||
|
// the tools with it: the last document that loaded is still the best
|
||||||
|
// description available. Only the very first load has nothing to fall
|
||||||
|
// back on.
|
||||||
|
if (this.current) {
|
||||||
|
console.error(`landing-mcp: keeping the previous OpenAPI document: ${error.message}`);
|
||||||
|
this.loadedAt = Date.now();
|
||||||
|
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const spec = parse(await this.read());
|
||||||
|
|
||||||
|
// The document's own server URL ("/api"), relative to the application root.
|
||||||
|
const serverUrl = spec.servers?.[0]?.url ?? '/api';
|
||||||
|
|
||||||
|
this.current = {
|
||||||
|
spec,
|
||||||
|
tools: buildTools(spec, { localFiles: this.localFiles }),
|
||||||
|
apiBase: /^https?:\/\//.test(serverUrl) ? serverUrl : `${this.baseUrl}${serverUrl}`,
|
||||||
|
};
|
||||||
|
this.loadedAt = Date.now();
|
||||||
|
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async read() {
|
||||||
|
if (!/^https?:\/\//.test(this.source)) return readFile(this.source, 'utf8');
|
||||||
|
|
||||||
|
const response = await fetch(this.source, { signal: AbortSignal.timeout(15_000) });
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`GET ${this.source} answered ${response.status}`);
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
234
mcp/src/client.mjs
Normal file
234
mcp/src/client.mjs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* Calls the API on the model's behalf, and keeps it signed in.
|
||||||
|
*
|
||||||
|
* Tokens come from the Pireon identity server through POST /api/v1/auth/token
|
||||||
|
* and expire, so a fixed bearer token in the MCP config would die mid-session.
|
||||||
|
* With credentials instead, this client logs in on the first authenticated
|
||||||
|
* call, refreshes before expiry, and retries once on a 401 — a token revoked or
|
||||||
|
* expired upstream costs one extra round trip, not a broken session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
|
||||||
|
// Refresh this long before the stated expiry, so a token never expires between
|
||||||
|
// the check and the request.
|
||||||
|
const EXPIRY_MARGIN_MS = 60_000;
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
// Above the API's own 20 MB so its answer, not this one, is what a caller
|
||||||
|
// usually sees; this only stops something absurd being read into memory.
|
||||||
|
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
export class LandingClient {
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {string} options.apiBase e.g. https://admin.example.com/api
|
||||||
|
* @param {string} [options.token] A fixed bearer token.
|
||||||
|
* @param {string} [options.username]
|
||||||
|
* @param {string} [options.password]
|
||||||
|
* @param {string} [options.previewToken] Sent on public calls to see drafts.
|
||||||
|
* @param {typeof fetch} [options.fetch]
|
||||||
|
*/
|
||||||
|
constructor({ apiBase, token, username, password, previewToken, fetch = globalThis.fetch }) {
|
||||||
|
this.apiBase = apiBase.replace(/\/+$/, '');
|
||||||
|
this.fixedToken = token || null;
|
||||||
|
this.credentials = username && password ? { username, password } : null;
|
||||||
|
this.previewToken = previewToken || null;
|
||||||
|
this.fetch = fetch;
|
||||||
|
|
||||||
|
/** @type {{access: string, refresh: string|null, expiresAt: number|null}|null} */
|
||||||
|
this.session = null;
|
||||||
|
this.pending = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
canAuthenticate() {
|
||||||
|
return Boolean(this.fixedToken || this.credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proves the credentials or token work, by spending them on GET /v1/me.
|
||||||
|
* Throws for credentials the application refused; resolves the response
|
||||||
|
* otherwise, for the caller to judge its status.
|
||||||
|
*/
|
||||||
|
async verify() {
|
||||||
|
return this.call({ method: 'GET', path: '/v1/me', params: [], hasBody: false, requiresAuth: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('./tools.mjs').Operation} operation
|
||||||
|
* @param {Record<string, unknown>} args
|
||||||
|
* @returns {Promise<{status: number, body: unknown}>}
|
||||||
|
*/
|
||||||
|
async call(operation, args = {}) {
|
||||||
|
const request = await this.buildRequest(operation, args);
|
||||||
|
|
||||||
|
if (!operation.requiresAuth) {
|
||||||
|
if (this.previewToken) request.headers['X-Preview-Token'] = this.previewToken;
|
||||||
|
|
||||||
|
return this.send(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.headers.Authorization = `Bearer ${await this.accessToken()}`;
|
||||||
|
|
||||||
|
let response = await this.send(request);
|
||||||
|
|
||||||
|
// A fixed token has nothing to renew itself with, so its 401 stands.
|
||||||
|
if (response.status === 401 && !this.fixedToken) {
|
||||||
|
this.session = { ...this.session, access: null };
|
||||||
|
request.headers.Authorization = `Bearer ${await this.accessToken()}`;
|
||||||
|
response = await this.send(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildRequest(operation, args) {
|
||||||
|
let path = operation.path;
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const { name, in: location } of operation.params) {
|
||||||
|
const value = args[name];
|
||||||
|
|
||||||
|
if (location === 'path') {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
throw new Error(`Missing path parameter "${name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
path = path.replace(`{${name}}`, encodeURIComponent(String(value)));
|
||||||
|
} else if (value !== undefined && value !== null) {
|
||||||
|
// Laravel's boolean() and the `boolean` rule both accept 1/0; only the
|
||||||
|
// former accepts "true".
|
||||||
|
query.set(name, typeof value === 'boolean' ? (value ? '1' : '0') : String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = { Accept: 'application/json' };
|
||||||
|
let body;
|
||||||
|
|
||||||
|
const uploads = (operation.files ?? []).filter((field) => args[`${field}_path`]);
|
||||||
|
|
||||||
|
if (uploads.length > 0) {
|
||||||
|
// No Content-Type: fetch writes it, boundary included, from the form.
|
||||||
|
body = new FormData();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(args.body ?? {})) {
|
||||||
|
body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of uploads) {
|
||||||
|
body.append(field, ...(await this.readUpload(args[`${field}_path`])));
|
||||||
|
}
|
||||||
|
} else if (operation.hasBody && args.body !== undefined) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
body = JSON.stringify(args.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = query.toString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `${this.apiBase}${path}${qs ? `?${qs}` : ''}`,
|
||||||
|
method: operation.method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<[Blob, string]>} */
|
||||||
|
async readUpload(path) {
|
||||||
|
let info;
|
||||||
|
|
||||||
|
try {
|
||||||
|
info = await stat(path);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Cannot read ${path}: no such file`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!info.isFile()) throw new Error(`${path} is not a file`);
|
||||||
|
if (info.size > MAX_UPLOAD_BYTES) throw new Error(`${path} is ${Math.round(info.size / 1048576)} MB; the limit is 20 MB`);
|
||||||
|
|
||||||
|
return [new Blob([await readFile(path)]), basename(path)];
|
||||||
|
}
|
||||||
|
|
||||||
|
async send({ url, method, headers, body }) {
|
||||||
|
const response = await this.fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let parsed = text;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
// Not JSON (an HTML error page from a proxy, say): hand it over as text.
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: response.status, body: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async accessToken() {
|
||||||
|
if (this.fixedToken) return this.fixedToken;
|
||||||
|
|
||||||
|
if (!this.credentials) {
|
||||||
|
throw new Error('This endpoint needs a token: set LANDING_TOKEN, or LANDING_USERNAME and LANDING_PASSWORD.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.session;
|
||||||
|
const fresh = session?.access
|
||||||
|
&& (session.expiresAt === null || session.expiresAt - Date.now() > EXPIRY_MARGIN_MS);
|
||||||
|
|
||||||
|
if (fresh) return session.access;
|
||||||
|
|
||||||
|
// Parallel tool calls would otherwise each log in, and the token endpoint
|
||||||
|
// allows six requests a minute.
|
||||||
|
this.pending ??= this.renew().finally(() => { this.pending = null; });
|
||||||
|
|
||||||
|
return (await this.pending).access;
|
||||||
|
}
|
||||||
|
|
||||||
|
async renew() {
|
||||||
|
if (this.session?.refresh) {
|
||||||
|
try {
|
||||||
|
return await this.exchange({ grant_type: 'refresh_token', refresh_token: this.session.refresh });
|
||||||
|
} catch {
|
||||||
|
// Refresh tokens expire and get revoked too; the password still works.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.exchange({ grant_type: 'password', ...this.credentials });
|
||||||
|
}
|
||||||
|
|
||||||
|
async exchange(grant) {
|
||||||
|
const { status, body } = await this.send({
|
||||||
|
url: `${this.apiBase}/v1/auth/token`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(grant),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200 || !body?.access_token) {
|
||||||
|
const reason = body?.message ?? (typeof body === 'string' ? body.slice(0, 200) : `HTTP ${status}`);
|
||||||
|
const error = new Error(`Could not obtain a token (${grant.grant_type} grant): ${reason}`);
|
||||||
|
|
||||||
|
// 401/403/422 are the application's answer about these credentials;
|
||||||
|
// anything else says nothing about them. The HTTP transport needs to
|
||||||
|
// tell the two apart.
|
||||||
|
error.status = status;
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.session = {
|
||||||
|
access: body.access_token,
|
||||||
|
refresh: body.refresh_token ?? null,
|
||||||
|
expiresAt: body.expires_at ? Date.parse(body.expires_at) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.session;
|
||||||
|
}
|
||||||
|
}
|
||||||
288
mcp/src/http.mjs
Normal file
288
mcp/src/http.mjs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* The MCP server over Streamable HTTP, for deploying next to the application.
|
||||||
|
*
|
||||||
|
* Stateless: every POST builds a fresh MCP server around the caller's client,
|
||||||
|
* so nothing about one request survives into the next except the sign-in
|
||||||
|
* below, and any number of replicas can sit behind the same domain.
|
||||||
|
*
|
||||||
|
* Nobody signs in to this server as such. Each request carries its caller's
|
||||||
|
* own credentials, and the API applies that person's permissions exactly as the
|
||||||
|
* panel would:
|
||||||
|
*
|
||||||
|
* Authorization: Basic base64(email:password) Pireon credentials. Signed
|
||||||
|
* in once, then kept fresh.
|
||||||
|
* Authorization: Bearer <token> A Pireon access token.
|
||||||
|
* (none) Public API only, unless
|
||||||
|
* LANDING_MCP_REQUIRE_AUTH.
|
||||||
|
*
|
||||||
|
* Credentials never reach a log, and are held only in memory, keyed by a hash,
|
||||||
|
* for as long as they are in use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { LandingClient } from './client.mjs';
|
||||||
|
import { createMcpServer } from './mcp.mjs';
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
// A refused credential is refused again without asking the application, for
|
||||||
|
// this long. A client retrying in a loop with a wrong password would otherwise
|
||||||
|
// spend the account's sign-in budget for its owner too.
|
||||||
|
const REJECTION_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
// A bearer token is re-checked this often, matching the application's own
|
||||||
|
// introspection cache: past that, a token revoked upstream stops working here
|
||||||
|
// at the same moment it stops working there.
|
||||||
|
const BEARER_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
// A signed-in session nobody has used for this long is forgotten, password
|
||||||
|
// included.
|
||||||
|
const IDLE_MS = 12 * 60 * 60 * 1000;
|
||||||
|
const MAX_SESSIONS = 500;
|
||||||
|
|
||||||
|
class HttpError extends Error {
|
||||||
|
constructor(status, message) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {import('./catalogue.mjs').Catalogue} options.catalogue
|
||||||
|
* @param {number} options.port
|
||||||
|
* @param {string} options.host
|
||||||
|
* @param {string} [options.previewToken] Only ever sent for signed-in callers.
|
||||||
|
* @param {boolean} [options.requireAuth]
|
||||||
|
*/
|
||||||
|
export function startHttpServer({ catalogue, port, host, previewToken, requireAuth = false }) {
|
||||||
|
const sessions = new Sessions({ previewToken, requireAuth });
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
handle(req, res, { catalogue, sessions }).catch((error) => {
|
||||||
|
console.error(`landing-mcp: ${error.stack ?? error.message}`);
|
||||||
|
|
||||||
|
if (!res.headersSent) sendError(res, 500, 'Internal error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
console.error(`landing-mcp: listening on http://${host}:${port}/mcp${requireAuth ? '' : ' (anonymous callers get the public API)'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PID 1 in a container: without this, `docker stop` waits out its timeout
|
||||||
|
// and then kills whatever request was in flight.
|
||||||
|
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||||
|
process.once(signal, () => server.close(() => process.exit(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(req, res, { catalogue, sessions }) {
|
||||||
|
const { pathname } = new URL(req.url, 'http://localhost');
|
||||||
|
|
||||||
|
// Liveness only, like the application's /up: it must not fail because the
|
||||||
|
// application is redeploying, or both would restart each other.
|
||||||
|
if (pathname === '/up') return send(res, 200, { status: 'ok' });
|
||||||
|
|
||||||
|
if (pathname !== '/mcp') return sendError(res, 404, 'Not found');
|
||||||
|
|
||||||
|
// Stateless: there is no session to open a stream on or to delete.
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.setHeader('Allow', 'POST');
|
||||||
|
|
||||||
|
return sendError(res, 405, 'This server is stateless and only accepts POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
let current;
|
||||||
|
|
||||||
|
try {
|
||||||
|
current = await catalogue.get();
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, 503, `The API description is not available: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client;
|
||||||
|
|
||||||
|
try {
|
||||||
|
client = await sessions.authenticate(req.headers.authorization, current.apiBase);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof HttpError)) throw error;
|
||||||
|
|
||||||
|
if (error.status === 401) res.setHeader('WWW-Authenticate', 'Basic realm="landing-mcp", charset="UTF-8"');
|
||||||
|
|
||||||
|
return sendError(res, error.status, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = await readJson(req);
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, error.status ?? 400, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcp = createMcpServer({ spec: current.spec, tools: current.tools, client });
|
||||||
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
||||||
|
|
||||||
|
res.on('close', () => {
|
||||||
|
transport.close();
|
||||||
|
mcp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
await mcp.connect(transport);
|
||||||
|
await transport.handleRequest(req, res, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Sessions {
|
||||||
|
constructor({ previewToken, requireAuth }) {
|
||||||
|
this.previewToken = previewToken;
|
||||||
|
this.requireAuth = requireAuth;
|
||||||
|
|
||||||
|
/** @type {Map<string, {client: Promise<LandingClient>, lastUsed: number, expiresAt: number}>} */
|
||||||
|
this.entries = new Map();
|
||||||
|
/** @type {Map<string, number>} */
|
||||||
|
this.rejected = new Map();
|
||||||
|
this.anonymous = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<LandingClient>} */
|
||||||
|
async authenticate(header, apiBase) {
|
||||||
|
if (!header) {
|
||||||
|
if (this.requireAuth) throw new HttpError(401, 'Authentication required: send Basic credentials or a Bearer token');
|
||||||
|
|
||||||
|
// No preview secret: that would hand drafts to anyone who can reach this.
|
||||||
|
this.anonymous ??= new LandingClient({ apiBase });
|
||||||
|
|
||||||
|
return this.anonymous;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = parseAuthorization(header);
|
||||||
|
|
||||||
|
if (!credentials) throw new HttpError(401, 'Unsupported Authorization header: use Basic or Bearer');
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const key = createHash('sha256').update(header).digest('hex');
|
||||||
|
|
||||||
|
this.sweep(now);
|
||||||
|
|
||||||
|
if ((this.rejected.get(key) ?? 0) > now) throw new HttpError(401, 'Credentials refused by the application');
|
||||||
|
|
||||||
|
const entry = this.entries.get(key);
|
||||||
|
|
||||||
|
if (entry && entry.expiresAt > now) {
|
||||||
|
entry.lastUsed = now;
|
||||||
|
|
||||||
|
return entry.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The promise, not the client, so concurrent first requests with the same
|
||||||
|
// credentials sign in once between them.
|
||||||
|
const client = this.open(credentials, apiBase);
|
||||||
|
|
||||||
|
this.entries.set(key, {
|
||||||
|
client,
|
||||||
|
lastUsed: now,
|
||||||
|
expiresAt: credentials.token ? now + BEARER_TTL_MS : Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await client;
|
||||||
|
} catch (error) {
|
||||||
|
this.entries.delete(key);
|
||||||
|
|
||||||
|
if (error instanceof HttpError && error.status === 401) this.rejected.set(key, Date.now() + REJECTION_TTL_MS);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(credentials, apiBase) {
|
||||||
|
const client = new LandingClient({ apiBase, ...credentials, previewToken: this.previewToken });
|
||||||
|
let response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await client.verify();
|
||||||
|
} catch (error) {
|
||||||
|
if ([401, 403, 422].includes(error.status)) throw new HttpError(401, 'Credentials refused by the application');
|
||||||
|
if (error.status === 429) throw new HttpError(429, 'Too many sign-in attempts for this account; wait a minute');
|
||||||
|
|
||||||
|
throw new HttpError(502, `Could not reach the application: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new HttpError(401, 'Token refused by the application');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !== 200) throw new HttpError(502, `The application answered ${response.status} to GET /v1/me`);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
sweep(now) {
|
||||||
|
for (const [key, until] of this.rejected) {
|
||||||
|
if (until <= now) this.rejected.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, entry] of this.entries) {
|
||||||
|
if (now - entry.lastUsed > IDLE_MS || entry.expiresAt <= now) this.entries.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insertion order is sign-in order: the oldest goes first.
|
||||||
|
while (this.entries.size >= MAX_SESSIONS) this.entries.delete(this.entries.keys().next().value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {{username: string, password: string}|{token: string}|null} */
|
||||||
|
export function parseAuthorization(header) {
|
||||||
|
const match = /^(\w+)\s+(\S+)\s*$/.exec(header);
|
||||||
|
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, scheme, value] = match;
|
||||||
|
|
||||||
|
if (scheme.toLowerCase() === 'bearer') return { token: value };
|
||||||
|
|
||||||
|
if (scheme.toLowerCase() === 'basic') {
|
||||||
|
const decoded = Buffer.from(value, 'base64').toString('utf8');
|
||||||
|
const colon = decoded.indexOf(':');
|
||||||
|
|
||||||
|
if (colon < 1 || colon === decoded.length - 1) return null;
|
||||||
|
|
||||||
|
return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(req) {
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
for await (const chunk of req) {
|
||||||
|
size += chunk.length;
|
||||||
|
|
||||||
|
if (size > MAX_BODY_BYTES) throw new HttpError(413, 'Request body too large');
|
||||||
|
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, 'Request body is not valid JSON');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(res, status, payload) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shaped as a JSON-RPC error, which is what an MCP client knows how to show.
|
||||||
|
function sendError(res, status, message) {
|
||||||
|
send(res, status, { jsonrpc: '2.0', error: { code: -32000, message }, id: null });
|
||||||
|
}
|
||||||
52
mcp/src/mcp.mjs
Normal file
52
mcp/src/mcp.mjs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* The MCP server itself, independent of the transport it is reached through.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object} options.spec
|
||||||
|
* @param {import('./tools.mjs').Tool[]} options.tools
|
||||||
|
* @param {import('./client.mjs').LandingClient} options.client
|
||||||
|
*/
|
||||||
|
export function createMcpServer({ spec, tools, client }) {
|
||||||
|
// Offering tools that can only fail would cost the model a round trip each
|
||||||
|
// to learn that; without credentials the admin half is simply not there.
|
||||||
|
const offered = tools.filter((tool) => !tool.operation.requiresAuth || client.canAuthenticate());
|
||||||
|
const byName = new Map(offered.map((tool) => [tool.name, tool]));
|
||||||
|
|
||||||
|
const server = new Server(
|
||||||
|
{ name: 'landing', version: spec.info?.version ?? '1.0.0' },
|
||||||
|
{
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
// The document's introduction already explains the two APIs, auth and
|
||||||
|
// how translatable fields are read and written.
|
||||||
|
instructions: spec.info?.description,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||||
|
tools: offered.map(({ operation, ...tool }) => tool),
|
||||||
|
}));
|
||||||
|
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const tool = byName.get(request.params.name);
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool ${request.params.name}` }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { status, body } = await client.call(tool.operation, request.params.arguments ?? {});
|
||||||
|
const payload = body === null || body === '' ? '' : `\n${typeof body === 'string' ? body : JSON.stringify(body)}`;
|
||||||
|
|
||||||
|
return { isError: status >= 400, content: [{ type: 'text', text: `HTTP ${status}${payload}` }] };
|
||||||
|
} catch (error) {
|
||||||
|
return { isError: true, content: [{ type: 'text', text: error.message }] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
349
mcp/src/tools.mjs
Normal file
349
mcp/src/tools.mjs
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/**
|
||||||
|
* Turns the API's OpenAPI document into MCP tools, one per operation.
|
||||||
|
*
|
||||||
|
* The tools are derived rather than written by hand for the same reason the
|
||||||
|
* TypeScript types are: `openapi.yaml` is held to the truth by the contract
|
||||||
|
* tests, and nothing would hold a second, hand-written description of the API
|
||||||
|
* to anything. A new endpoint documented there is a new tool here with no
|
||||||
|
* change to this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Endpoints that exist for someone other than an operator. Telemetry is the
|
||||||
|
// frontend reporting back with its own secret; the submissions are a visitor's
|
||||||
|
// forms, and a call to one puts a row in the inbox and notifies real people.
|
||||||
|
const EXCLUDED_TAGS = new Set(['Telemetry', 'Submissions']);
|
||||||
|
|
||||||
|
// Obtaining a token is the client's job, not the model's.
|
||||||
|
const EXCLUDED_OPERATIONS = new Set(['POST /v1/auth/token']);
|
||||||
|
|
||||||
|
const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
|
||||||
|
|
||||||
|
// OpenAPI keywords that either mean nothing to JSON Schema or only describe
|
||||||
|
// responses. `nullable` is translated rather than dropped.
|
||||||
|
const DROPPED_KEYWORDS = new Set([
|
||||||
|
'nullable', 'example', 'readOnly', 'writeOnly', 'discriminator', 'xml', 'externalDocs',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} Operation
|
||||||
|
* @property {string} method Upper-case HTTP method.
|
||||||
|
* @property {string} path Path as documented, e.g. /v1/pages/{page}.
|
||||||
|
* @property {{name: string, in: string}[]} params
|
||||||
|
* @property {boolean} hasBody
|
||||||
|
* @property {string[]} files Multipart fields filled from a local file, read from `<name>_path`.
|
||||||
|
* @property {boolean} requiresAuth
|
||||||
|
*
|
||||||
|
* @typedef {object} Tool
|
||||||
|
* @property {string} name
|
||||||
|
* @property {string} description
|
||||||
|
* @property {object} inputSchema
|
||||||
|
* @property {object} annotations
|
||||||
|
* @property {Operation} operation Kept server-side; never sent to the client.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} spec The parsed OpenAPI document.
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean} [options.localFiles] Offer uploads from a path on this
|
||||||
|
* machine. Only true over stdio, where this machine is the caller's own; a
|
||||||
|
* deployed server would be reading its own disk on a stranger's say-so.
|
||||||
|
* @returns {Tool[]}
|
||||||
|
*/
|
||||||
|
export function buildTools(spec, { localFiles = false } = {}) {
|
||||||
|
const paths = Object.keys(spec.paths ?? {});
|
||||||
|
const tools = [];
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
const item = spec.paths[path];
|
||||||
|
|
||||||
|
for (const method of METHODS) {
|
||||||
|
const op = item[method];
|
||||||
|
|
||||||
|
if (!op) continue;
|
||||||
|
if (EXCLUDED_OPERATIONS.has(`${method.toUpperCase()} ${path}`)) continue;
|
||||||
|
if ((op.tags ?? []).some((tag) => EXCLUDED_TAGS.has(tag))) continue;
|
||||||
|
|
||||||
|
const tool = buildTool(spec, paths, path, method, item, op, localFiles);
|
||||||
|
|
||||||
|
if (tool) tools.push(tool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A clash would silently shadow one endpoint behind another, so it is a
|
||||||
|
// startup failure rather than something to discover by calling the wrong one.
|
||||||
|
const seen = new Map();
|
||||||
|
|
||||||
|
for (const tool of tools) {
|
||||||
|
const { method, path } = tool.operation;
|
||||||
|
|
||||||
|
if (seen.has(tool.name)) {
|
||||||
|
throw new Error(`Tool name "${tool.name}" is derived from both ${seen.get(tool.name)} and ${method} ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.set(tool.name, `${method} ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTool(spec, paths, path, method, item, op, localFiles) {
|
||||||
|
const params = mergeParameters(spec, item.parameters, op.parameters)
|
||||||
|
// Headers are credentials here (preview and ingest secrets), which come
|
||||||
|
// from the server's configuration, never from the model.
|
||||||
|
.filter((param) => param.in === 'path' || param.in === 'query');
|
||||||
|
|
||||||
|
const properties = {};
|
||||||
|
const required = [];
|
||||||
|
|
||||||
|
for (const param of params) {
|
||||||
|
const schema = toJsonSchema(spec, param.schema ?? { type: 'string' });
|
||||||
|
|
||||||
|
properties[param.name] = param.description
|
||||||
|
? { ...schema, description: param.description.trim() }
|
||||||
|
: schema;
|
||||||
|
|
||||||
|
if (param.required || param.in === 'path') required.push(param.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodySchema = op.requestBody?.content?.['application/json']?.schema;
|
||||||
|
const multipartSchema = op.requestBody?.content?.['multipart/form-data']?.schema;
|
||||||
|
|
||||||
|
// A file in a multipart body cannot travel as JSON — as base64 an ordinary
|
||||||
|
// photo costs hundreds of thousands of tokens — so the model names a path
|
||||||
|
// and the server reads it. Only where that path is on the caller's machine.
|
||||||
|
const files = localFiles && multipartSchema
|
||||||
|
? Object.entries(toJsonSchema(spec, multipartSchema).properties ?? {})
|
||||||
|
.filter(([, property]) => property.format === 'binary')
|
||||||
|
.map(([name]) => name)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Multipart only, and no way to send a file from here: nothing to offer.
|
||||||
|
if (!bodySchema && multipartSchema && files.length === 0) return null;
|
||||||
|
|
||||||
|
for (const field of files) {
|
||||||
|
const name = `${field}_path`;
|
||||||
|
|
||||||
|
if (name in properties) {
|
||||||
|
throw new Error(`${method.toUpperCase()} ${path} has a parameter named "${name}", which the file upload needs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
properties[name] = {
|
||||||
|
type: 'string',
|
||||||
|
description: `Absolute path to a file on this machine, uploaded as \`${field}\`. Read by the MCP server; use instead of \`body\`.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bodySchema) {
|
||||||
|
if ('body' in properties) {
|
||||||
|
throw new Error(`${method.toUpperCase()} ${path} has a parameter named "body", which the request body needs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = toJsonSchema(spec, bodySchema);
|
||||||
|
|
||||||
|
// Create and update share one write schema, so its `required` describes
|
||||||
|
// creation. On a partial update it would force the model to resend fields
|
||||||
|
// it has no reason to touch.
|
||||||
|
if (method === 'patch') delete body.required;
|
||||||
|
|
||||||
|
// Beside a file, the body carries the upload's other fields (`for`, say)
|
||||||
|
// rather than the JSON alternative, whose own requirements — a `url` —
|
||||||
|
// would then be wrong.
|
||||||
|
if (files.length > 0) delete body.required;
|
||||||
|
|
||||||
|
properties.body = {
|
||||||
|
...body,
|
||||||
|
description: files.length > 0
|
||||||
|
? 'JSON request body; with a file, its other fields go here too.'
|
||||||
|
: 'JSON request body.',
|
||||||
|
};
|
||||||
|
|
||||||
|
// With a file to send instead, the JSON body is the other way in rather
|
||||||
|
// than a requirement; the API says which one it missed if neither comes.
|
||||||
|
if (op.requestBody.required && files.length === 0) required.push('body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const security = op.security ?? spec.security ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: toolName(paths, path, method),
|
||||||
|
description: describe(path, method, op, security.length > 0),
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties,
|
||||||
|
...(required.length ? { required } : {}),
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
title: op.summary,
|
||||||
|
readOnlyHint: method === 'get',
|
||||||
|
destructiveHint: method === 'delete',
|
||||||
|
idempotentHint: method === 'get' || method === 'put' || method === 'delete',
|
||||||
|
},
|
||||||
|
operation: {
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
path,
|
||||||
|
params: params.map(({ name, in: location }) => ({ name, in: location })),
|
||||||
|
hasBody: Boolean(bodySchema),
|
||||||
|
files,
|
||||||
|
requiresAuth: security.length > 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named after the Laravel route the operation lands on, so a tool reads the
|
||||||
|
* same as `routes/api.php`: /v1/pages/{page} under PATCH is admin_pages_update,
|
||||||
|
* /public/v1/projects/{slug} under GET is public_projects_show.
|
||||||
|
*/
|
||||||
|
export function toolName(paths, path, method) {
|
||||||
|
const segments = path.split('/').filter(Boolean).filter((s) => s !== 'v1');
|
||||||
|
const scope = segments[0] === 'public' ? 'public' : 'admin';
|
||||||
|
|
||||||
|
if (segments[0] === 'public') segments.shift();
|
||||||
|
|
||||||
|
const isParam = (segment) => segment.startsWith('{');
|
||||||
|
const endsInParam = isParam(segments.at(-1));
|
||||||
|
const base = segments.filter((s) => !isParam(s)).map((s) => s.replaceAll('-', '_'));
|
||||||
|
|
||||||
|
let action = null;
|
||||||
|
|
||||||
|
if (endsInParam) {
|
||||||
|
action = { get: 'show', put: 'update', patch: 'update', delete: 'destroy' }[method];
|
||||||
|
} else if (method === 'get') {
|
||||||
|
// A collection has an item route next to it; /site or /radar does not,
|
||||||
|
// and is named after itself alone.
|
||||||
|
const hasItemRoute = paths.some((p) => p.startsWith(`${path}/{`));
|
||||||
|
action = hasItemRoute ? 'index' : null;
|
||||||
|
} else if (method === 'post') {
|
||||||
|
// /pages/{page}/restore is an action named by its last segment already.
|
||||||
|
action = segments.length > 1 && isParam(segments.at(-2)) ? null : 'store';
|
||||||
|
} else {
|
||||||
|
action = method;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [scope, ...base, ...(action ? [action] : [])].join('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(path, method, op, requiresAuth) {
|
||||||
|
const parts = [op.summary, op.description].filter(Boolean).map((s) => s.trim());
|
||||||
|
|
||||||
|
parts.push(`${method.toUpperCase()} /api${path} · ${requiresAuth ? 'authenticated' : 'public, no token'}`);
|
||||||
|
|
||||||
|
return parts.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path-level parameters apply to every operation under the path; an operation
|
||||||
|
* may redeclare one, and its own declaration wins.
|
||||||
|
*/
|
||||||
|
function mergeParameters(spec, pathLevel = [], operationLevel = []) {
|
||||||
|
const merged = new Map();
|
||||||
|
|
||||||
|
for (const raw of [...pathLevel, ...operationLevel]) {
|
||||||
|
const param = resolve(spec, raw);
|
||||||
|
merged.set(`${param.in}:${param.name}`, param);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...merged.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(spec, node) {
|
||||||
|
let current = node;
|
||||||
|
const visited = new Set();
|
||||||
|
|
||||||
|
while (current && typeof current.$ref === 'string') {
|
||||||
|
if (visited.has(current.$ref)) throw new Error(`Circular $ref ${current.$ref}`);
|
||||||
|
|
||||||
|
visited.add(current.$ref);
|
||||||
|
current = lookup(spec, current.$ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookup(spec, ref) {
|
||||||
|
if (!ref.startsWith('#/')) throw new Error(`Only local $refs are supported, got ${ref}`);
|
||||||
|
|
||||||
|
const target = ref
|
||||||
|
.slice(2)
|
||||||
|
.split('/')
|
||||||
|
.map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~'))
|
||||||
|
.reduce((node, key) => node?.[key], spec);
|
||||||
|
|
||||||
|
if (target === undefined) throw new Error(`Unresolvable $ref ${ref}`);
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI 3.0 schema → self-contained JSON Schema: references inlined (MCP
|
||||||
|
* clients receive each tool's schema on its own, with no `components` to point
|
||||||
|
* into) and `nullable` spelled the way JSON Schema spells it.
|
||||||
|
*/
|
||||||
|
export function toJsonSchema(spec, schema, stack = []) {
|
||||||
|
if (schema === null || typeof schema !== 'object') return schema;
|
||||||
|
|
||||||
|
if (typeof schema.$ref === 'string') {
|
||||||
|
// A recursive schema cannot be inlined forever; past the first repeat the
|
||||||
|
// model gets "anything" and the server's validation has the last word.
|
||||||
|
if (stack.includes(schema.$ref)) return {};
|
||||||
|
|
||||||
|
const { $ref, nullable: isNullable, ...siblings } = schema;
|
||||||
|
|
||||||
|
return nullable(isNullable, {
|
||||||
|
...toJsonSchema(spec, lookup(spec, $ref), [...stack, $ref]),
|
||||||
|
...toJsonSchema(spec, siblings, stack),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// `allOf: [{$ref}]` next to a description is how this document annotates a
|
||||||
|
// shared schema in place. Flattened, the model reads one schema, not a
|
||||||
|
// composition of one.
|
||||||
|
if (Array.isArray(schema.allOf) && schema.allOf.length === 1) {
|
||||||
|
const { allOf, nullable: isNullable, ...siblings } = schema;
|
||||||
|
|
||||||
|
return nullable(isNullable, {
|
||||||
|
...toJsonSchema(spec, allOf[0], stack),
|
||||||
|
...toJsonSchema(spec, siblings, stack),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(schema)) {
|
||||||
|
if (DROPPED_KEYWORDS.has(key)) continue;
|
||||||
|
|
||||||
|
if (key === 'properties') {
|
||||||
|
out.properties = Object.fromEntries(
|
||||||
|
Object.entries(value)
|
||||||
|
// Response-only fields have no business in something the model writes.
|
||||||
|
.filter(([, property]) => !resolve(spec, property)?.readOnly)
|
||||||
|
.map(([name, property]) => [name, toJsonSchema(spec, property, stack)]),
|
||||||
|
);
|
||||||
|
} else if (['allOf', 'oneOf', 'anyOf'].includes(key)) {
|
||||||
|
out[key] = value.map((member) => toJsonSchema(spec, member, stack));
|
||||||
|
} else if (['items', 'additionalProperties', 'not'].includes(key) && typeof value === 'object') {
|
||||||
|
out[key] = toJsonSchema(spec, value, stack);
|
||||||
|
} else {
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nullable(schema.nullable, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullable(isNullable, schema) {
|
||||||
|
if (!isNullable) return schema;
|
||||||
|
|
||||||
|
if (typeof schema.type === 'string') {
|
||||||
|
const out = { ...schema, type: [schema.type, 'null'] };
|
||||||
|
|
||||||
|
if (Array.isArray(out.enum) && !out.enum.includes(null)) out.enum = [...out.enum, null];
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { description, ...rest } = schema;
|
||||||
|
|
||||||
|
return { ...(description ? { description } : {}), anyOf: [rest, { type: 'null' }] };
|
||||||
|
}
|
||||||
198
mcp/test/client.test.mjs
Normal file
198
mcp/test/client.test.mjs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { LandingClient } from '../src/client.mjs';
|
||||||
|
|
||||||
|
const showPage = {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/v1/pages/{page}',
|
||||||
|
params: [{ name: 'page', in: 'path' }, { name: 'lang', in: 'query' }],
|
||||||
|
hasBody: false,
|
||||||
|
requiresAuth: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const publicProjects = {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/public/v1/projects',
|
||||||
|
params: [{ name: 'featured', in: 'query' }],
|
||||||
|
hasBody: false,
|
||||||
|
requiresAuth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A fake fetch that answers from a list of handlers and records every call. */
|
||||||
|
function fakeFetch(handler) {
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
const fetch = async (url, init) => {
|
||||||
|
calls.push({ url, ...init });
|
||||||
|
const { status = 200, body = {} } = handler(url, init, calls.length);
|
||||||
|
|
||||||
|
return new Response(status === 204 ? null : JSON.stringify(body), { status });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { fetch, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResponse = (access, expiresInMs = 3_600_000) => ({
|
||||||
|
access_token: access,
|
||||||
|
refresh_token: `refresh-${access}`,
|
||||||
|
expires_at: new Date(Date.now() + expiresInMs).toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds the URL from path and query parameters', async () => {
|
||||||
|
const { fetch, calls } = fakeFetch(() => ({ body: { data: [] } }));
|
||||||
|
const client = new LandingClient({ apiBase: 'http://app.test/api/', fetch });
|
||||||
|
|
||||||
|
await client.call(publicProjects, { featured: true });
|
||||||
|
|
||||||
|
assert.equal(calls[0].url, 'http://app.test/api/public/v1/projects?featured=1');
|
||||||
|
assert.equal(calls[0].headers.Authorization, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sends the preview secret on public calls only', async () => {
|
||||||
|
const { fetch, calls } = fakeFetch((url) => (
|
||||||
|
url.endsWith('/auth/token') ? { body: tokenResponse('a') } : { body: {} }
|
||||||
|
));
|
||||||
|
const client = new LandingClient({
|
||||||
|
apiBase: 'http://app.test/api', username: 'u', password: 'p', previewToken: 'secret', fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.call(publicProjects, {});
|
||||||
|
await client.call(showPage, { page: 1 });
|
||||||
|
|
||||||
|
assert.equal(calls[0].headers['X-Preview-Token'], 'secret');
|
||||||
|
assert.equal(calls.at(-1).headers['X-Preview-Token'], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logs in once and reuses the token', async () => {
|
||||||
|
const { fetch, calls } = fakeFetch((url) => (
|
||||||
|
url.endsWith('/auth/token') ? { body: tokenResponse('a') } : { body: { data: {} } }
|
||||||
|
));
|
||||||
|
const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch });
|
||||||
|
|
||||||
|
await Promise.all([client.call(showPage, { page: 1 }), client.call(showPage, { page: 2 })]);
|
||||||
|
await client.call(showPage, { page: 3 });
|
||||||
|
|
||||||
|
assert.equal(calls.filter((c) => c.url.endsWith('/auth/token')).length, 1);
|
||||||
|
assert.equal(calls.at(-1).url, 'http://app.test/api/v1/pages/3');
|
||||||
|
assert.equal(calls.at(-1).headers.Authorization, 'Bearer a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshes a token about to expire', async () => {
|
||||||
|
let issued = 0;
|
||||||
|
const { fetch, calls } = fakeFetch((url) => (
|
||||||
|
url.endsWith('/auth/token') ? { body: tokenResponse(`t${++issued}`, 10_000) } : { body: {} }
|
||||||
|
));
|
||||||
|
const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch });
|
||||||
|
|
||||||
|
await client.call(showPage, { page: 1 });
|
||||||
|
await client.call(showPage, { page: 1 });
|
||||||
|
|
||||||
|
const grants = calls.filter((c) => c.url.endsWith('/auth/token')).map((c) => JSON.parse(c.body).grant_type);
|
||||||
|
|
||||||
|
assert.deepEqual(grants, ['password', 'refresh_token']);
|
||||||
|
assert.equal(calls.at(-1).headers.Authorization, 'Bearer t2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retries once with a new token after a 401', async () => {
|
||||||
|
let issued = 0;
|
||||||
|
const { fetch, calls } = fakeFetch((url, init) => {
|
||||||
|
if (url.endsWith('/auth/token')) return { body: tokenResponse(`t${++issued}`) };
|
||||||
|
|
||||||
|
return init.headers.Authorization === 'Bearer t1' ? { status: 401, body: { message: 'Unauthenticated.' } } : { body: { ok: true } };
|
||||||
|
});
|
||||||
|
const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch });
|
||||||
|
|
||||||
|
const response = await client.call(showPage, { page: 1 });
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(calls.at(-1).headers.Authorization, 'Bearer t2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the password when the refresh token is refused', async () => {
|
||||||
|
let issued = 0;
|
||||||
|
const { fetch, calls } = fakeFetch((url, init) => {
|
||||||
|
if (!url.endsWith('/auth/token')) return { body: {} };
|
||||||
|
|
||||||
|
return JSON.parse(init.body).grant_type === 'refresh_token'
|
||||||
|
? { status: 422, body: { message: 'invalid' } }
|
||||||
|
: { body: tokenResponse(`t${++issued}`, 10_000) };
|
||||||
|
});
|
||||||
|
const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch });
|
||||||
|
|
||||||
|
await client.call(showPage, { page: 1 });
|
||||||
|
await client.call(showPage, { page: 1 });
|
||||||
|
|
||||||
|
const grants = calls.filter((c) => c.url.endsWith('/auth/token')).map((c) => JSON.parse(c.body).grant_type);
|
||||||
|
|
||||||
|
assert.deepEqual(grants, ['password', 'refresh_token', 'password']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fixed token is used as is and never renewed', async () => {
|
||||||
|
const { fetch, calls } = fakeFetch(() => ({ status: 401, body: {} }));
|
||||||
|
const client = new LandingClient({ apiBase: 'http://app.test/api', token: 'fixed', fetch });
|
||||||
|
|
||||||
|
const response = await client.call(showPage, { page: 1 });
|
||||||
|
|
||||||
|
assert.equal(response.status, 401);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].headers.Authorization, 'Bearer fixed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses an authenticated call without credentials', async () => {
|
||||||
|
const client = new LandingClient({ apiBase: 'http://app.test/api', fetch: async () => assert.fail('no request') });
|
||||||
|
|
||||||
|
await assert.rejects(client.call(showPage, { page: 1 }), /LANDING_TOKEN/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a call missing a path parameter', async () => {
|
||||||
|
const client = new LandingClient({ apiBase: 'http://app.test/api', token: 't', fetch: async () => assert.fail('no request') });
|
||||||
|
|
||||||
|
await assert.rejects(client.call(showPage, {}), /"page"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMedia = {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/v1/media',
|
||||||
|
params: [],
|
||||||
|
hasBody: true,
|
||||||
|
files: ['file'],
|
||||||
|
requiresAuth: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('uploads a local file as multipart, never as JSON', async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'landing-mcp-'));
|
||||||
|
const file = join(dir, 'hero.png');
|
||||||
|
await writeFile(file, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||||
|
|
||||||
|
const { fetch, calls } = fakeFetch(() => ({ status: 201, body: { data: { path: 'pages/x.png' } } }));
|
||||||
|
const client = new LandingClient({ apiBase: 'http://app.test/api', token: 't', fetch });
|
||||||
|
|
||||||
|
const response = await client.call(uploadMedia, { file_path: file });
|
||||||
|
const sent = calls[0].body;
|
||||||
|
|
||||||
|
assert.equal(response.status, 201);
|
||||||
|
assert.ok(sent instanceof FormData);
|
||||||
|
assert.equal(sent.get('file').name, 'hero.png');
|
||||||
|
assert.equal(sent.get('file').size, 4);
|
||||||
|
// fetch has to write it, boundary and all.
|
||||||
|
assert.equal(calls[0].headers['Content-Type'], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sends the JSON body when there is no file', async () => {
|
||||||
|
const { fetch, calls } = fakeFetch(() => ({ status: 201, body: {} }));
|
||||||
|
const client = new LandingClient({ apiBase: 'http://app.test/api', token: 't', fetch });
|
||||||
|
|
||||||
|
await client.call(uploadMedia, { body: { url: 'https://images.example.org/a.png' } });
|
||||||
|
|
||||||
|
assert.equal(calls[0].headers['Content-Type'], 'application/json');
|
||||||
|
assert.equal(JSON.parse(calls[0].body).url, 'https://images.example.org/a.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('says which file it could not read', async () => {
|
||||||
|
const client = new LandingClient({ apiBase: 'http://app.test/api', token: 't', fetch: async () => assert.fail('no request') });
|
||||||
|
|
||||||
|
await assert.rejects(client.call(uploadMedia, { file_path: '/nope/missing.png' }), /\/nope\/missing\.png/);
|
||||||
|
});
|
||||||
155
mcp/test/http.test.mjs
Normal file
155
mcp/test/http.test.mjs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { after, before, test } from 'node:test';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { Catalogue } from '../src/catalogue.mjs';
|
||||||
|
import { parseAuthorization, startHttpServer } from '../src/http.mjs';
|
||||||
|
|
||||||
|
const spec = await readFile(new URL('../../public/openapi.yaml', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stands in for the application: the spec, the token endpoint, /v1/me and one
|
||||||
|
* public endpoint that reports whether it was sent the preview secret.
|
||||||
|
*/
|
||||||
|
const app = { logins: 0, requests: [] };
|
||||||
|
|
||||||
|
const appServer = createServer(async (req, res) => {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) chunks.push(chunk);
|
||||||
|
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
|
||||||
|
|
||||||
|
app.requests.push({ url: req.url, headers: req.headers });
|
||||||
|
|
||||||
|
const json = (status, payload) => {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.url === '/openapi.yaml') return res.end(spec);
|
||||||
|
|
||||||
|
if (req.url === '/api/v1/auth/token') {
|
||||||
|
app.logins++;
|
||||||
|
|
||||||
|
return body.username === 'ada@example.com' && body.password === 'secret'
|
||||||
|
? json(200, { access_token: 'ada-token', refresh_token: 'r', expires_at: new Date(Date.now() + 3_600_000).toISOString() })
|
||||||
|
: json(422, { message: 'These credentials do not match our records.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url.startsWith('/api/v1/')) {
|
||||||
|
return ['Bearer ada-token', 'Bearer valid-token'].includes(req.headers.authorization)
|
||||||
|
? json(200, { data: { email: 'ada@example.com' } })
|
||||||
|
: json(401, { message: 'Unauthenticated.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url.startsWith('/api/public/v1/site')) {
|
||||||
|
return json(200, { data: { preview: req.headers['x-preview-token'] === 'drafts' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
json(404, { message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
let mcpUrl;
|
||||||
|
let mcpServer;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
appServer.listen(0, '127.0.0.1');
|
||||||
|
await once(appServer, 'listening');
|
||||||
|
|
||||||
|
const appUrl = `http://127.0.0.1:${appServer.address().port}`;
|
||||||
|
|
||||||
|
mcpServer = startHttpServer({
|
||||||
|
catalogue: new Catalogue({ source: `${appUrl}/openapi.yaml`, baseUrl: appUrl }),
|
||||||
|
port: 0,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
previewToken: 'drafts',
|
||||||
|
});
|
||||||
|
await once(mcpServer, 'listening');
|
||||||
|
|
||||||
|
mcpUrl = new URL(`http://127.0.0.1:${mcpServer.address().port}/mcp`);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
mcpServer.close();
|
||||||
|
appServer.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function connect(authorization) {
|
||||||
|
const client = new Client({ name: 'test', version: '0' });
|
||||||
|
const headers = authorization ? { Authorization: authorization } : {};
|
||||||
|
|
||||||
|
await client.connect(new StreamableHTTPClientTransport(mcpUrl, { requestInit: { headers } }));
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basic = (user, password) => `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
|
||||||
|
|
||||||
|
test('an anonymous caller gets the public API, without the preview secret', async () => {
|
||||||
|
const client = await connect();
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
|
||||||
|
assert.ok(tools.length > 0);
|
||||||
|
assert.ok(tools.every((tool) => tool.name.startsWith('public_')));
|
||||||
|
|
||||||
|
const result = await client.callTool({ name: 'public_site', arguments: {} });
|
||||||
|
|
||||||
|
assert.match(result.content[0].text, /"preview":false/);
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Basic credentials get both APIs, and sign in once across requests', async () => {
|
||||||
|
const before = app.logins;
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const client = await connect(basic('ada@example.com', 'secret'));
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
|
||||||
|
assert.ok(tools.some((tool) => tool.name === 'admin_pages_update'));
|
||||||
|
|
||||||
|
const me = await client.callTool({ name: 'admin_me', arguments: {} });
|
||||||
|
assert.equal(me.isError, false);
|
||||||
|
|
||||||
|
const site = await client.callTool({ name: 'public_site', arguments: {} });
|
||||||
|
assert.match(site.content[0].text, /"preview":true/);
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(app.logins - before, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong credentials are a 401, remembered rather than retried', async () => {
|
||||||
|
const before = app.logins;
|
||||||
|
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
await assert.rejects(connect(basic('ada@example.com', 'wrong')), /401|Credentials refused/);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(app.logins - before, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a bearer token is checked against the application', async () => {
|
||||||
|
const good = await connect('Bearer valid-token');
|
||||||
|
assert.ok((await good.listTools()).tools.some((tool) => tool.name === 'admin_me'));
|
||||||
|
await good.close();
|
||||||
|
|
||||||
|
await assert.rejects(connect('Bearer revoked-token'), /401|refused/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only POST /mcp is served, and /up answers without the application', async () => {
|
||||||
|
const base = `http://127.0.0.1:${mcpServer.address().port}`;
|
||||||
|
|
||||||
|
assert.equal((await fetch(`${base}/up`)).status, 200);
|
||||||
|
assert.equal((await fetch(`${base}/mcp`)).status, 405);
|
||||||
|
assert.equal((await fetch(`${base}/elsewhere`)).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses Authorization headers', () => {
|
||||||
|
assert.deepEqual(parseAuthorization(basic('a@b.c', 'p:with:colons')), { username: 'a@b.c', password: 'p:with:colons' });
|
||||||
|
assert.deepEqual(parseAuthorization('bearer abc'), { token: 'abc' });
|
||||||
|
assert.equal(parseAuthorization(basic('', 'p')), null);
|
||||||
|
assert.equal(parseAuthorization('Digest abc'), null);
|
||||||
|
});
|
||||||
105
mcp/test/tools.test.mjs
Normal file
105
mcp/test/tools.test.mjs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { parse } from 'yaml';
|
||||||
|
import { buildTools } from '../src/tools.mjs';
|
||||||
|
|
||||||
|
// The document in this checkout: the one the application serves.
|
||||||
|
const spec = parse(await readFile(new URL('../../public/openapi.yaml', import.meta.url), 'utf8'));
|
||||||
|
const tools = buildTools(spec);
|
||||||
|
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
||||||
|
|
||||||
|
test('names follow the Laravel routes', () => {
|
||||||
|
const expected = {
|
||||||
|
public_site: 'GET /public/v1/site',
|
||||||
|
public_good_first_issues: 'GET /public/v1/good-first-issues',
|
||||||
|
public_projects_index: 'GET /public/v1/projects',
|
||||||
|
public_projects_show: 'GET /public/v1/projects/{slug}',
|
||||||
|
public_clients: 'GET /public/v1/clients',
|
||||||
|
admin_me: 'GET /v1/me',
|
||||||
|
admin_pages_index: 'GET /v1/pages',
|
||||||
|
admin_pages_store: 'POST /v1/pages',
|
||||||
|
admin_pages_update: 'PATCH /v1/pages/{page}',
|
||||||
|
admin_pages_destroy: 'DELETE /v1/pages/{page}',
|
||||||
|
admin_pages_restore: 'POST /v1/pages/{page}/restore',
|
||||||
|
admin_roles_catalogue: 'GET /v1/roles/catalogue',
|
||||||
|
admin_technologies_index: 'GET /v1/technologies',
|
||||||
|
admin_technologies_store: 'POST /v1/technologies',
|
||||||
|
admin_technologies_update: 'PATCH /v1/technologies/{technology}',
|
||||||
|
admin_media_store: 'POST /v1/media',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, route] of Object.entries(expected)) {
|
||||||
|
const tool = byName.get(name);
|
||||||
|
assert.ok(tool, `missing ${name}`);
|
||||||
|
assert.equal(`${tool.operation.method} ${tool.operation.path}`, route);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('names are valid MCP tool names', () => {
|
||||||
|
for (const { name } of tools) assert.match(name, /^[a-z0-9_]{1,64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves out what is not for an operator', () => {
|
||||||
|
const routes = tools.map(({ operation }) => `${operation.method} ${operation.path}`);
|
||||||
|
|
||||||
|
assert.ok(!routes.includes('POST /v1/auth/token'));
|
||||||
|
assert.ok(!routes.some((route) => route.includes('/telemetry/')));
|
||||||
|
assert.ok(!routes.includes('POST /public/v1/contact'));
|
||||||
|
assert.ok(!routes.includes('POST /public/v1/calls/{slug}/apply'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every other documented operation is a tool', () => {
|
||||||
|
const documented = Object.values(spec.paths)
|
||||||
|
.flatMap((item) => ['get', 'post', 'put', 'patch', 'delete'].filter((m) => item[m])).length;
|
||||||
|
|
||||||
|
// auth/token, two telemetry endpoints, contact and apply.
|
||||||
|
assert.equal(tools.length, documented - 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('schemas are self-contained JSON Schema', () => {
|
||||||
|
for (const tool of tools) {
|
||||||
|
const json = JSON.stringify(tool.inputSchema);
|
||||||
|
|
||||||
|
assert.ok(!json.includes('"$ref"'), `${tool.name} still has a $ref`);
|
||||||
|
assert.ok(!json.includes('"nullable"'), `${tool.name} still has nullable`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('path parameters are required, headers are not offered', () => {
|
||||||
|
const show = byName.get('public_projects_show');
|
||||||
|
|
||||||
|
assert.deepEqual(show.inputSchema.required, ['slug']);
|
||||||
|
assert.ok(!('X-Preview-Token' in show.inputSchema.properties));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a create requires its fields, an update does not', () => {
|
||||||
|
assert.deepEqual(byName.get('admin_pages_store').inputSchema.properties.body.required, ['title', 'slug']);
|
||||||
|
assert.equal(byName.get('admin_pages_update').inputSchema.properties.body.required, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the admin API is marked as needing a token', () => {
|
||||||
|
for (const tool of tools) {
|
||||||
|
assert.equal(tool.operation.requiresAuth, tool.name.startsWith('admin_'), tool.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('over stdio an upload takes a local path, and the JSON body becomes optional', () => {
|
||||||
|
const local = buildTools(spec, { localFiles: true }).find((tool) => tool.name === 'admin_media_store');
|
||||||
|
|
||||||
|
assert.deepEqual(local.operation.files, ['file']);
|
||||||
|
assert.equal(local.inputSchema.properties.file_path.type, 'string');
|
||||||
|
assert.ok(local.inputSchema.properties.body.properties.url);
|
||||||
|
assert.ok(local.inputSchema.properties.body.properties.for);
|
||||||
|
assert.equal(local.inputSchema.required, undefined);
|
||||||
|
// `url` is the JSON alternative; beside a file it must not be demanded.
|
||||||
|
assert.equal(local.inputSchema.properties.body.required, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('over HTTP there is no local path to offer: only the URL', () => {
|
||||||
|
const remote = byName.get('admin_media_store');
|
||||||
|
|
||||||
|
assert.deepEqual(remote.operation.files, []);
|
||||||
|
assert.ok(!('file_path' in remote.inputSchema.properties));
|
||||||
|
assert.deepEqual(remote.inputSchema.required, ['body']);
|
||||||
|
});
|
||||||
@@ -18,6 +18,10 @@
|
|||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
|
<!-- The suite, not any one test, outgrew PHP's usual 128M: the panel
|
||||||
|
tests boot Filament again and again in one process. Stated here so
|
||||||
|
it runs the same on every machine, whatever its php.ini says. -->
|
||||||
|
<ini name="memory_limit" value="512M"/>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
|
|||||||
2308
public/openapi.yaml
2308
public/openapi.yaml
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
|||||||
{{-- Rendered at the very top of every page in the panel, on every install.
|
|
||||||
|
|
||||||
The failure this exists for runs in one direction only: mistaking a real
|
|
||||||
deployment for a throwaway copy, not the other way round. Nobody edits a
|
|
||||||
local clone carefully because they think it is production — the mistake
|
|
||||||
that costs something is the reverse. So `local` stays silent, and
|
|
||||||
anything else says so, loudly in the one case that matters. --}}
|
|
||||||
|
|
||||||
@unless (app()->environment('local'))
|
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
@class([
|
|
||||||
'w-full py-1 text-center text-xs font-semibold tracking-wide text-white uppercase',
|
|
||||||
'bg-danger-600' => app()->environment('production'),
|
|
||||||
'bg-warning-600' => ! app()->environment('production'),
|
|
||||||
])
|
|
||||||
>
|
|
||||||
{{ app()->environment('production')
|
|
||||||
? __('panel.environment.production')
|
|
||||||
: __('panel.environment.other', ['name' => app()->environment()]) }}
|
|
||||||
</div>
|
|
||||||
@endunless
|
|
||||||
@@ -10,10 +10,25 @@ use App\Http\Controllers\Api\PublicApi\V1\ProjectController as PublicProjectCont
|
|||||||
use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController;
|
use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController;
|
||||||
use App\Http\Controllers\Api\PublicApi\V1\SiteController;
|
use App\Http\Controllers\Api\PublicApi\V1\SiteController;
|
||||||
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
|
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
|
||||||
|
use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationController;
|
||||||
|
use App\Http\Controllers\Api\V1\CallController;
|
||||||
use App\Http\Controllers\Api\V1\CategoryController;
|
use App\Http\Controllers\Api\V1\CategoryController;
|
||||||
|
use App\Http\Controllers\Api\V1\ClientController;
|
||||||
|
use App\Http\Controllers\Api\V1\MaintenanceController;
|
||||||
use App\Http\Controllers\Api\V1\MeController;
|
use App\Http\Controllers\Api\V1\MeController;
|
||||||
|
use App\Http\Controllers\Api\V1\MediaController;
|
||||||
|
use App\Http\Controllers\Api\V1\MemberController;
|
||||||
|
use App\Http\Controllers\Api\V1\MissingUrlController;
|
||||||
use App\Http\Controllers\Api\V1\PageController;
|
use App\Http\Controllers\Api\V1\PageController;
|
||||||
|
use App\Http\Controllers\Api\V1\ProjectController;
|
||||||
|
use App\Http\Controllers\Api\V1\RedirectController;
|
||||||
|
use App\Http\Controllers\Api\V1\RepositoryController;
|
||||||
use App\Http\Controllers\Api\V1\RoleController;
|
use App\Http\Controllers\Api\V1\RoleController;
|
||||||
|
use App\Http\Controllers\Api\V1\ServiceController;
|
||||||
|
use App\Http\Controllers\Api\V1\SettingController;
|
||||||
|
use App\Http\Controllers\Api\V1\TalkController;
|
||||||
|
use App\Http\Controllers\Api\V1\TechnologyController;
|
||||||
|
use App\Http\Controllers\Api\V1\TestimonialController;
|
||||||
use App\Http\Controllers\Api\V1\TokenController;
|
use App\Http\Controllers\Api\V1\TokenController;
|
||||||
use App\Http\Controllers\Api\V1\UserController;
|
use App\Http\Controllers\Api\V1\UserController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -152,7 +167,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
// Convenience endpoint for first-party clients: exchanges credentials for
|
// Convenience endpoint for first-party clients: exchanges credentials for
|
||||||
// a token upstream so they do not need to know the identity server's URL.
|
// a token upstream so they do not need to know the identity server's URL.
|
||||||
Route::post('auth/token', [TokenController::class, 'store'])
|
Route::post('auth/token', [TokenController::class, 'store'])
|
||||||
->middleware('throttle:6,1')
|
->middleware('throttle:auth-token')
|
||||||
->name('auth.token');
|
->name('auth.token');
|
||||||
|
|
||||||
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
|
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
|
||||||
@@ -165,6 +180,60 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
|
|
||||||
Route::apiResource('categories', CategoryController::class);
|
Route::apiResource('categories', CategoryController::class);
|
||||||
|
|
||||||
|
Route::apiResource('technologies', TechnologyController::class);
|
||||||
|
|
||||||
|
// The collective's work, people and offer, and who it was for. Soft-deleted, so each can
|
||||||
|
// be brought back as a page can.
|
||||||
|
foreach ([
|
||||||
|
'projects' => ProjectController::class,
|
||||||
|
'members' => MemberController::class,
|
||||||
|
'services' => ServiceController::class,
|
||||||
|
'clients' => ClientController::class,
|
||||||
|
'testimonials' => TestimonialController::class,
|
||||||
|
] as $resource => $controller) {
|
||||||
|
$parameter = str($resource)->singular()->toString();
|
||||||
|
|
||||||
|
Route::apiResource($resource, $controller);
|
||||||
|
Route::post("{$resource}/{{$parameter}}/restore", [$controller, 'restore'])
|
||||||
|
->withTrashed()
|
||||||
|
->name("{$resource}.restore");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleted for good, like the panel does: neither has a bin.
|
||||||
|
Route::apiResource('talks', TalkController::class);
|
||||||
|
Route::apiResource('calls', CallController::class);
|
||||||
|
|
||||||
|
// The inbox. Submissions arrive from the public site, never from here.
|
||||||
|
Route::apiResource('applications', AdminApplicationController::class)
|
||||||
|
->only(['index', 'show', 'update', 'destroy']);
|
||||||
|
Route::post('applications/{application}/restore', [AdminApplicationController::class, 'restore'])
|
||||||
|
->withTrashed()
|
||||||
|
->name('applications.restore');
|
||||||
|
|
||||||
|
// Old addresses, and the missing ones the frontend reported.
|
||||||
|
Route::apiResource('redirects', RedirectController::class);
|
||||||
|
Route::apiResource('missing-urls', MissingUrlController::class)->only(['index', 'show', 'update', 'destroy']);
|
||||||
|
Route::post('missing-urls/{missing_url}/redirect', [MissingUrlController::class, 'redirect'])
|
||||||
|
->name('missing-urls.redirect');
|
||||||
|
|
||||||
|
Route::apiResource('repositories', RepositoryController::class);
|
||||||
|
Route::post('repositories/{repository}/sync', [RepositoryController::class, 'sync'])
|
||||||
|
->name('repositories.sync');
|
||||||
|
|
||||||
|
// Declared before the settings routes so "maintenance" is never read
|
||||||
|
// as a setting's key.
|
||||||
|
Route::get('maintenance', [MaintenanceController::class, 'show'])->name('maintenance.show');
|
||||||
|
Route::put('maintenance', [MaintenanceController::class, 'update'])->name('maintenance.update');
|
||||||
|
Route::apiResource('settings', SettingController::class)->only(['index', 'show', 'update']);
|
||||||
|
|
||||||
|
// The library. A file is uploaded here and then referenced from a
|
||||||
|
// record by its path; it is never edited, only processed again.
|
||||||
|
Route::get('media', [MediaController::class, 'index'])->name('media.index');
|
||||||
|
Route::post('media', [MediaController::class, 'store'])->name('media.store');
|
||||||
|
Route::get('media/{media}', [MediaController::class, 'show'])->name('media.show');
|
||||||
|
Route::delete('media/{media}', [MediaController::class, 'destroy'])->name('media.destroy');
|
||||||
|
Route::post('media/{media}/reprocess', [MediaController::class, 'reprocess'])->name('media.reprocess');
|
||||||
|
|
||||||
Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']);
|
Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']);
|
||||||
|
|
||||||
// Declared before the resource route so "catalogue" is not swallowed
|
// Declared before the resource route so "catalogue" is not swallowed
|
||||||
|
|||||||
@@ -4,11 +4,28 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Feature\Api;
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\Call;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Media;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\MissingUrl;
|
||||||
use App\Models\Page;
|
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\Talk;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Models\Testimonial;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Maintenance;
|
||||||
use Database\Seeders\RolePermissionSeeder;
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use Tests\FakesPireon;
|
use Tests\FakesPireon;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -27,7 +44,7 @@ use Tests\VerifiesOpenApiContract;
|
|||||||
* test exercises behaviour, not shape.
|
* test exercises behaviour, not shape.
|
||||||
*
|
*
|
||||||
* Unlike the public listings, an admin index and its matching show endpoint
|
* Unlike the public listings, an admin index and its matching show endpoint
|
||||||
* send the same full record — none of the four resources here has a
|
* send the same full record — none of the resources here has a
|
||||||
* "lightweight for a listing" projection — so both are held to the stricter
|
* "lightweight for a listing" projection — so both are held to the stricter
|
||||||
* rule throughout: everything documented and not `nullable` must be present.
|
* rule throughout: everything documented and not `nullable` must be present.
|
||||||
*/
|
*/
|
||||||
@@ -62,6 +79,19 @@ class AdminApiContractTest extends TestCase
|
|||||||
'pages' => ['path' => '/v1/pages'],
|
'pages' => ['path' => '/v1/pages'],
|
||||||
'categories' => ['path' => '/v1/categories'],
|
'categories' => ['path' => '/v1/categories'],
|
||||||
'users' => ['path' => '/v1/users'],
|
'users' => ['path' => '/v1/users'],
|
||||||
|
'technologies' => ['path' => '/v1/technologies'],
|
||||||
|
'projects' => ['path' => '/v1/projects'],
|
||||||
|
'members' => ['path' => '/v1/members'],
|
||||||
|
'services' => ['path' => '/v1/services'],
|
||||||
|
'clients' => ['path' => '/v1/clients'],
|
||||||
|
'testimonials' => ['path' => '/v1/testimonials'],
|
||||||
|
'talks' => ['path' => '/v1/talks'],
|
||||||
|
'calls' => ['path' => '/v1/calls'],
|
||||||
|
'applications' => ['path' => '/v1/applications'],
|
||||||
|
'redirects' => ['path' => '/v1/redirects'],
|
||||||
|
'missing-urls' => ['path' => '/v1/missing-urls'],
|
||||||
|
'repositories' => ['path' => '/v1/repositories'],
|
||||||
|
'media' => ['path' => '/v1/media'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +100,20 @@ class AdminApiContractTest extends TestCase
|
|||||||
{
|
{
|
||||||
$page = Page::factory()->create();
|
$page = Page::factory()->create();
|
||||||
$category = Category::factory()->create();
|
$category = Category::factory()->create();
|
||||||
|
$technology = Technology::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$member = Member::factory()->create();
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
$testimonial = Testimonial::factory()->create();
|
||||||
|
$talk = Talk::factory()->create();
|
||||||
|
$call = Call::factory()->create();
|
||||||
|
$application = Application::factory()->for($call)->create();
|
||||||
|
$redirect = Redirect::factory()->create();
|
||||||
|
$missingUrl = MissingUrl::factory()->create();
|
||||||
|
$repository = Repository::factory()->create();
|
||||||
|
Setting::set(Maintenance::KEY, ['enabled' => false]);
|
||||||
|
$media = Media::factory()->create();
|
||||||
|
|
||||||
// One request, one documented path — the URL a real caller hits and
|
// One request, one documented path — the URL a real caller hits and
|
||||||
// the path key that describes it, side by side rather than derived
|
// the path key that describes it, side by side rather than derived
|
||||||
@@ -80,6 +124,35 @@ class AdminApiContractTest extends TestCase
|
|||||||
'page' => ["/v1/pages/{$page->id}", '/v1/pages/{page}'],
|
'page' => ["/v1/pages/{$page->id}", '/v1/pages/{page}'],
|
||||||
'categories' => ['/v1/categories', '/v1/categories'],
|
'categories' => ['/v1/categories', '/v1/categories'],
|
||||||
'category' => ["/v1/categories/{$category->id}", '/v1/categories/{category}'],
|
'category' => ["/v1/categories/{$category->id}", '/v1/categories/{category}'],
|
||||||
|
'technologies' => ['/v1/technologies', '/v1/technologies'],
|
||||||
|
'technology' => ["/v1/technologies/{$technology->id}", '/v1/technologies/{technology}'],
|
||||||
|
'projects' => ['/v1/projects', '/v1/projects'],
|
||||||
|
'project' => ["/v1/projects/{$project->id}", '/v1/projects/{project}'],
|
||||||
|
'members' => ['/v1/members', '/v1/members'],
|
||||||
|
'member' => ["/v1/members/{$member->id}", '/v1/members/{member}'],
|
||||||
|
'services' => ['/v1/services', '/v1/services'],
|
||||||
|
'service' => ["/v1/services/{$service->id}", '/v1/services/{service}'],
|
||||||
|
'clients' => ['/v1/clients', '/v1/clients'],
|
||||||
|
'client' => ["/v1/clients/{$client->id}", '/v1/clients/{client}'],
|
||||||
|
'testimonials' => ['/v1/testimonials', '/v1/testimonials'],
|
||||||
|
'testimonial' => ["/v1/testimonials/{$testimonial->id}", '/v1/testimonials/{testimonial}'],
|
||||||
|
'talks' => ['/v1/talks', '/v1/talks'],
|
||||||
|
'talk' => ["/v1/talks/{$talk->id}", '/v1/talks/{talk}'],
|
||||||
|
'calls' => ['/v1/calls', '/v1/calls'],
|
||||||
|
'call' => ["/v1/calls/{$call->id}", '/v1/calls/{call}'],
|
||||||
|
'applications' => ['/v1/applications', '/v1/applications'],
|
||||||
|
'application' => ["/v1/applications/{$application->id}", '/v1/applications/{application}'],
|
||||||
|
'redirects' => ['/v1/redirects', '/v1/redirects'],
|
||||||
|
'redirect' => ["/v1/redirects/{$redirect->id}", '/v1/redirects/{redirect}'],
|
||||||
|
'missing-urls' => ['/v1/missing-urls', '/v1/missing-urls'],
|
||||||
|
'missing-url' => ["/v1/missing-urls/{$missingUrl->id}", '/v1/missing-urls/{missing_url}'],
|
||||||
|
'repositories' => ['/v1/repositories', '/v1/repositories'],
|
||||||
|
'repository' => ["/v1/repositories/{$repository->id}", '/v1/repositories/{repository}'],
|
||||||
|
'settings' => ['/v1/settings', '/v1/settings'],
|
||||||
|
'setting' => ['/v1/settings/'.Maintenance::KEY, '/v1/settings/{setting}'],
|
||||||
|
'maintenance' => ['/v1/maintenance', '/v1/maintenance'],
|
||||||
|
'media' => ['/v1/media', '/v1/media'],
|
||||||
|
'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'],
|
||||||
'users' => ['/v1/users', '/v1/users'],
|
'users' => ['/v1/users', '/v1/users'],
|
||||||
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
||||||
'me' => ['/v1/me', '/v1/me'],
|
'me' => ['/v1/me', '/v1/me'],
|
||||||
@@ -120,6 +193,22 @@ class AdminApiContractTest extends TestCase
|
|||||||
$this->assertSame([], $problems, "The admin API and its specification disagree:\n".implode("\n", $problems));
|
$this->assertSame([], $problems, "The admin API and its specification disagree:\n".implode("\n", $problems));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_upload_answers_in_the_shape_it_documents(): void
|
||||||
|
{
|
||||||
|
Storage::fake((string) config('media.disk'));
|
||||||
|
|
||||||
|
$response = $this->post('/api/v1/media', [
|
||||||
|
'file' => UploadedFile::fake()->image('hero.jpg', 64, 48),
|
||||||
|
], ['Accept' => 'application/json'])->assertCreated();
|
||||||
|
|
||||||
|
$record = $response->json('data');
|
||||||
|
$schema = $this->recordSchema('/v1/media', 'post', 201);
|
||||||
|
|
||||||
|
$this->assertSame([], array_values(array_diff(array_keys($record), $this->documentedKeys($schema))), 'Sent but not documented');
|
||||||
|
$this->assertSame([], array_values(array_diff($this->alwaysPresent($schema), array_keys($record))), 'Documented but not sent');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pagination envelope every listing here uses — `data`, `links`,
|
* The pagination envelope every listing here uses — `data`, `links`,
|
||||||
* `meta` — checked the same way the record inside it is: nothing sent
|
* `meta` — checked the same way the record inside it is: nothing sent
|
||||||
|
|||||||
180
tests/Feature/Api/CallsAndInboxApiTest.php
Normal file
180
tests/Feature/Api/CallsAndInboxApiTest.php
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationStatus;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\FakesPireon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls for contributors and the inbox they fill, through the admin API.
|
||||||
|
*/
|
||||||
|
class CallsAndInboxApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use FakesPireon, RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(RolePermissionSeeder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function actingWithRole(string $role): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->syncRoles([$role]);
|
||||||
|
|
||||||
|
$this->withHeaders($this->apiHeadersFor($user));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_call_is_opened_with_its_stack(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$technology = Technology::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/calls?lang=es', [
|
||||||
|
'title' => ['es' => 'Backend en Laravel', 'en' => 'Laravel backend'],
|
||||||
|
'slug' => ['es' => 'backend-laravel', 'en' => 'laravel-backend'],
|
||||||
|
'hours_per_week' => 4,
|
||||||
|
'duration_weeks' => 8,
|
||||||
|
'seniority' => 'mid',
|
||||||
|
'closes_at' => '2026-12-01',
|
||||||
|
'technology_ids' => [$technology->id],
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.title', 'Backend en Laravel')
|
||||||
|
->assertJsonPath('data.status', 'open')
|
||||||
|
->assertJsonPath('data.technology_ids', [$technology->id])
|
||||||
|
->assertJsonPath('data.applications_count', 0);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/calls', [
|
||||||
|
'title' => ['es' => 'Otra'],
|
||||||
|
'slug' => ['es' => 'backend-laravel'],
|
||||||
|
'hours_per_week' => 60,
|
||||||
|
'seniority' => 'guru',
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['slug', 'hours_per_week', 'seniority']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_call_is_closed_and_then_deleted(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$call = Call::factory()->create();
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/calls/{$call->id}", ['status' => 'filled'])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.status', 'filled');
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/calls?status=open')->assertJsonCount(0, 'data');
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/calls/{$call->id}")->assertNoContent();
|
||||||
|
$this->assertModelMissing($call);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function the_inbox_leaves_spam_out_unless_asked(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
Application::factory()->create(['name' => 'Ada']);
|
||||||
|
Application::factory()->create(['status' => ApplicationStatus::Spam]);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/applications')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.name', 'Ada')
|
||||||
|
->assertJsonMissingPath('data.0.ip_hash');
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/applications?status=spam')->assertJsonCount(1, 'data');
|
||||||
|
$this->getJson('/api/v1/applications?search=ada')->assertJsonCount(1, 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function handling_an_application_records_who_did_it(): void
|
||||||
|
{
|
||||||
|
$editor = $this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$application = Application::factory()->create();
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/applications/{$application->id}", [
|
||||||
|
'status' => 'contacted',
|
||||||
|
'notes' => 'Llamada el jueves.',
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.status', 'contacted')
|
||||||
|
->assertJsonPath('data.notes', 'Llamada el jueves.')
|
||||||
|
->assertJsonPath('data.handled_by.id', $editor->id);
|
||||||
|
|
||||||
|
$this->assertNotNull($application->fresh()->handled_at);
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/applications/{$application->id}", ['status' => 'new'])
|
||||||
|
->assertJsonPath('data.handled_by', null)
|
||||||
|
->assertJsonPath('data.handled_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function what_the_sender_wrote_cannot_be_changed(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$application = Application::factory()->create(['email' => 'real@example.org']);
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/applications/{$application->id}", ['email' => 'fake@example.org', 'status' => 'accepted'])
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->assertSame('real@example.org', $application->fresh()->email);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function nobody_creates_an_application_here(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('admin');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/applications', ['name' => 'X'])->assertMethodNotAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_writer_cannot_read_the_inbox(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('writer');
|
||||||
|
|
||||||
|
$application = Application::factory()->create();
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/applications')->assertForbidden();
|
||||||
|
$this->getJson("/api/v1/applications/{$application->id}")->assertForbidden();
|
||||||
|
|
||||||
|
// Calls are theirs to read, like on the panel.
|
||||||
|
$this->getJson('/api/v1/calls')->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_deleted_application_can_be_brought_back(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$application = Application::factory()->create();
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/applications/{$application->id}")->assertNoContent();
|
||||||
|
$this->assertSoftDeleted($application);
|
||||||
|
|
||||||
|
$this->postJson("/api/v1/applications/{$application->id}/restore")->assertOk();
|
||||||
|
$this->assertNotSoftDeleted($application);
|
||||||
|
}
|
||||||
|
}
|
||||||
257
tests/Feature/Api/CollectiveApiTest.php
Normal file
257
tests/Feature/Api/CollectiveApiTest.php
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Enums\ProjectStatus;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\FakesPireon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects, members and services through the admin API: what the MCP server
|
||||||
|
* uses to load the collective's work without anyone retyping it into forms.
|
||||||
|
*/
|
||||||
|
class CollectiveApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use FakesPireon, RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(RolePermissionSeeder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function actingWithRole(string $role): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->syncRoles([$role]);
|
||||||
|
|
||||||
|
$this->withHeaders($this->apiHeadersFor($user));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_project_arrives_with_its_team_stack_client_and_services(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
[$ada, $linus] = Member::factory()->count(2)->create();
|
||||||
|
$technologies = Technology::factory()->count(2)->create();
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/projects?lang=es&with=translations', [
|
||||||
|
'title' => ['es' => 'Catálogo abierto', 'en' => 'Open catalogue'],
|
||||||
|
'slug' => ['es' => 'catalogo', 'en' => 'catalogue'],
|
||||||
|
'tagline' => ['es' => 'Una herramienta pequeña.'],
|
||||||
|
'status' => 'active',
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'lead_member_id' => $ada->id,
|
||||||
|
'technology_ids' => $technologies->pluck('id')->all(),
|
||||||
|
'service_ids' => [$service->id],
|
||||||
|
'members' => [['id' => $ada->id, 'role' => 'Responsable'], ['id' => $linus->id]],
|
||||||
|
])->assertCreated();
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertJsonPath('data.title', 'Catálogo abierto')
|
||||||
|
->assertJsonPath('data.translations.title.en', 'Open catalogue')
|
||||||
|
->assertJsonPath('data.status', 'active')
|
||||||
|
->assertJsonPath('data.client_id', $client->id)
|
||||||
|
->assertJsonCount(2, 'data.technology_ids')
|
||||||
|
->assertJsonPath('data.members.0.role', 'Responsable');
|
||||||
|
|
||||||
|
$project = Project::query()->sole();
|
||||||
|
|
||||||
|
$this->assertSame('Responsable', $project->members()->find($ada->id)->pivot->role);
|
||||||
|
$this->assertNull($project->members()->find($linus->id)->pivot->role);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_partial_update_leaves_relations_alone_unless_they_are_sent(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$technology = Technology::factory()->create();
|
||||||
|
$project->technologies()->attach($technology);
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/projects/{$project->id}", ['status' => 'paused'])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.technology_ids', [$technology->id]);
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/projects/{$project->id}", ['technology_ids' => []])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.technology_ids', []);
|
||||||
|
|
||||||
|
$this->assertSame(ProjectStatus::Paused, $project->fresh()->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function writing_a_project_is_not_publishing_it(): void
|
||||||
|
{
|
||||||
|
// A writer authors projects but may not put them on the site.
|
||||||
|
$this->actingWithRole('writer');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/projects', [
|
||||||
|
'title' => ['es' => 'Borrador'],
|
||||||
|
'slug' => ['es' => 'borrador'],
|
||||||
|
'published_at' => now()->toIso8601String(),
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('published_at');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/projects', [
|
||||||
|
'title' => ['es' => 'Borrador'],
|
||||||
|
'slug' => ['es' => 'borrador'],
|
||||||
|
])->assertCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_editor_may_publish_a_project(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/projects', [
|
||||||
|
'title' => ['es' => 'Publicado'],
|
||||||
|
'slug' => ['es' => 'publicado'],
|
||||||
|
'published_at' => '2026-09-01T10:00:00Z',
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.published_at', '2026-09-01T10:00:00+00:00');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_deleted_project_can_be_brought_back(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/projects/{$project->id}")->assertNoContent();
|
||||||
|
$this->assertSoftDeleted($project);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/projects?trashed=1')->assertJsonCount(1, 'data');
|
||||||
|
|
||||||
|
$this->postJson("/api/v1/projects/{$project->id}/restore")->assertOk();
|
||||||
|
$this->assertNotSoftDeleted($project);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_member_arrives_with_links_and_technologies(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$technology = Technology::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/members?lang=es', [
|
||||||
|
'name' => 'Ada Lovelace',
|
||||||
|
'slug' => 'ada',
|
||||||
|
'headline' => ['es' => 'Backend', 'en' => 'Backend'],
|
||||||
|
'github_username' => 'ada',
|
||||||
|
'links' => ['web' => 'https://ada.example.org'],
|
||||||
|
'is_founder' => true,
|
||||||
|
'joined_at' => '2024-09-01',
|
||||||
|
'technology_ids' => [$technology->id],
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.headline', 'Backend')
|
||||||
|
->assertJsonPath('data.links.web', 'https://ada.example.org')
|
||||||
|
->assertJsonPath('data.joined_at', '2024-09-01')
|
||||||
|
->assertJsonPath('data.technology_ids', [$technology->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function member_slugs_and_links_are_checked(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
Member::factory()->create(['slug' => 'ada']);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/members', ['name' => 'Ada', 'slug' => 'ada'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('slug');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/members', ['name' => 'Bob', 'slug' => 'bob', 'links' => ['web' => 'not a url']])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('links.web');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function someone_may_edit_their_own_profile_without_members_update(): void
|
||||||
|
{
|
||||||
|
$viewer = $this->actingWithRole('viewer');
|
||||||
|
|
||||||
|
$own = Member::factory()->create(['user_id' => $viewer->id]);
|
||||||
|
$someoneElse = Member::factory()->create();
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/members/{$own->id}", ['github_username' => 'me'])->assertOk();
|
||||||
|
$this->patchJson("/api/v1/members/{$someoneElse->id}", ['github_username' => 'them'])->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_service_links_to_the_work_behind_it(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$technology = Technology::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/services?lang=es', [
|
||||||
|
'title' => ['es' => 'Formación'],
|
||||||
|
'slug' => ['es' => 'formacion'],
|
||||||
|
'tagline' => ['es' => 'Talleres prácticos.'],
|
||||||
|
'icon' => 'support',
|
||||||
|
'is_published' => true,
|
||||||
|
'project_ids' => [$project->id],
|
||||||
|
'technology_ids' => [$technology->id],
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.title', 'Formación')
|
||||||
|
->assertJsonPath('data.is_published', true)
|
||||||
|
->assertJsonPath('data.project_ids', [$project->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function writing_a_service_is_not_publishing_it(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('writer');
|
||||||
|
|
||||||
|
$service = Service::factory()->create(['is_published' => false]);
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/services/{$service->id}", ['is_published' => true])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('is_published');
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/services/{$service->id}", ['tagline' => ['es' => 'Nuevo texto']])
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->assertFalse($service->fresh()->is_published);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_reader_can_list_but_not_write(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('viewer');
|
||||||
|
|
||||||
|
Project::factory()->create();
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/projects')->assertOk()->assertJsonCount(1, 'data');
|
||||||
|
$this->getJson('/api/v1/services')->assertOk();
|
||||||
|
$this->getJson('/api/v1/members')->assertOk();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/projects', ['title' => ['es' => 'X'], 'slug' => ['es' => 'x']])->assertForbidden();
|
||||||
|
$this->postJson('/api/v1/services', ['title' => ['es' => 'X'], 'slug' => ['es' => 'x']])->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
133
tests/Feature/Api/MediaLibraryApiTest.php
Normal file
133
tests/Feature/Api/MediaLibraryApiTest.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Jobs\ProcessMedia;
|
||||||
|
use App\Models\Media;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\FakesPireon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media library through the admin API: what is on the disk, what uses it,
|
||||||
|
* and deleting what nothing does.
|
||||||
|
*/
|
||||||
|
class MediaLibraryApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use FakesPireon, RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(RolePermissionSeeder::class);
|
||||||
|
|
||||||
|
Storage::fake((string) config('media.disk'));
|
||||||
|
Queue::fake();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function actingWithRole(string $role): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->syncRoles([$role]);
|
||||||
|
|
||||||
|
$this->withHeaders($this->apiHeadersFor($user));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function stored(string $path): Media
|
||||||
|
{
|
||||||
|
Storage::disk((string) config('media.disk'))->put($path, 'x');
|
||||||
|
|
||||||
|
return Media::for($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function the_library_says_what_is_in_use(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$logo = $this->stored('technologies/angular.svg');
|
||||||
|
$orphan = $this->stored('pages/forgotten.jpg');
|
||||||
|
|
||||||
|
Technology::factory()->create(['logo' => $logo->path]);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/media')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(2, 'data')
|
||||||
|
->assertJsonPath('data.0.path', 'pages/forgotten.jpg')
|
||||||
|
->assertJsonPath('data.0.referenced', false)
|
||||||
|
->assertJsonPath('data.1.referenced', true);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/media?orphans=1')
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $orphan->id);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/media?for=technologies')
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $logo->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_image_nothing_uses_is_deleted_with_its_file(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$orphan = $this->stored('pages/forgotten.jpg');
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/media/{$orphan->id}")->assertNoContent();
|
||||||
|
|
||||||
|
$this->assertModelMissing($orphan);
|
||||||
|
Storage::disk((string) config('media.disk'))->assertMissing('pages/forgotten.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_image_in_use_is_not_deleted(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$image = $this->stored('pages/hero.jpg');
|
||||||
|
Page::factory()->create(['featured_image' => $image->path]);
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/media/{$image->id}")->assertConflict();
|
||||||
|
|
||||||
|
$this->assertModelExists($image);
|
||||||
|
Storage::disk((string) config('media.disk'))->assertExists('pages/hero.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_writer_sees_the_library_but_deletes_nothing(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('writer');
|
||||||
|
|
||||||
|
$orphan = $this->stored('pages/forgotten.jpg');
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/media')->assertOk();
|
||||||
|
$this->getJson("/api/v1/media/{$orphan->id}")->assertOk()->assertJsonPath('data.referenced', false);
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/media/{$orphan->id}")->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_image_is_processed_again_on_request(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$image = $this->stored('pages/hero.jpg');
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->postJson("/api/v1/media/{$image->id}/reprocess")->assertAccepted();
|
||||||
|
|
||||||
|
Queue::assertPushed(ProcessMedia::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
409
tests/Feature/Api/MediaUploadTest.php
Normal file
409
tests/Feature/Api/MediaUploadTest.php
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Models\Media;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Media\HostResolver;
|
||||||
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\FakesPireon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class MediaUploadTest extends TestCase
|
||||||
|
{
|
||||||
|
use FakesPireon, RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(RolePermissionSeeder::class);
|
||||||
|
|
||||||
|
Storage::fake((string) config('media.disk'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function actingWithRole(string $role): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->syncRoles([$role]);
|
||||||
|
|
||||||
|
$this->withHeaders($this->apiHeadersFor($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answers DNS for the hosts the tests invent, so nothing here depends on
|
||||||
|
* the network — and so a test can make a name resolve somewhere private.
|
||||||
|
*
|
||||||
|
* @param array<string, array<int, string>> $answers
|
||||||
|
*/
|
||||||
|
protected function resolving(array $answers): void
|
||||||
|
{
|
||||||
|
$this->app->instance(HostResolver::class, new class($answers) extends HostResolver
|
||||||
|
{
|
||||||
|
/** @param array<string, array<int, string>> $answers */
|
||||||
|
public function __construct(private array $answers) {}
|
||||||
|
|
||||||
|
public function resolve(string $host): array
|
||||||
|
{
|
||||||
|
return $this->answers[$host] ?? [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Http::assertNothingSent()` would also count the token check against the
|
||||||
|
* identity server; what matters is that the image host was never asked.
|
||||||
|
*/
|
||||||
|
protected function assertNoImageWasFetched(): void
|
||||||
|
{
|
||||||
|
Http::assertNotSent(fn ($request) => ! str_starts_with($request->url(), $this->pireonServer));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function png(int $width = 12, int $height = 8): string
|
||||||
|
{
|
||||||
|
return UploadedFile::fake()->image('x.png', $width, $height)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_stores_an_uploaded_image_where_the_panel_puts_a_pages_images(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$response = $this->post('/api/v1/media', [
|
||||||
|
'file' => UploadedFile::fake()->image('Foto de portada.JPG', 1200, 800),
|
||||||
|
], ['Accept' => 'application/json']);
|
||||||
|
|
||||||
|
$response->assertCreated()
|
||||||
|
->assertJsonPath('data.mime', 'image/jpeg')
|
||||||
|
->assertJsonPath('data.width', 1200)
|
||||||
|
->assertJsonPath('data.height', 800);
|
||||||
|
|
||||||
|
$path = $response->json('data.path');
|
||||||
|
|
||||||
|
// Named by the server, never after what the client sent.
|
||||||
|
$this->assertMatchesRegularExpression('#^pages/[A-Za-z0-9]{40}\.jpg$#', $path);
|
||||||
|
Storage::disk((string) config('media.disk'))->assertExists($path);
|
||||||
|
$this->assertDatabaseHas('media', ['path' => $path]);
|
||||||
|
$this->assertStringEndsWith($path, $response->json('data.url'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function the_path_it_answers_with_is_what_a_page_points_at(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$path = $this->post('/api/v1/media', [
|
||||||
|
'file' => UploadedFile::fake()->image('hero.png', 800, 600),
|
||||||
|
], ['Accept' => 'application/json'])->json('data.path');
|
||||||
|
|
||||||
|
$page = Page::factory()->create();
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/pages/{$page->id}", ['featured_image' => $path])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.featured_image', $path)
|
||||||
|
->assertJsonPath('data.featured_image_media.width', 800);
|
||||||
|
|
||||||
|
// One upload, one row: attaching it to the page reuses what the upload
|
||||||
|
// already created instead of starting over.
|
||||||
|
$this->assertSame(1, Media::query()->where('path', $path)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_downloads_an_image_from_a_public_url(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
$this->resolving(['images.example.test' => ['93.184.216.34']]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://images.example.test/*' => Http::response($this->png(30, 20), 200, ['Content-Type' => 'image/png']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/cover.png'])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.mime', 'image/png')
|
||||||
|
->assertJsonPath('data.width', 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_says_who_is_fetching(): void
|
||||||
|
{
|
||||||
|
config()->set('app.name', 'Pireon Admin');
|
||||||
|
config()->set('app.url', 'https://admin.example.org');
|
||||||
|
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
$this->resolving(['images.example.test' => ['93.184.216.34']]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://images.example.test/*' => Http::response($this->png(), 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])->assertCreated();
|
||||||
|
|
||||||
|
// Hosts such as Wikimedia answer Guzzle's anonymous default with 403.
|
||||||
|
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://images.example.test/')
|
||||||
|
&& $request->header('User-Agent') === ['pireon-admin/1.0 (+https://admin.example.org; media fetcher)']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{0: array<string, array<int, string>>, 1: string}>
|
||||||
|
*/
|
||||||
|
public static function unsafeDestinations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'a name resolving to a private address' => [['intranet.example.test' => ['10.0.0.5']], 'https://intranet.example.test/a.png'],
|
||||||
|
'one private address among public ones' => [['mixed.example.test' => ['93.184.216.34', '192.168.1.10']], 'https://mixed.example.test/a.png'],
|
||||||
|
'loopback, literally' => [[], 'https://127.0.0.1/a.png'],
|
||||||
|
'the cloud metadata service' => [[], 'https://169.254.169.254/latest/meta-data'],
|
||||||
|
'shared address space' => [[], 'https://100.64.0.1/a.png'],
|
||||||
|
'IPv6 loopback' => [[], 'https://[::1]/a.png'],
|
||||||
|
'IPv4-mapped loopback' => [[], 'https://[::ffff:127.0.0.1]/a.png'],
|
||||||
|
'a name that does not resolve' => [[], 'https://nowhere.example.test/a.png'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<int, string>> $dns
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('unsafeDestinations')]
|
||||||
|
public function it_never_fetches_from_somewhere_private(array $dns, string $url): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
$this->resolving($dns);
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/media', ['url' => $url])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('url');
|
||||||
|
|
||||||
|
$this->assertNoImageWasFetched();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_refuses_plain_http(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/media', ['url' => 'http://images.example.test/a.png'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('url');
|
||||||
|
|
||||||
|
$this->assertNoImageWasFetched();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_does_not_follow_a_redirect(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
$this->resolving(['images.example.test' => ['93.184.216.34']]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://images.example.test/*' => Http::response('', 302, ['Location' => 'https://10.0.0.5/a.png']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonPath('errors.url.0', __('rules.media.remote.redirect'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_stops_reading_past_the_size_limit(): void
|
||||||
|
{
|
||||||
|
config()->set('media.upload.max_kb', 1);
|
||||||
|
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
$this->resolving(['images.example.test' => ['93.184.216.34']]);
|
||||||
|
|
||||||
|
// No Content-Length to go by: the limit has to hold while reading.
|
||||||
|
Http::fake([
|
||||||
|
'https://images.example.test/*' => Http::response(str_repeat('x', 4096), 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonPath('errors.url.0', __('rules.media.remote.too_large', ['max' => 1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_url_that_answers_with_something_else_is_not_stored(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
$this->resolving(['images.example.test' => ['93.184.216.34']]);
|
||||||
|
|
||||||
|
// Claims to be an image; the bytes say otherwise.
|
||||||
|
Http::fake([
|
||||||
|
'https://images.example.test/*' => Http::response('<html>nope</html>', 200, ['Content-Type' => 'image/png']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonPath('errors.url.0', __('rules.media.not_an_image'));
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('media', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{0: string, 1: string}>
|
||||||
|
*/
|
||||||
|
public static function notImages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'text renamed to .jpg' => ['photo.jpg', 'not an image at all'],
|
||||||
|
'HTML renamed to .svg' => ['logo.svg', '<html><body>not a vector</body></html>'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('notImages')]
|
||||||
|
public function it_refuses_an_upload_that_is_not_a_raster_image(string $name, string $contents): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$this->post('/api/v1/media', [
|
||||||
|
'file' => UploadedFile::fake()->createWithContent($name, $contents),
|
||||||
|
], ['Accept' => 'application/json'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('file');
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('media', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_svg_is_stored_with_whatever_could_run_taken_out(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" onload="alert(1)">'
|
||||||
|
.'<script>alert(2)</script>'
|
||||||
|
.'<a href="javascript:alert(3)"><rect width="10" height="10" fill="#21759B"/></a>'
|
||||||
|
.'<image href="https://tracker.example.test/pixel.png"/>'
|
||||||
|
.'</svg>';
|
||||||
|
|
||||||
|
$response = $this->post('/api/v1/media', [
|
||||||
|
'file' => UploadedFile::fake()->createWithContent('logo.svg', $svg),
|
||||||
|
], ['Accept' => 'application/json'])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.mime', 'image/svg+xml')
|
||||||
|
->assertJsonPath('data.width', null);
|
||||||
|
|
||||||
|
$stored = Storage::disk((string) config('media.disk'))->get($response->json('data.path'));
|
||||||
|
|
||||||
|
$this->assertStringEndsWith('.svg', $response->json('data.path'));
|
||||||
|
$this->assertStringContainsString('#21759B', $stored);
|
||||||
|
|
||||||
|
foreach (['<script', 'onload', 'javascript:', 'tracker.example.test'] as $removed) {
|
||||||
|
$this->assertStringNotContainsString($removed, $stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_upload_for_a_technology_lands_where_the_panel_puts_their_logos(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$this->post('/api/v1/media', [
|
||||||
|
'file' => UploadedFile::fake()->image('logo.png'),
|
||||||
|
'for' => 'technologies',
|
||||||
|
], ['Accept' => 'application/json'])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.path', fn (string $path) => str_starts_with($path, 'technologies/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_takes_the_permission_of_the_record_it_is_for(): void
|
||||||
|
{
|
||||||
|
// A writer authors pages but only reads technologies.
|
||||||
|
$this->actingWithRole('writer');
|
||||||
|
|
||||||
|
$this->post('/api/v1/media', [
|
||||||
|
'file' => UploadedFile::fake()->image('logo.png'),
|
||||||
|
'for' => 'technologies',
|
||||||
|
], ['Accept' => 'application/json'])
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_only_knows_the_records_it_is_told_about(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('super_admin');
|
||||||
|
|
||||||
|
$this->post('/api/v1/media', [
|
||||||
|
'file' => UploadedFile::fake()->image('logo.png'),
|
||||||
|
'for' => '../../etc',
|
||||||
|
], ['Accept' => 'application/json'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('for');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_wants_exactly_one_source(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/media', [])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['file', 'url']);
|
||||||
|
|
||||||
|
$this->post('/api/v1/media', [
|
||||||
|
'file' => UploadedFile::fake()->image('a.png'),
|
||||||
|
'url' => 'https://images.example.test/a.png',
|
||||||
|
], ['Accept' => 'application/json'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('file');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function whoever_writes_pages_may_upload(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('writer');
|
||||||
|
|
||||||
|
$this->post('/api/v1/media', ['file' => UploadedFile::fake()->image('a.png')], ['Accept' => 'application/json'])
|
||||||
|
->assertCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function whoever_only_reads_may_not(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('viewer');
|
||||||
|
|
||||||
|
$this->post('/api/v1/media', ['file' => UploadedFile::fake()->image('b.png')], ['Accept' => 'application/json'])
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('media', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_refuses_a_url_before_fetching_it_for_someone_who_may_not_upload(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('viewer');
|
||||||
|
$this->resolving(['images.example.test' => ['93.184.216.34']]);
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertNoImageWasFetched();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function the_panel_still_has_no_way_to_create_a_file_on_its_own(): void
|
||||||
|
{
|
||||||
|
$editor = User::factory()->create();
|
||||||
|
$editor->syncRoles(['editor']);
|
||||||
|
|
||||||
|
$this->assertTrue($editor->can('upload', Media::class));
|
||||||
|
$this->assertFalse($editor->can('create', Media::class));
|
||||||
|
}
|
||||||
|
}
|
||||||
166
tests/Feature/Api/ShowcaseAdminApiTest.php
Normal file
166
tests/Feature/Api/ShowcaseAdminApiTest.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Talk;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\FakesPireon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clients, testimonials and talks through the admin API: the rest of what the
|
||||||
|
* landing shows, so it can be loaded without the panel.
|
||||||
|
*/
|
||||||
|
class ShowcaseAdminApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use FakesPireon, RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(RolePermissionSeeder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function actingWithRole(string $role): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->syncRoles([$role]);
|
||||||
|
|
||||||
|
$this->withHeaders($this->apiHeadersFor($user));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_client_is_created_with_its_sector_in_several_languages(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/clients?lang=en&with=translations', [
|
||||||
|
'name' => 'Asociación Vecinal',
|
||||||
|
'slug' => 'asociacion-vecinal',
|
||||||
|
'sector' => ['es' => 'Tercer sector', 'en' => 'Non-profit'],
|
||||||
|
'url' => 'https://vecinos.example.org',
|
||||||
|
'is_public' => true,
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.sector', 'Non-profit')
|
||||||
|
->assertJsonPath('data.translations.sector.es', 'Tercer sector')
|
||||||
|
->assertJsonPath('data.projects_count', 0);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/clients', ['name' => 'Otra', 'slug' => 'asociacion-vecinal'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('slug');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_deleted_client_can_be_brought_back(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/clients/{$client->id}")->assertNoContent();
|
||||||
|
$this->assertSoftDeleted($client);
|
||||||
|
|
||||||
|
$this->postJson("/api/v1/clients/{$client->id}/restore")->assertOk();
|
||||||
|
$this->assertNotSoftDeleted($client);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_testimonial_keeps_the_language_it_was_given_in(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/testimonials?lang=es', [
|
||||||
|
'quote' => ['en' => 'They listened first.', 'es' => 'Primero escucharon.'],
|
||||||
|
'original_locale' => 'en',
|
||||||
|
'author_name' => 'Grace Hopper',
|
||||||
|
'author_role' => ['es' => 'Coordinadora'],
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'given_at' => '2026-05-01',
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.quote', 'Primero escucharon.')
|
||||||
|
->assertJsonPath('data.original_locale', 'en')
|
||||||
|
->assertJsonPath('data.client_id', $client->id)
|
||||||
|
->assertJsonPath('data.given_at', '2026-05-01');
|
||||||
|
|
||||||
|
$this->assertSame('They listened first.', Testimonial::query()->sole()->getTranslation('quote', 'en'));
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/testimonials', [
|
||||||
|
'quote' => ['es' => 'x'],
|
||||||
|
'original_locale' => 'tlh',
|
||||||
|
'author_name' => 'Worf',
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('original_locale');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_talk_is_filtered_by_who_gave_it_and_deleted_for_good(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$member = Member::factory()->create();
|
||||||
|
|
||||||
|
$id = $this->postJson('/api/v1/talks', [
|
||||||
|
'title' => 'Angular sin miedo',
|
||||||
|
'language' => 'es',
|
||||||
|
'summary' => ['es' => 'Señales y SSR.', 'en' => 'Signals and SSR.'],
|
||||||
|
'event' => 'Commit Conf',
|
||||||
|
'held_at' => '2026-04-20',
|
||||||
|
'member_id' => $member->id,
|
||||||
|
'video_url' => 'https://video.example.org/1',
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.title', 'Angular sin miedo')
|
||||||
|
->json('data.id');
|
||||||
|
|
||||||
|
Talk::factory()->create();
|
||||||
|
|
||||||
|
$this->getJson("/api/v1/talks?member_id={$member->id}&lang=en")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.summary', 'Signals and SSR.');
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/talks/{$id}")->assertNoContent();
|
||||||
|
$this->assertDatabaseMissing('talks', ['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_writer_can_read_them_but_not_write_them(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('writer');
|
||||||
|
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/clients')->assertOk();
|
||||||
|
$this->getJson('/api/v1/testimonials')->assertOk();
|
||||||
|
$this->getJson('/api/v1/talks')->assertOk();
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/clients/{$client->id}", ['name' => 'Otro'])->assertForbidden();
|
||||||
|
$this->postJson('/api/v1/talks', ['title' => 'X', 'language' => 'es', 'event' => 'Y'])->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_logo_is_uploaded_for_a_client(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('writer');
|
||||||
|
|
||||||
|
// Uploading for a client takes permission to write clients.
|
||||||
|
$this->postJson('/api/v1/media', ['url' => 'https://example.org/a.png', 'for' => 'clients'])
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
221
tests/Feature/Api/SiteAdministrationApiTest.php
Normal file
221
tests/Feature/Api/SiteAdministrationApiTest.php
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Enums\MissingUrlStatus;
|
||||||
|
use App\Jobs\SyncRepository;
|
||||||
|
use App\Models\MissingUrl;
|
||||||
|
use App\Models\Repository;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\ContentVersion;
|
||||||
|
use App\Support\Maintenance;
|
||||||
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\FakesPireon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects, missing URLs, repositories, settings and maintenance through the
|
||||||
|
* admin API — the panel's administration screens.
|
||||||
|
*/
|
||||||
|
class SiteAdministrationApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use FakesPireon, RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(RolePermissionSeeder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function actingWithRole(string $role): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->syncRoles([$role]);
|
||||||
|
|
||||||
|
$this->withHeaders($this->apiHeadersFor($user));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_redirect_is_normalised_and_closes_the_report_it_answers(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
MissingUrl::report('/proyectos/viejo', 'es');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/redirects', [
|
||||||
|
'from' => 'proyectos/viejo/',
|
||||||
|
'to' => '/proyectos/nuevo?x=1',
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.from', '/proyectos/viejo')
|
||||||
|
->assertJsonPath('data.to', '/proyectos/nuevo')
|
||||||
|
->assertJsonPath('data.status', 301)
|
||||||
|
->assertJsonPath('data.automatic', false);
|
||||||
|
|
||||||
|
$this->assertSame(MissingUrlStatus::Resolved, MissingUrl::query()->sole()->status);
|
||||||
|
|
||||||
|
// The same address in the same language is one rule, not two.
|
||||||
|
$this->postJson('/api/v1/redirects', ['from' => '/proyectos/viejo', 'to' => '/otra'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('from');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/redirects', ['from' => '/a', 'to' => '/a/'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('to');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_missing_url_is_given_a_destination(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
MissingUrl::report('/en/old', 'en');
|
||||||
|
$missing = MissingUrl::query()->sole();
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/missing-urls')->assertJsonCount(1, 'data');
|
||||||
|
|
||||||
|
$this->postJson("/api/v1/missing-urls/{$missing->id}/redirect", ['to' => '/en/new'])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.from', '/en/old')
|
||||||
|
->assertJsonPath('data.locale', 'en');
|
||||||
|
|
||||||
|
$this->assertSame(MissingUrlStatus::Resolved, $missing->fresh()->status);
|
||||||
|
$this->getJson('/api/v1/missing-urls')->assertJsonCount(0, 'data');
|
||||||
|
|
||||||
|
$this->postJson("/api/v1/missing-urls/{$missing->id}/redirect", ['to' => '/en/other'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('locale');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_missing_url_can_be_ignored_but_not_declared_resolved(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$missing = MissingUrl::factory()->create();
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/missing-urls/{$missing->id}", ['status' => 'ignored'])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.status', 'ignored');
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/missing-urls/{$missing->id}", ['status' => 'resolved'])
|
||||||
|
->assertUnprocessable();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_repository_is_added_and_synced_on_the_queue(): void
|
||||||
|
{
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->actingWithRole('admin');
|
||||||
|
|
||||||
|
$id = $this->postJson('/api/v1/repositories', [
|
||||||
|
'provider' => 'github',
|
||||||
|
'full_name' => 'pireon/landing',
|
||||||
|
'show_publicly' => true,
|
||||||
|
// Synced columns are not the caller's to set.
|
||||||
|
'stars' => 9999,
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.full_name', 'pireon/landing')
|
||||||
|
->assertJsonPath('data.stars', 0)
|
||||||
|
->json('data.id');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/repositories', ['provider' => 'github', 'full_name' => 'pireon/landing'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('full_name');
|
||||||
|
|
||||||
|
$this->postJson("/api/v1/repositories/{$id}/sync")->assertAccepted();
|
||||||
|
|
||||||
|
Queue::assertPushed(SyncRepository::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_editor_reads_repositories_but_cannot_sync_them(): void
|
||||||
|
{
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$repository = Repository::factory()->create();
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/repositories')->assertOk();
|
||||||
|
$this->postJson("/api/v1/repositories/{$repository->id}/sync")->assertForbidden();
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_setting_is_replaced_whole(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('admin');
|
||||||
|
|
||||||
|
Setting::set('site.contact', ['email' => 'hola@example.org']);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/settings')->assertOk()->assertJsonPath('data.0.key', 'site.contact');
|
||||||
|
|
||||||
|
$this->patchJson('/api/v1/settings/site.contact', ['value' => ['email' => 'nuevo@example.org']])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.value.email', 'nuevo@example.org');
|
||||||
|
|
||||||
|
$this->assertSame(['email' => 'nuevo@example.org'], Setting::get('site.contact'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function the_content_version_is_not_edited_by_hand(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('admin');
|
||||||
|
|
||||||
|
ContentVersion::bump();
|
||||||
|
|
||||||
|
$this->patchJson('/api/v1/settings/'.ContentVersion::KEY, ['value' => 1])->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_editor_sees_settings_but_changes_none(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
Setting::set(Maintenance::KEY, ['enabled' => false]);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/settings')->assertOk();
|
||||||
|
$this->getJson('/api/v1/maintenance')->assertOk()->assertJsonPath('data.enabled', false);
|
||||||
|
|
||||||
|
$this->patchJson('/api/v1/settings/'.Maintenance::KEY, ['value' => ['enabled' => true]])->assertForbidden();
|
||||||
|
$this->putJson('/api/v1/maintenance', ['enabled' => true])->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertFalse(Maintenance::isEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function maintenance_is_switched_on_and_off_keeping_its_wording(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('admin');
|
||||||
|
|
||||||
|
$this->putJson('/api/v1/maintenance', [
|
||||||
|
'enabled' => true,
|
||||||
|
'message' => ['es' => 'Volvemos enseguida.', 'en' => ''],
|
||||||
|
'until' => now()->addHour()->toIso8601String(),
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.enabled', true)
|
||||||
|
->assertJsonPath('data.message', ['es' => 'Volvemos enseguida.']);
|
||||||
|
|
||||||
|
$this->assertTrue(Maintenance::isEnabled());
|
||||||
|
|
||||||
|
$this->putJson('/api/v1/maintenance', ['enabled' => false])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.enabled', false)
|
||||||
|
->assertJsonPath('data.until', null)
|
||||||
|
->assertJsonPath('data.message.es', 'Volvemos enseguida.');
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user