Compare commits
16 Commits
main
...
82ef2b38dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
docs/*.pdf
|
||||
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.
|
||||
#
|
||||
# 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,
|
||||
# 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"
|
||||
@@ -116,3 +123,18 @@ MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM_ADDRESS="admin@example.org"
|
||||
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
|
||||
|
||||
- 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.yaml
|
||||
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
|
||||
|
||||
ENV SERVER_NAME=:80
|
||||
ENV SERVER_NAME=:8080
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["entrypoint"]
|
||||
|
||||
|
||||
180
README.md
180
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
|
||||
que se llevaría `content:prune --media`) y qué **ya no está en disco**.
|
||||
|
||||
No se sube desde ahí: los archivos llegan al subirlos en una ficha. Y **no se
|
||||
puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que
|
||||
No se sube desde ahí: los archivos llegan al subirlos en una ficha (o por la
|
||||
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
|
||||
huérfanos.
|
||||
|
||||
«Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la
|
||||
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, o `technologies`), 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
|
||||
|
||||
Un proyecto tiene además una galería —capturas, el antes y el después— que se
|
||||
@@ -1225,8 +1263,21 @@ POST /api/v1/pages/{page}/restore
|
||||
GET /api/v1/users PATCH para roles, is_active, locale
|
||||
GET /api/v1/roles solo lectura
|
||||
GET /api/v1/roles/catalogue catálogo de permisos agrupado
|
||||
/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
|
||||
POST /api/v1/media subir una imagen (fichero o URL)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
|
||||
cliente en vez de comprobar nombres de rol.
|
||||
|
||||
@@ -1332,6 +1383,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
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -1364,7 +1514,7 @@ php artisan site:maintenance --up
|
||||
## Tests
|
||||
|
||||
```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)
|
||||
@@ -1458,7 +1608,7 @@ párrafo aparte sin relación programática con él.
|
||||
## 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
|
||||
Dockerfile imagen única para los tres contenedores PHP
|
||||
docker/ Caddyfile, php.ini y entrypoint
|
||||
@@ -1491,6 +1641,7 @@ config/site.php frontend público, preview, caché, rate
|
||||
config/media.php disco, anchos y formatos de imagen
|
||||
config/git.php proveedores de git y etiquetas de issues
|
||||
public/openapi.yaml especificación de ambas APIs
|
||||
mcp/ servidor MCP (stdio y HTTP) generado desde la anterior
|
||||
```
|
||||
|
||||
## Despliegue con Docker
|
||||
@@ -1499,16 +1650,29 @@ Cuatro contenedores y una base de datos:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
# 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 |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `scheduler` | cierra convocatorias, barre imágenes pendientes, sincroniza repos |
|
||||
| `postgres` | la base de datos |
|
||||
@@ -1587,7 +1751,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
|
||||
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`
|
||||
y ahí no está ni el runner de tests.
|
||||
|
||||
115
app/Http/Controllers/Api/V1/MediaController.php
Normal file
115
app/Http/Controllers/Api/V1/MediaController.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?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\Models\Media;
|
||||
use App\Services\Media\RemoteImage;
|
||||
use enshrined\svgSanitize\Sanitizer;
|
||||
use finfo;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* POST /api/v1/media
|
||||
*
|
||||
* 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',
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class PageController extends Controller
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$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('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')))
|
||||
@@ -67,7 +67,7 @@ class PageController extends Controller
|
||||
|
||||
$page->save();
|
||||
|
||||
return PageResource::make($page->load(['category', 'author']))
|
||||
return PageResource::make($page->load(['category', 'author', 'featuredMedia']))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ class PageController extends Controller
|
||||
{
|
||||
$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
|
||||
@@ -91,7 +91,7 @@ class PageController extends Controller
|
||||
|
||||
$page->save();
|
||||
|
||||
return PageResource::make($page->load(['category', 'author']));
|
||||
return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
|
||||
}
|
||||
|
||||
public function destroy(Page $page): JsonResponse
|
||||
@@ -109,7 +109,7 @@ class PageController extends Controller
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
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')],
|
||||
];
|
||||
}
|
||||
}
|
||||
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']);
|
||||
}
|
||||
}
|
||||
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']);
|
||||
}
|
||||
}
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
35
app/Http/Resources/MediaResource.php
Normal file
35
app/Http/Resources/MediaResource.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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 [
|
||||
// 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,20 @@ class MediaPolicy extends ResourcePolicy
|
||||
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
|
||||
* 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)
|
||||
->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
|
||||
// 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
|
||||
|
||||
@@ -86,14 +86,6 @@ class AdminPanelProvider extends PanelProvider
|
||||
])
|
||||
|
||||
// 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(
|
||||
PanelsRenderHook::USER_MENU_BEFORE,
|
||||
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",
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"enshrined/svg-sanitize": "^1.0",
|
||||
"filament/filament": "^5.7",
|
||||
"laravel/framework": "^13.17",
|
||||
"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",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "e9bbf5ad6f9f32f9f506caf56db81710",
|
||||
"content-hash": "6f1347758fcd5a7f4c56b93c83b60453",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -989,6 +989,51 @@
|
||||
],
|
||||
"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",
|
||||
"version": "v5.7.6",
|
||||
|
||||
@@ -76,4 +76,41 @@ return [
|
||||
// Formats that are copied through untouched. Resampling a vector loses the
|
||||
// point of it.
|
||||
'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',
|
||||
],
|
||||
|
||||
// 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.
|
||||
#
|
||||
# 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
|
||||
# is one less thing to run, back up and forget to secure. Swapping it in later
|
||||
# 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
|
||||
#
|
||||
# `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
|
||||
# rather than once per replica.
|
||||
###############################################################################
|
||||
|
||||
name: landing
|
||||
|
||||
x-app: &app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: pireon-admin:latest
|
||||
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:
|
||||
# Uploaded images live here, and they are the one thing in this stack that
|
||||
# cannot be rebuilt from the repository.
|
||||
@@ -39,23 +62,95 @@ x-app: &app
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
services:
|
||||
app:
|
||||
<<: *app
|
||||
environment:
|
||||
x-app-env: &app-env
|
||||
APP_NAME: ${APP_NAME:-Pireon Admin}
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
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_HOST: postgres
|
||||
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
|
||||
SESSION_DRIVER: 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.
|
||||
RUN_MIGRATIONS: "true"
|
||||
ports:
|
||||
- "${APP_PORT:-8000}:80"
|
||||
expose:
|
||||
- "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:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/up"]
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/up"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 40s
|
||||
@@ -67,6 +162,7 @@ services:
|
||||
worker:
|
||||
<<: *app
|
||||
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
|
||||
# migrates, and a worker that starts against an empty schema spends its
|
||||
@@ -74,7 +170,7 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
app:
|
||||
landing-app:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "php", "artisan", "queue:monitor", "default"]
|
||||
@@ -89,6 +185,7 @@ services:
|
||||
scheduler:
|
||||
<<: *app
|
||||
command: ["php", "artisan", "schedule:work"]
|
||||
environment: *app-env
|
||||
|
||||
# The FrankenPHP base image ships a healthcheck that probes Caddy's admin
|
||||
# API, and this container does not serve HTTP — inherited, it could never
|
||||
@@ -109,17 +206,56 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
app:
|
||||
landing-app:
|
||||
condition: service_healthy
|
||||
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:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_DATABASE:-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
|
||||
# default collation rules are what the JSON slug lookups sort under.
|
||||
@@ -141,3 +277,7 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
storage:
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
|
||||
@@ -21,6 +21,21 @@
|
||||
@media path /storage/*
|
||||
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.
|
||||
@api path /api/*
|
||||
header @api {
|
||||
|
||||
@@ -30,11 +30,6 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'environment' => [
|
||||
'production' => 'Producció — això és real',
|
||||
'other' => 'Entorn de :name',
|
||||
],
|
||||
|
||||
'concurrency' => [
|
||||
'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.',
|
||||
|
||||
@@ -28,4 +28,21 @@ return [
|
||||
'pages' => [
|
||||
'cannot_publish' => 'No tens permís per publicar pàgines.',
|
||||
],
|
||||
|
||||
'publication' => [
|
||||
'forbidden' => 'No tens permís per publicar ni despublicar això.',
|
||||
],
|
||||
|
||||
'media' => [
|
||||
'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' => [
|
||||
'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.',
|
||||
|
||||
@@ -28,4 +28,21 @@ return [
|
||||
'pages' => [
|
||||
'cannot_publish' => 'You do not have permission to publish pages.',
|
||||
],
|
||||
|
||||
'publication' => [
|
||||
'forbidden' => 'You do not have permission to publish or unpublish this.',
|
||||
],
|
||||
|
||||
'media' => [
|
||||
'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' => [
|
||||
'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.',
|
||||
|
||||
@@ -28,4 +28,21 @@ return [
|
||||
'pages' => [
|
||||
'cannot_publish' => 'No tienes permiso para publicar páginas.',
|
||||
],
|
||||
|
||||
'publication' => [
|
||||
'forbidden' => 'No tienes permiso para publicar ni despublicar esto.',
|
||||
],
|
||||
|
||||
'media' => [
|
||||
'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>
|
||||
</source>
|
||||
<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_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
|
||||
@@ -106,8 +106,18 @@ tags:
|
||||
description: Translatable page content
|
||||
- name: Categories
|
||||
description: Translatable page categories
|
||||
- name: Projects
|
||||
description: The collective's work — translatable, with its team and stack
|
||||
- name: Members
|
||||
description: The people in the collective
|
||||
- name: Services
|
||||
description: What the collective offers
|
||||
- name: Technologies
|
||||
description: What the collective uses, and its place on the tech radar
|
||||
- name: Users
|
||||
description: Local user administration
|
||||
- name: Media
|
||||
description: Images uploaded for a record to point at
|
||||
- name: Roles
|
||||
description: Local authorisation model (read-only)
|
||||
|
||||
@@ -909,8 +919,9 @@ paths:
|
||||
description: |
|
||||
Proxies the identity server's `password` and `refresh_token` grants so
|
||||
first-party clients need not embed the OAuth server's URL or secret.
|
||||
Rate limited to 6 requests per minute. Browser clients should use
|
||||
Authorization Code + PKCE instead.
|
||||
Rate limited to 6 requests per minute per account and address, and 30
|
||||
per address overall. Browser clients should use Authorization Code +
|
||||
PKCE instead.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -1171,6 +1182,533 @@ paths:
|
||||
"204": { description: Deleted }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/technologies:
|
||||
get:
|
||||
tags: [Technologies]
|
||||
summary: List technologies
|
||||
description: Ordered by `position`, then name.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
- name: category
|
||||
in: query
|
||||
schema: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] }
|
||||
- name: radar_ring
|
||||
in: query
|
||||
description: '`none` for the technologies kept off the radar.'
|
||||
schema: { type: string, enum: [adopt, trial, assess, hold, none] }
|
||||
- name: search
|
||||
in: query
|
||||
description: Matches the name.
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated technologies
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/TechnologyCollection" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
post:
|
||||
tags: [Technologies]
|
||||
summary: Create a technology
|
||||
description: |
|
||||
For a logo, upload it first with `POST /v1/media` and `for:
|
||||
technologies`, and send the `path` it answers with as `logo`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/TechnologyWrite" }
|
||||
responses:
|
||||
"201":
|
||||
description: Technology created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Technology" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/v1/technologies/{technology}:
|
||||
parameters:
|
||||
- name: technology
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
get:
|
||||
tags: [Technologies]
|
||||
summary: Show a technology
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
responses:
|
||||
"200":
|
||||
description: The technology
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Technology" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { description: Not found }
|
||||
patch:
|
||||
tags: [Technologies]
|
||||
summary: Update a technology
|
||||
description: |
|
||||
Partial update. `description` and `radar_note` merge into the existing
|
||||
translations; send `radar_ring: null` to take it off the radar.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/TechnologyWrite" }
|
||||
responses:
|
||||
"200":
|
||||
description: Technology updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Technology" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
delete:
|
||||
tags: [Technologies]
|
||||
summary: Delete a technology
|
||||
description: Refused with 403 while a project, member or call still uses it.
|
||||
responses:
|
||||
"204": { description: Deleted }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/projects:
|
||||
get:
|
||||
tags: [Projects]
|
||||
summary: List projects
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
- name: status
|
||||
in: query
|
||||
schema: { type: string, enum: [idea, active, paused, completed, archived] }
|
||||
- name: search
|
||||
in: query
|
||||
description: Matches the title within the active language only.
|
||||
schema: { type: string }
|
||||
- name: trashed
|
||||
in: query
|
||||
schema: { type: boolean }
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated projects
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ProjectCollection" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
post:
|
||||
tags: [Projects]
|
||||
summary: Create a project
|
||||
description: |
|
||||
Relations are sent as ids and replaced whole when present:
|
||||
`technology_ids`, `service_ids`, and `members` with each one's role.
|
||||
`published_at` and `unpublish_at` require `projects.publish`. For a
|
||||
cover, upload it with `POST /v1/media` and `for: projects`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ProjectWrite" }
|
||||
responses:
|
||||
"201":
|
||||
description: Project created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Project" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/v1/projects/{project}:
|
||||
parameters:
|
||||
- name: project
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
get:
|
||||
tags: [Projects]
|
||||
summary: Show a project
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
responses:
|
||||
"200":
|
||||
description: The project
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Project" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { description: Not found }
|
||||
patch:
|
||||
tags: [Projects]
|
||||
summary: Update a project
|
||||
description: Partial update. Translatable fields merge into the existing map.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ProjectWrite" }
|
||||
responses:
|
||||
"200":
|
||||
description: Project updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Project" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
delete:
|
||||
tags: [Projects]
|
||||
summary: Soft-delete a project
|
||||
responses:
|
||||
"204": { description: Deleted }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/projects/{project}/restore:
|
||||
post:
|
||||
tags: [Projects]
|
||||
summary: Restore a soft-deleted project
|
||||
parameters:
|
||||
- name: project
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"200":
|
||||
description: Project restored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Project" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/members:
|
||||
get:
|
||||
tags: [Members]
|
||||
summary: List members
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
- name: search
|
||||
in: query
|
||||
description: Matches the name.
|
||||
schema: { type: string }
|
||||
- name: is_public
|
||||
in: query
|
||||
schema: { type: boolean }
|
||||
- name: trashed
|
||||
in: query
|
||||
schema: { type: boolean }
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated members
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/MemberCollection" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
post:
|
||||
tags: [Members]
|
||||
summary: Create a member
|
||||
description: |
|
||||
`links` is a label → URL map. `technology_ids` is replaced whole when
|
||||
present; which projects someone is on is set from the project, where
|
||||
the role belongs. For a photo, upload it with `POST /v1/media` and
|
||||
`for: members`. Someone may edit their own profile without
|
||||
`members.update`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/MemberWrite" }
|
||||
responses:
|
||||
"201":
|
||||
description: Member created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Member" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/v1/members/{member}:
|
||||
parameters:
|
||||
- name: member
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
get:
|
||||
tags: [Members]
|
||||
summary: Show a member
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
responses:
|
||||
"200":
|
||||
description: The member
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Member" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { description: Not found }
|
||||
patch:
|
||||
tags: [Members]
|
||||
summary: Update a member
|
||||
description: Partial update. Translatable fields merge into the existing map.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/MemberWrite" }
|
||||
responses:
|
||||
"200":
|
||||
description: Member updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Member" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
delete:
|
||||
tags: [Members]
|
||||
summary: Soft-delete a member
|
||||
responses:
|
||||
"204": { description: Deleted }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/members/{member}/restore:
|
||||
post:
|
||||
tags: [Members]
|
||||
summary: Restore a soft-deleted member
|
||||
parameters:
|
||||
- name: member
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"200":
|
||||
description: Member restored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Member" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/services:
|
||||
get:
|
||||
tags: [Services]
|
||||
summary: List services
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
- name: is_published
|
||||
in: query
|
||||
schema: { type: boolean }
|
||||
- name: search
|
||||
in: query
|
||||
description: Matches the title within the active language only.
|
||||
schema: { type: string }
|
||||
- name: trashed
|
||||
in: query
|
||||
schema: { type: boolean }
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated services
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ServiceCollection" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
post:
|
||||
tags: [Services]
|
||||
summary: Create a service
|
||||
description: |
|
||||
`technology_ids` and `project_ids` are replaced whole when present.
|
||||
`is_published`, `published_at` and `unpublish_at` require
|
||||
`services.publish`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ServiceWrite" }
|
||||
responses:
|
||||
"201":
|
||||
description: Service created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Service" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/v1/services/{service}:
|
||||
parameters:
|
||||
- name: service
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
get:
|
||||
tags: [Services]
|
||||
summary: Show a service
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
responses:
|
||||
"200":
|
||||
description: The service
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Service" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { description: Not found }
|
||||
patch:
|
||||
tags: [Services]
|
||||
summary: Update a service
|
||||
description: Partial update. Translatable fields merge into the existing map.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ServiceWrite" }
|
||||
responses:
|
||||
"200":
|
||||
description: Service updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Service" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
delete:
|
||||
tags: [Services]
|
||||
summary: Soft-delete a service
|
||||
responses:
|
||||
"204": { description: Deleted }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/services/{service}/restore:
|
||||
post:
|
||||
tags: [Services]
|
||||
summary: Restore a soft-deleted service
|
||||
parameters:
|
||||
- name: service
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"200":
|
||||
description: Service restored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Service" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/media:
|
||||
post:
|
||||
tags: [Media]
|
||||
summary: Upload an image
|
||||
description: |
|
||||
Stores an image and answers with the `path` a record points at — send
|
||||
it as a page's `featured_image`. Two ways in, exactly one per request:
|
||||
|
||||
- **`multipart/form-data`** with the image as `file`.
|
||||
- **`application/json`** with a `url`, which the API downloads itself.
|
||||
HTTPS only, from a public address — anything resolving to a private,
|
||||
reserved or loopback address is refused — and redirects are not
|
||||
followed: send the image's final address.
|
||||
|
||||
JPEG, PNG, WebP, GIF, AVIF or SVG, decided from the file's bytes rather
|
||||
than its name or declared type. An SVG is stored sanitized: scripts,
|
||||
event handlers and references to anything outside the file are
|
||||
removed. 20 MB at most.
|
||||
|
||||
`for` says which kind of record the image is for — `pages` (the
|
||||
default) or `technologies` — which decides where it is stored and whose
|
||||
permission it takes.
|
||||
|
||||
The smaller versions and the placeholder are derived afterwards, on the
|
||||
queue: `processed` says whether that has happened, and the image serves
|
||||
either way. Requires permission to create or edit the records named in
|
||||
`for`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: The image.
|
||||
for: { $ref: "#/components/schemas/MediaFor" }
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [url]
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
description: An `https://` address the API downloads the image from.
|
||||
example: https://images.example.org/hero.jpg
|
||||
for: { $ref: "#/components/schemas/MediaFor" }
|
||||
responses:
|
||||
"201":
|
||||
description: Image stored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Media" }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/v1/users:
|
||||
get:
|
||||
tags: [Users]
|
||||
@@ -2123,6 +2661,305 @@ components:
|
||||
nullable: true
|
||||
description: Must be after `published_at`. When it arrives the record leaves the site on its own.
|
||||
|
||||
Project:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
locale: { type: string, description: The language the fields below were resolved into. }
|
||||
title: { type: string, nullable: true }
|
||||
slug: { type: string, nullable: true }
|
||||
tagline: { type: string, nullable: true }
|
||||
description: { type: string, nullable: true, description: HTML from the panel's rich editor. }
|
||||
decisions: { type: string, nullable: true, description: HTML. Why the technical choices were made. }
|
||||
postmortem: { type: string, nullable: true, description: HTML. What went wrong and what was learned. }
|
||||
meta_title: { type: string, nullable: true }
|
||||
meta_description: { type: string, nullable: true }
|
||||
cover_image_alt: { type: string, nullable: true }
|
||||
status: { type: string, enum: [idea, active, paused, completed, archived] }
|
||||
is_featured: { type: boolean }
|
||||
needs_help: { type: boolean }
|
||||
demo_url: { type: string, nullable: true, format: uri }
|
||||
cover_image: { type: string, nullable: true, description: The stored path — what a client PATCHes back. }
|
||||
cover_image_media:
|
||||
allOf: [{ $ref: "#/components/schemas/Image" }]
|
||||
nullable: true
|
||||
readOnly: true
|
||||
started_at: { type: string, format: date, nullable: true }
|
||||
ended_at: { type: string, format: date, nullable: true }
|
||||
published_at: { type: string, format: date-time, nullable: true, description: On the site from then on; null keeps it off. }
|
||||
unpublish_at: { type: string, format: date-time, nullable: true }
|
||||
position: { type: integer }
|
||||
client_id: { type: integer, nullable: true }
|
||||
lead_member_id: { type: integer, nullable: true }
|
||||
technology_ids: { type: array, items: { type: integer } }
|
||||
service_ids: { type: array, items: { type: integer } }
|
||||
members:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
role: { type: string, nullable: true }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
deleted_at: { type: string, format: date-time, nullable: true }
|
||||
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
|
||||
|
||||
ProjectWrite:
|
||||
type: object
|
||||
required: [title, slug]
|
||||
properties:
|
||||
title: { $ref: "#/components/schemas/TranslatedField" }
|
||||
slug:
|
||||
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
|
||||
description: Unique per language.
|
||||
tagline: { $ref: "#/components/schemas/TranslatedField" }
|
||||
description: { $ref: "#/components/schemas/TranslatedField" }
|
||||
decisions: { $ref: "#/components/schemas/TranslatedField" }
|
||||
postmortem: { $ref: "#/components/schemas/TranslatedField" }
|
||||
meta_title: { $ref: "#/components/schemas/TranslatedField" }
|
||||
meta_description: { $ref: "#/components/schemas/TranslatedField" }
|
||||
cover_image_alt: { $ref: "#/components/schemas/TranslatedField" }
|
||||
status: { type: string, enum: [idea, active, paused, completed, archived] }
|
||||
is_featured: { type: boolean }
|
||||
needs_help: { type: boolean }
|
||||
demo_url: { type: string, format: uri, nullable: true }
|
||||
cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. }
|
||||
started_at: { type: string, format: date, nullable: true }
|
||||
ended_at: { type: string, format: date, nullable: true }
|
||||
published_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. }
|
||||
unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. }
|
||||
position: { type: integer, minimum: 0 }
|
||||
client_id: { type: integer, nullable: true }
|
||||
lead_member_id: { type: integer, nullable: true }
|
||||
technology_ids: { type: array, items: { type: integer } }
|
||||
service_ids: { type: array, items: { type: integer } }
|
||||
members:
|
||||
type: array
|
||||
description: The whole team, replacing the previous one.
|
||||
items:
|
||||
type: object
|
||||
required: [id]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
role: { type: string, nullable: true, maxLength: 120 }
|
||||
|
||||
ProjectCollection:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Project" }
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||
|
||||
Member:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
locale: { type: string }
|
||||
name: { type: string }
|
||||
slug: { type: string }
|
||||
headline: { type: string, nullable: true }
|
||||
bio: { type: string, nullable: true, description: Plain text. }
|
||||
avatar: { type: string, nullable: true, description: The stored path. }
|
||||
avatar_media:
|
||||
allOf: [{ $ref: "#/components/schemas/Image" }]
|
||||
nullable: true
|
||||
readOnly: true
|
||||
email: { type: string, nullable: true, description: Never shown on the site. }
|
||||
github_username: { type: string, nullable: true }
|
||||
links: { type: object, additionalProperties: { type: string }, description: Label → URL. }
|
||||
is_public: { type: boolean }
|
||||
is_founder: { type: boolean }
|
||||
joined_at: { type: string, format: date, nullable: true }
|
||||
left_at: { type: string, format: date, nullable: true }
|
||||
position: { type: integer }
|
||||
technology_ids: { type: array, items: { type: integer } }
|
||||
projects:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
title: { type: string, nullable: true }
|
||||
role: { type: string, nullable: true }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
deleted_at: { type: string, format: date-time, nullable: true }
|
||||
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
|
||||
|
||||
MemberWrite:
|
||||
type: object
|
||||
required: [name, slug]
|
||||
properties:
|
||||
name: { type: string, maxLength: 255 }
|
||||
slug: { type: string, maxLength: 255, pattern: "^[A-Za-z0-9_-]+$" }
|
||||
headline: { $ref: "#/components/schemas/TranslatedField" }
|
||||
bio: { $ref: "#/components/schemas/TranslatedField" }
|
||||
avatar: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=members`. }
|
||||
email: { type: string, format: email, nullable: true }
|
||||
github_username: { type: string, nullable: true, maxLength: 120 }
|
||||
links: { type: object, nullable: true, additionalProperties: { type: string, format: uri } }
|
||||
is_public: { type: boolean }
|
||||
is_founder: { type: boolean }
|
||||
joined_at: { type: string, format: date, nullable: true }
|
||||
left_at: { type: string, format: date, nullable: true }
|
||||
position: { type: integer, minimum: 0 }
|
||||
technology_ids: { type: array, items: { type: integer } }
|
||||
|
||||
MemberCollection:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Member" }
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||
|
||||
Service:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
locale: { type: string }
|
||||
title: { type: string, nullable: true }
|
||||
slug: { type: string, nullable: true }
|
||||
tagline: { type: string, nullable: true }
|
||||
description: { type: string, nullable: true, description: Plain text. }
|
||||
meta_title: { type: string, nullable: true }
|
||||
meta_description: { type: string, nullable: true }
|
||||
icon: { type: string, nullable: true }
|
||||
is_featured: { type: boolean }
|
||||
is_published: { type: boolean }
|
||||
published_at: { type: string, format: date-time, nullable: true }
|
||||
unpublish_at: { type: string, format: date-time, nullable: true }
|
||||
position: { type: integer }
|
||||
technology_ids: { type: array, items: { type: integer } }
|
||||
project_ids: { type: array, items: { type: integer } }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
deleted_at: { type: string, format: date-time, nullable: true }
|
||||
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
|
||||
|
||||
ServiceWrite:
|
||||
type: object
|
||||
required: [title, slug]
|
||||
properties:
|
||||
title: { $ref: "#/components/schemas/TranslatedField" }
|
||||
slug:
|
||||
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
|
||||
description: Unique per language.
|
||||
tagline: { $ref: "#/components/schemas/TranslatedField" }
|
||||
description: { $ref: "#/components/schemas/TranslatedField" }
|
||||
meta_title: { $ref: "#/components/schemas/TranslatedField" }
|
||||
meta_description: { $ref: "#/components/schemas/TranslatedField" }
|
||||
icon: { type: string, nullable: true, maxLength: 120, description: "An icon name, e.g. `support`." }
|
||||
is_featured: { type: boolean }
|
||||
position: { type: integer, minimum: 0 }
|
||||
is_published: { type: boolean, description: Requires `services.publish`. }
|
||||
published_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. }
|
||||
unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. }
|
||||
technology_ids: { type: array, items: { type: integer } }
|
||||
project_ids: { type: array, items: { type: integer } }
|
||||
|
||||
ServiceCollection:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Service" }
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||
|
||||
MediaFor:
|
||||
type: string
|
||||
enum: [pages, technologies, projects, members]
|
||||
default: pages
|
||||
description: The kind of record the image is for.
|
||||
|
||||
Technology:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
locale: { type: string, description: The language the prose below was resolved into. }
|
||||
name: { type: string, description: A proper noun — the same in every language. }
|
||||
slug: { type: string }
|
||||
description: { type: string, nullable: true }
|
||||
radar_note: { type: string, nullable: true, description: Why it sits in its ring. }
|
||||
category: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] }
|
||||
radar_ring:
|
||||
type: string
|
||||
enum: [adopt, trial, assess, hold]
|
||||
nullable: true
|
||||
description: Null keeps it off the radar; projects can still use it.
|
||||
color: { type: string, nullable: true, example: "#DD0031" }
|
||||
logo:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The stored path — what `POST /v1/media` answered — and what a client PATCHes back.
|
||||
logo_media:
|
||||
allOf: [{ $ref: "#/components/schemas/Image" }]
|
||||
nullable: true
|
||||
readOnly: true
|
||||
website: { type: string, nullable: true, format: uri }
|
||||
position: { type: integer }
|
||||
projects_count: { type: integer }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
translations:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Every language of `description` and `radar_note`, with `?with=translations`.
|
||||
|
||||
TechnologyWrite:
|
||||
type: object
|
||||
required: [name, slug]
|
||||
properties:
|
||||
name: { type: string, maxLength: 120 }
|
||||
slug: { type: string, maxLength: 120, pattern: "^[A-Za-z0-9_-]+$" }
|
||||
description: { $ref: "#/components/schemas/TranslatedField" }
|
||||
radar_note: { $ref: "#/components/schemas/TranslatedField" }
|
||||
category:
|
||||
type: string
|
||||
enum: [language, framework, datastore, infrastructure, tool, practice]
|
||||
default: tool
|
||||
radar_ring: { type: string, enum: [adopt, trial, assess, hold], nullable: true }
|
||||
color: { type: string, nullable: true, pattern: "^#[0-9a-fA-F]{6}$" }
|
||||
logo:
|
||||
type: string
|
||||
nullable: true
|
||||
description: A path from `POST /v1/media`, uploaded with `for=technologies`.
|
||||
website: { type: string, format: uri, nullable: true, maxLength: 255 }
|
||||
position: { type: integer, minimum: 0 }
|
||||
|
||||
TechnologyCollection:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Technology" }
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||
|
||||
Media:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
description: What a record stores, e.g. as a page's `featured_image`.
|
||||
example: pages/Xk3fQ9sLw2PmN8vR1tY6uZ0aB4cD7eF5gH2jK9mN.jpg
|
||||
url: { type: string, format: uri, description: Absolute. }
|
||||
mime: { type: string, nullable: true, example: image/jpeg }
|
||||
size: { type: integer, nullable: true, description: Bytes. }
|
||||
width: { type: integer, nullable: true }
|
||||
height: { type: integer, nullable: true }
|
||||
processed:
|
||||
type: boolean
|
||||
description: |
|
||||
Whether the queue has derived the smaller versions and the
|
||||
placeholder yet. The image serves either way.
|
||||
|
||||
Category:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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
|
||||
@@ -12,8 +12,13 @@ use App\Http\Controllers\Api\PublicApi\V1\SiteController;
|
||||
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
|
||||
use App\Http\Controllers\Api\V1\CategoryController;
|
||||
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\PageController;
|
||||
use App\Http\Controllers\Api\V1\ProjectController;
|
||||
use App\Http\Controllers\Api\V1\RoleController;
|
||||
use App\Http\Controllers\Api\V1\ServiceController;
|
||||
use App\Http\Controllers\Api\V1\TechnologyController;
|
||||
use App\Http\Controllers\Api\V1\TokenController;
|
||||
use App\Http\Controllers\Api\V1\UserController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -152,7 +157,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
// Convenience endpoint for first-party clients: exchanges credentials for
|
||||
// a token upstream so they do not need to know the identity server's URL.
|
||||
Route::post('auth/token', [TokenController::class, 'store'])
|
||||
->middleware('throttle:6,1')
|
||||
->middleware('throttle:auth-token')
|
||||
->name('auth.token');
|
||||
|
||||
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
|
||||
@@ -165,6 +170,26 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
|
||||
Route::apiResource('categories', CategoryController::class);
|
||||
|
||||
Route::apiResource('technologies', TechnologyController::class);
|
||||
|
||||
// The collective's work, people and offer. Soft-deleted, so each can
|
||||
// be brought back as a page can.
|
||||
foreach ([
|
||||
'projects' => ProjectController::class,
|
||||
'members' => MemberController::class,
|
||||
'services' => ServiceController::class,
|
||||
] as $resource => $controller) {
|
||||
$parameter = str($resource)->singular()->toString();
|
||||
|
||||
Route::apiResource($resource, $controller);
|
||||
Route::post("{$resource}/{{$parameter}}/restore", [$controller, 'restore'])
|
||||
->withTrashed()
|
||||
->name("{$resource}.restore");
|
||||
}
|
||||
|
||||
// Upload only: the file is then referenced from a record by its path.
|
||||
Route::post('media', [MediaController::class, 'store'])->name('media.store');
|
||||
|
||||
Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']);
|
||||
|
||||
// Declared before the resource route so "catalogue" is not swallowed
|
||||
|
||||
@@ -5,10 +5,16 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Member;
|
||||
use App\Models\Page;
|
||||
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 Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
@@ -62,6 +68,10 @@ class AdminApiContractTest extends TestCase
|
||||
'pages' => ['path' => '/v1/pages'],
|
||||
'categories' => ['path' => '/v1/categories'],
|
||||
'users' => ['path' => '/v1/users'],
|
||||
'technologies' => ['path' => '/v1/technologies'],
|
||||
'projects' => ['path' => '/v1/projects'],
|
||||
'members' => ['path' => '/v1/members'],
|
||||
'services' => ['path' => '/v1/services'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -70,6 +80,10 @@ class AdminApiContractTest extends TestCase
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
$category = Category::factory()->create();
|
||||
$technology = Technology::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$member = Member::factory()->create();
|
||||
$service = Service::factory()->create();
|
||||
|
||||
// One request, one documented path — the URL a real caller hits and
|
||||
// the path key that describes it, side by side rather than derived
|
||||
@@ -80,6 +94,14 @@ class AdminApiContractTest extends TestCase
|
||||
'page' => ["/v1/pages/{$page->id}", '/v1/pages/{page}'],
|
||||
'categories' => ['/v1/categories', '/v1/categories'],
|
||||
'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}'],
|
||||
'users' => ['/v1/users', '/v1/users'],
|
||||
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
||||
'me' => ['/v1/me', '/v1/me'],
|
||||
@@ -120,6 +142,22 @@ class AdminApiContractTest extends TestCase
|
||||
$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`,
|
||||
* `meta` — checked the same way the record inside it is: nothing sent
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
165
tests/Feature/Api/TechnologyApiTest.php
Normal file
165
tests/Feature/Api/TechnologyApiTest.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Enums\RadarRing;
|
||||
use App\Models\Project;
|
||||
use App\Models\Technology;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TechnologyApiTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
protected function actingWithRole(string $role): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->syncRoles([$role]);
|
||||
|
||||
$this->withHeaders($this->apiHeadersFor($user));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_creates_a_technology_with_its_prose_in_several_languages(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$this->postJson('/api/v1/technologies?lang=es&with=translations', [
|
||||
'name' => 'Kotlin Multiplatform',
|
||||
'slug' => 'kotlin-multiplatform',
|
||||
'category' => 'framework',
|
||||
'radar_ring' => 'trial',
|
||||
'color' => '#7F52FF',
|
||||
'website' => 'https://kotlinlang.org/docs/multiplatform.html',
|
||||
'description' => ['es' => 'Código Kotlin compartido.', 'en' => 'Shared Kotlin code.'],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.name', 'Kotlin Multiplatform')
|
||||
->assertJsonPath('data.radar_ring', 'trial')
|
||||
->assertJsonPath('data.description', 'Código Kotlin compartido.')
|
||||
->assertJsonPath('data.translations.description.en', 'Shared Kotlin code.');
|
||||
|
||||
$this->assertSame(RadarRing::Trial, Technology::query()->sole()->radar_ring);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_logo_is_a_path_from_the_upload_endpoint(): void
|
||||
{
|
||||
Storage::fake((string) config('media.disk'));
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$path = $this->post('/api/v1/media', [
|
||||
'file' => UploadedFile::fake()->createWithContent(
|
||||
'angular.svg',
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>',
|
||||
),
|
||||
'for' => 'technologies',
|
||||
], ['Accept' => 'application/json'])->json('data.path');
|
||||
|
||||
$this->postJson('/api/v1/technologies', [
|
||||
'name' => 'Angular',
|
||||
'slug' => 'angular',
|
||||
'logo' => $path,
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.logo', $path)
|
||||
->assertJsonPath('data.logo_media.url', fn (string $url) => str_ends_with($url, $path))
|
||||
->assertJsonPath('data.logo_media.alt', 'Angular');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_partial_update_leaves_the_rest_alone(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$technology = Technology::factory()->create([
|
||||
'name' => 'Laravel',
|
||||
'radar_ring' => RadarRing::Assess,
|
||||
'description' => ['es' => 'Framework PHP.', 'en' => 'PHP framework.'],
|
||||
]);
|
||||
|
||||
$this->patchJson("/api/v1/technologies/{$technology->id}", [
|
||||
'radar_ring' => 'adopt',
|
||||
'description' => ['ca' => 'Framework PHP.'],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.name', 'Laravel')
|
||||
->assertJsonPath('data.radar_ring', 'adopt');
|
||||
|
||||
$this->assertSame('PHP framework.', $technology->fresh()->getTranslation('description', 'en'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_ring_can_be_cleared_to_take_it_off_the_radar(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$technology = Technology::factory()->create(['radar_ring' => RadarRing::Hold]);
|
||||
|
||||
$this->patchJson("/api/v1/technologies/{$technology->id}", ['radar_ring' => null])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.radar_ring', null);
|
||||
|
||||
$this->getJson('/api/v1/technologies?radar_ring=none')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function slugs_are_unique_and_url_safe(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
Technology::factory()->create(['slug' => 'react']);
|
||||
|
||||
$this->postJson('/api/v1/technologies', ['name' => 'React', 'slug' => 'react'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('slug');
|
||||
|
||||
$this->postJson('/api/v1/technologies', ['name' => 'Spring Boot', 'slug' => 'spring boot'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('slug');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function one_still_in_use_cannot_be_deleted(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$technology = Technology::factory()->create();
|
||||
Project::factory()->create()->technologies()->attach($technology);
|
||||
|
||||
$this->deleteJson("/api/v1/technologies/{$technology->id}")->assertForbidden();
|
||||
|
||||
$this->assertModelExists($technology);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_reader_can_list_but_not_write(): void
|
||||
{
|
||||
$this->actingWithRole('viewer');
|
||||
|
||||
Technology::factory()->create();
|
||||
|
||||
$this->getJson('/api/v1/technologies')->assertOk()->assertJsonCount(1, 'data');
|
||||
|
||||
$this->postJson('/api/v1/technologies', ['name' => 'Flutter', 'slug' => 'flutter'])
|
||||
->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,38 @@ class TokenEndpointTest extends TestCase
|
||||
->assertJsonValidationErrors('grant_type');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function one_account_exhausting_its_budget_does_not_lock_out_another_on_the_same_address(): void
|
||||
{
|
||||
// Everyone the MCP server signs in arrives from its one container.
|
||||
// Registered first so it wins over fakePireon()'s token stub, which
|
||||
// would otherwise accept every password.
|
||||
Http::fake(["{$this->pireonServer}/oauth/token" => fn ($request) => $request['username'] === 'ada@example.com'
|
||||
? Http::response(['access_token' => 'fake-access-token', 'token_type' => 'Bearer', 'expires_in' => 3600])
|
||||
: Http::response(['error' => 'invalid_grant'], 400)]);
|
||||
$this->fakePireon();
|
||||
|
||||
foreach (range(1, 6) as $attempt) {
|
||||
$this->postJson('/api/v1/auth/token', [
|
||||
'grant_type' => 'password',
|
||||
'username' => 'mallory@example.com',
|
||||
'password' => "guess-{$attempt}",
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
$this->postJson('/api/v1/auth/token', [
|
||||
'grant_type' => 'password',
|
||||
'username' => 'MALLORY@example.com',
|
||||
'password' => 'guess-7',
|
||||
])->assertTooManyRequests();
|
||||
|
||||
$this->postJson('/api/v1/auth/token', [
|
||||
'grant_type' => 'password',
|
||||
'username' => 'ada@example.com',
|
||||
'password' => 'secret',
|
||||
])->assertOk();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void
|
||||
{
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The one banner that has to be right in the one direction that matters.
|
||||
*
|
||||
* Nobody edits a local clone carefully because they mistake it for
|
||||
* production — the expensive mistake runs the other way. So the only
|
||||
* environment that stays silent is `local`; every other one says so, and
|
||||
* production says so the loudest.
|
||||
*/
|
||||
class EnvironmentBannerTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
}
|
||||
|
||||
protected function setEnvironment(string $environment): void
|
||||
{
|
||||
$this->app['env'] = $environment;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function nothing_is_shown_in_a_local_install(): void
|
||||
{
|
||||
$this->setEnvironment('local');
|
||||
|
||||
$this->get('/admin')
|
||||
->assertOk()
|
||||
->assertDontSee(__('panel.environment.production'))
|
||||
->assertDontSeeText('bg-danger-600', escape: false);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function production_says_so_in_the_loudest_colour(): void
|
||||
{
|
||||
$this->setEnvironment('production');
|
||||
|
||||
$this->get('/admin')
|
||||
->assertOk()
|
||||
->assertSee(__('panel.environment.production'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function anything_else_names_itself(): void
|
||||
{
|
||||
$this->setEnvironment('staging');
|
||||
|
||||
$this->get('/admin')
|
||||
->assertOk()
|
||||
->assertSee(__('panel.environment.other', ['name' => 'staging']));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user