Compare commits

...

2 Commits

Author SHA1 Message Date
a49816eb46 Upload images through the API, and from the MCP server
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
POST /api/v1/media stores an image where the panel keeps a page's and
answers with the path to send as `featured_image`. Without it the MCP
server could create a page but never give it a picture. The file goes
through Media::for(), so it is processed, served and pruned like one
uploaded in the panel.

Two ways in, one per request: a multipart `file`, or JSON with a `url`
the API downloads itself. JPEG, PNG, WebP, GIF or AVIF only, decided
from the bytes; no SVG, which is a document that can carry script.

The URL is fetched from inside the stack's network, so RemoteImage
guards it: HTTPS only, every resolved address must be public, the
connection is pinned to the checked address with CURLOPT_RESOLVE so a
changing DNS answer cannot swap in a private one, redirects are not
followed, and the body is read to a ceiling whatever its headers say.

Uploading needs permission to write pages. MediaPolicy::create stays
false, so the panel gains no free-standing "new file" button.

In the MCP server a multipart binary field becomes `<field>_path` over
stdio: the server reads the file from the caller's disk and sends it
as multipart, instead of the model pushing base64 through its context.
Over HTTP the server's disk is not the caller's, so only `body.url` is
offered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:16:33 +02:00
daf6098203 Load a page's image record in the admin API
PageResource only fills `featured_image_media` from the `featuredMedia`
relation when it is loaded, and PageController never loaded it, so every
admin response carried the image's URL with no dimensions, srcset or
placeholder even when the processed record existed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:16:31 +02:00
22 changed files with 1013 additions and 16 deletions

View File

@@ -390,14 +390,45 @@ Filtros para las cuatro preguntas que no se pueden responder en ningún otro
sitio: qué está **sin procesar**, qué **falló**, qué **no usa nadie** (lo mismo sitio: qué está **sin procesar**, qué **falló**, qué **no usa nadie** (lo mismo
que se llevaría `content:prune --media`) y qué **ya no está en disco**. que se llevaría `content:prune --media`) y qué **ya no está en disco**.
No se sube desde ahí: los archivos llegan al subirlos en una ficha. Y **no se No se sube desde ahí: los archivos llegan al subirlos en una ficha (o por la
puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que API, abajo). Y **no se puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que
es justo de lo que se queja la página de salud. El botón solo aparece en los es justo de lo que se queja la página de salud. El botón solo aparece en los
huérfanos. huérfanos.
«Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la «Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la
tabla es derivada, así que reconstruirla siempre es seguro. tabla es derivada, así que reconstruirla siempre es seguro.
### Subir por la API
`POST /api/v1/media` es el campo de subida de una ficha, sin la ficha: guarda la
imagen donde el panel pone las de las páginas y contesta con el `path` que se
manda como `featured_image`. 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. Solo JPEG, PNG, WebP, GIF y AVIF,
decidido por los bytes y no por el nombre ni el tipo declarado. **Sin SVG**: el
panel lo acepta de quien lo administra, pero esto también descarga de URLs
arbitrarias, y un SVG es un documento que puede llevar script servido desde
este dominio. 20 MB como mucho (`MEDIA_UPLOAD_MAX_KB`).
**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 páginas (`pages.create` o `pages.update`), no ninguno de
`media.*`: esos siempre describieron cuidar ficheros que ya existen, y
`MediaPolicy::create` sigue siendo `false` para que el panel no gane un botón de
«nuevo fichero» suelto.
### Galerías ### Galerías
Un proyecto tiene además una galería —capturas, el antes y el después— que se Un proyecto tiene además una galería —capturas, el antes y el después— que se
@@ -1225,6 +1256,7 @@ POST /api/v1/pages/{page}/restore
GET /api/v1/users PATCH para roles, is_active, locale GET /api/v1/users PATCH para roles, is_active, locale
GET /api/v1/roles solo lectura GET /api/v1/roles solo lectura
GET /api/v1/roles/catalogue catálogo de permisos agrupado GET /api/v1/roles/catalogue catálogo de permisos agrupado
POST /api/v1/media subir una imagen (fichero o URL)
``` ```
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del `GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
@@ -1419,6 +1451,12 @@ salvo `LANDING_MCP_HOST`/`LANDING_MCP_PORT`), para probarlo sin Docker.
avisa a gente real. avisa a gente real.
- Las cabeceras no se exponen como parámetros: son secretos, y salen de la - Las cabeceras no se exponen como parámetros: son secretos, y salen de la
configuración, no del modelo. 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 `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 `openapi.yaml` de este repo —un cambio en el documento que haga chocar dos

View File

@@ -0,0 +1,85 @@
<?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 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',
];
public function store(MediaRequest $request, RemoteImage $remote): JsonResponse
{
$this->authorize('upload', Media::class);
$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;
$size = @getimagesizefromstring($contents);
// Both, because each catches what the other misses: the type is read
// from the magic bytes, and a file that has them but does not decode
// is not an image anybody can render.
if ($size === false || ! in_array($mime, (array) config('media.upload.mimes'), true)) {
throw ValidationException::withMessages([$field => [__('rules.media.not_an_image')]]);
}
$path = config('media.upload.directory').'/'.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);
}
}

View File

@@ -30,7 +30,7 @@ class PageController extends Controller
$locale = app()->getLocale(); $locale = app()->getLocale();
$pages = Page::query() $pages = Page::query()
->with(['category', 'author']) ->with(['category', 'author', 'featuredMedia'])
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('category_id'), fn ($q) => $q->where('category_id', $request->integer('category_id'))) ->when($request->filled('category_id'), fn ($q) => $q->where('category_id', $request->integer('category_id')))
->when($request->has('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured'))) ->when($request->has('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured')))
@@ -67,7 +67,7 @@ class PageController extends Controller
$page->save(); $page->save();
return PageResource::make($page->load(['category', 'author'])) return PageResource::make($page->load(['category', 'author', 'featuredMedia']))
->response() ->response()
->setStatusCode(201); ->setStatusCode(201);
} }
@@ -76,7 +76,7 @@ class PageController extends Controller
{ {
$this->authorize('view', $page); $this->authorize('view', $page);
return PageResource::make($page->load(['category', 'author'])); return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
} }
public function update(PageRequest $request, Page $page): PageResource public function update(PageRequest $request, Page $page): PageResource
@@ -91,7 +91,7 @@ class PageController extends Controller
$page->save(); $page->save();
return PageResource::make($page->load(['category', 'author'])); return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
} }
public function destroy(Page $page): JsonResponse public function destroy(Page $page): JsonResponse
@@ -109,7 +109,7 @@ class PageController extends Controller
$page->restore(); $page->restore();
return PageResource::make($page->load(['category', 'author'])); return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
} }
/** /**

View File

@@ -0,0 +1,45 @@
<?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'],
];
}
}

View 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(),
];
}
}

View File

@@ -21,6 +21,19 @@ class MediaPolicy extends ResourcePolicy
return false; return false;
} }
/**
* Uploading through the API, which has no form to attach a file to.
*
* Still true to the rule above in spirit: through the API the one record
* that holds an image is a page, so whoever may write pages may upload the
* image for one — and nobody else, whatever their `media.*` permissions,
* which only ever described looking after files that already exist.
*/
public function upload(User $user): bool
{
return $user->can('pages.create') || $user->can('pages.update');
}
/** /**
* A file something still points at cannot be deleted from here: doing so * A file something still points at cannot be deleted from here: doing so
* would leave a broken image on the public site, which is precisely what * would leave a broken image on the public site, which is precisely what

View 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]));
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Services\Media;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
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.
*/
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/*'])
->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;
}
/**
* 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)],
]);
}
}

View File

@@ -76,4 +76,30 @@ return [
// Formats that are copied through untouched. Resampling a vector loses the // Formats that are copied through untouched. Resampling a vector loses the
// point of it. // point of it.
'passthrough_mimes' => ['image/svg+xml', 'image/gif'], 'passthrough_mimes' => ['image/svg+xml', 'image/gif'],
/*
|--------------------------------------------------------------------------
| Uploads through the API
|--------------------------------------------------------------------------
|
| POST /api/v1/media, which the MCP server uses to put an image on a page.
|
| Raster formats only. The panel's own upload accepts SVG from the people
| who run it; this endpoint also fetches from arbitrary URLs, and an SVG is
| a document that can carry script, served from this application's domain.
|
| 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'],
'max_kb' => (int) env('MEDIA_UPLOAD_MAX_KB', 20 * 1024),
'directory' => 'pages',
// 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),
],
]; ];

View File

@@ -28,4 +28,17 @@ return [
'pages' => [ 'pages' => [
'cannot_publish' => 'No tens permís per publicar pàgines.', 'cannot_publish' => 'No tens permís per publicar pàgines.',
], ],
'media' => [
'not_an_image' => 'El fitxer no és una imatge JPEG, PNG, WebP, GIF o AVIF.',
'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.',
],
],
]; ];

View File

@@ -28,4 +28,17 @@ return [
'pages' => [ 'pages' => [
'cannot_publish' => 'You do not have permission to publish pages.', 'cannot_publish' => 'You do not have permission to publish pages.',
], ],
'media' => [
'not_an_image' => 'The file is not a JPEG, PNG, WebP, GIF or AVIF 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.',
],
],
]; ];

View File

@@ -28,4 +28,17 @@ return [
'pages' => [ 'pages' => [
'cannot_publish' => 'No tienes permiso para publicar páginas.', 'cannot_publish' => 'No tienes permiso para publicar páginas.',
], ],
'media' => [
'not_an_image' => 'El fichero no es una imagen JPEG, PNG, WebP, GIF o AVIF.',
'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.',
],
],
]; ];

View File

@@ -49,6 +49,8 @@ const catalogue = new Catalogue({
// A deployment outlives many deploys of the application; a stdio process // A deployment outlives many deploys of the application; a stdio process
// lives for one editor session and describes its tools once anyway. // lives for one editor session and describes its tools once anyway.
ttlMs: http ? 60_000 : Infinity, ttlMs: http ? 60_000 : Infinity,
// Over stdio the disk the server reads is the caller's own.
localFiles: !http,
}); });
if (http) { if (http) {

View File

@@ -16,11 +16,13 @@ export class Catalogue {
* @param {string} options.source Path or URL of openapi.yaml. * @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 {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 {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 }) { constructor({ source, baseUrl, ttlMs = Infinity, localFiles = false }) {
this.source = source; this.source = source;
this.baseUrl = baseUrl.replace(/\/+$/, ''); this.baseUrl = baseUrl.replace(/\/+$/, '');
this.ttlMs = ttlMs; this.ttlMs = ttlMs;
this.localFiles = localFiles;
this.current = null; this.current = null;
this.loadedAt = 0; this.loadedAt = 0;
this.pending = null; this.pending = null;
@@ -58,7 +60,7 @@ export class Catalogue {
this.current = { this.current = {
spec, spec,
tools: buildTools(spec), tools: buildTools(spec, { localFiles: this.localFiles }),
apiBase: /^https?:\/\//.test(serverUrl) ? serverUrl : `${this.baseUrl}${serverUrl}`, apiBase: /^https?:\/\//.test(serverUrl) ? serverUrl : `${this.baseUrl}${serverUrl}`,
}; };
this.loadedAt = Date.now(); this.loadedAt = Date.now();

View File

@@ -8,12 +8,19 @@
* expired upstream costs one extra round trip, not a broken session. * 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 // Refresh this long before the stated expiry, so a token never expires between
// the check and the request. // the check and the request.
const EXPIRY_MARGIN_MS = 60_000; const EXPIRY_MARGIN_MS = 60_000;
const TIMEOUT_MS = 30_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 { export class LandingClient {
/** /**
* @param {object} options * @param {object} options
@@ -55,7 +62,7 @@ export class LandingClient {
* @returns {Promise<{status: number, body: unknown}>} * @returns {Promise<{status: number, body: unknown}>}
*/ */
async call(operation, args = {}) { async call(operation, args = {}) {
const request = this.buildRequest(operation, args); const request = await this.buildRequest(operation, args);
if (!operation.requiresAuth) { if (!operation.requiresAuth) {
if (this.previewToken) request.headers['X-Preview-Token'] = this.previewToken; if (this.previewToken) request.headers['X-Preview-Token'] = this.previewToken;
@@ -77,7 +84,7 @@ export class LandingClient {
return response; return response;
} }
buildRequest(operation, args) { async buildRequest(operation, args) {
let path = operation.path; let path = operation.path;
const query = new URLSearchParams(); const query = new URLSearchParams();
@@ -100,7 +107,20 @@ export class LandingClient {
const headers = { Accept: 'application/json' }; const headers = { Accept: 'application/json' };
let body; let body;
if (operation.hasBody && args.body !== undefined) { 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'; headers['Content-Type'] = 'application/json';
body = JSON.stringify(args.body); body = JSON.stringify(args.body);
} }
@@ -115,6 +135,22 @@ export class LandingClient {
}; };
} }
/** @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 }) { async send({ url, method, headers, body }) {
const response = await this.fetch(url, { const response = await this.fetch(url, {
method, method,

View File

@@ -30,6 +30,7 @@ const DROPPED_KEYWORDS = new Set([
* @property {string} path Path as documented, e.g. /v1/pages/{page}. * @property {string} path Path as documented, e.g. /v1/pages/{page}.
* @property {{name: string, in: string}[]} params * @property {{name: string, in: string}[]} params
* @property {boolean} hasBody * @property {boolean} hasBody
* @property {string[]} files Multipart fields filled from a local file, read from `<name>_path`.
* @property {boolean} requiresAuth * @property {boolean} requiresAuth
* *
* @typedef {object} Tool * @typedef {object} Tool
@@ -42,9 +43,13 @@ const DROPPED_KEYWORDS = new Set([
/** /**
* @param {object} spec The parsed OpenAPI document. * @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[]} * @returns {Tool[]}
*/ */
export function buildTools(spec) { export function buildTools(spec, { localFiles = false } = {}) {
const paths = Object.keys(spec.paths ?? {}); const paths = Object.keys(spec.paths ?? {});
const tools = []; const tools = [];
@@ -58,7 +63,9 @@ export function buildTools(spec) {
if (EXCLUDED_OPERATIONS.has(`${method.toUpperCase()} ${path}`)) continue; if (EXCLUDED_OPERATIONS.has(`${method.toUpperCase()} ${path}`)) continue;
if ((op.tags ?? []).some((tag) => EXCLUDED_TAGS.has(tag))) continue; if ((op.tags ?? []).some((tag) => EXCLUDED_TAGS.has(tag))) continue;
tools.push(buildTool(spec, paths, path, method, item, op)); const tool = buildTool(spec, paths, path, method, item, op, localFiles);
if (tool) tools.push(tool);
} }
} }
@@ -79,7 +86,7 @@ export function buildTools(spec) {
return tools; return tools;
} }
function buildTool(spec, paths, path, method, item, op) { function buildTool(spec, paths, path, method, item, op, localFiles) {
const params = mergeParameters(spec, item.parameters, op.parameters) const params = mergeParameters(spec, item.parameters, op.parameters)
// Headers are credentials here (preview and ingest secrets), which come // Headers are credentials here (preview and ingest secrets), which come
// from the server's configuration, never from the model. // from the server's configuration, never from the model.
@@ -99,6 +106,32 @@ function buildTool(spec, paths, path, method, item, op) {
} }
const bodySchema = op.requestBody?.content?.['application/json']?.schema; 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 (bodySchema) {
if ('body' in properties) { if ('body' in properties) {
@@ -114,7 +147,9 @@ function buildTool(spec, paths, path, method, item, op) {
properties.body = { ...body, description: 'JSON request body.' }; properties.body = { ...body, description: 'JSON request body.' };
if (op.requestBody.required) required.push('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 ?? []; const security = op.security ?? spec.security ?? [];
@@ -139,6 +174,7 @@ function buildTool(spec, paths, path, method, item, op) {
path, path,
params: params.map(({ name, in: location }) => ({ name, in: location })), params: params.map(({ name, in: location }) => ({ name, in: location })),
hasBody: Boolean(bodySchema), hasBody: Boolean(bodySchema),
files,
requiresAuth: security.length > 0, requiresAuth: security.length > 0,
}, },
}; };

View File

@@ -1,4 +1,7 @@
import assert from 'node:assert/strict'; 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 { test } from 'node:test';
import { LandingClient } from '../src/client.mjs'; import { LandingClient } from '../src/client.mjs';
@@ -149,3 +152,47 @@ test('refuses a call missing a path parameter', async () => {
await assert.rejects(client.call(showPage, {}), /"page"/); 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/);
});

View File

@@ -79,3 +79,20 @@ test('only the admin API is marked as needing a token', () => {
assert.equal(tool.operation.requiresAuth, tool.name.startsWith('admin_'), tool.name); 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.equal(local.inputSchema.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']);
});

View File

@@ -108,6 +108,8 @@ tags:
description: Translatable page categories description: Translatable page categories
- name: Users - name: Users
description: Local user administration description: Local user administration
- name: Media
description: Images uploaded for a record to point at
- name: Roles - name: Roles
description: Local authorisation model (read-only) description: Local authorisation model (read-only)
@@ -1172,6 +1174,61 @@ paths:
"204": { description: Deleted } "204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" } "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 or AVIF, decided from the file's bytes rather than
its name or declared type; SVG is not accepted here. 20 MB at most.
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 pages.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: The image.
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
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: /v1/users:
get: get:
tags: [Users] tags: [Users]
@@ -2124,6 +2181,24 @@ components:
nullable: true nullable: true
description: Must be after `published_at`. When it arrives the record leaves the site on its own. description: Must be after `published_at`. When it arrives the record leaves the site on its own.
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: Category:
type: object type: object
properties: properties:

View File

@@ -12,6 +12,7 @@ use App\Http\Controllers\Api\PublicApi\V1\SiteController;
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController; use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
use App\Http\Controllers\Api\V1\CategoryController; use App\Http\Controllers\Api\V1\CategoryController;
use App\Http\Controllers\Api\V1\MeController; use App\Http\Controllers\Api\V1\MeController;
use App\Http\Controllers\Api\V1\MediaController;
use App\Http\Controllers\Api\V1\PageController; use App\Http\Controllers\Api\V1\PageController;
use App\Http\Controllers\Api\V1\RoleController; use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\TokenController; use App\Http\Controllers\Api\V1\TokenController;
@@ -165,6 +166,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::apiResource('categories', CategoryController::class); Route::apiResource('categories', CategoryController::class);
// 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']); Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']);
// Declared before the resource route so "catalogue" is not swallowed // Declared before the resource route so "catalogue" is not swallowed

View File

@@ -9,6 +9,8 @@ use App\Models\Page;
use App\Models\User; use App\Models\User;
use Database\Seeders\RolePermissionSeeder; use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon; use Tests\FakesPireon;
use Tests\TestCase; use Tests\TestCase;
@@ -120,6 +122,22 @@ class AdminApiContractTest extends TestCase
$this->assertSame([], $problems, "The admin API and its specification disagree:\n".implode("\n", $problems)); $this->assertSame([], $problems, "The admin API and its specification disagree:\n".implode("\n", $problems));
} }
#[Test]
public function an_upload_answers_in_the_shape_it_documents(): void
{
Storage::fake((string) config('media.disk'));
$response = $this->post('/api/v1/media', [
'file' => UploadedFile::fake()->image('hero.jpg', 64, 48),
], ['Accept' => 'application/json'])->assertCreated();
$record = $response->json('data');
$schema = $this->recordSchema('/v1/media', 'post', 201);
$this->assertSame([], array_values(array_diff(array_keys($record), $this->documentedKeys($schema))), 'Sent but not documented');
$this->assertSame([], array_values(array_diff($this->alwaysPresent($schema), array_keys($record))), 'Documented but not sent');
}
/** /**
* The pagination envelope every listing here uses — `data`, `links`, * The pagination envelope every listing here uses — `data`, `links`,
* `meta` — checked the same way the record inside it is: nothing sent * `meta` — checked the same way the record inside it is: nothing sent

View File

@@ -0,0 +1,323 @@
<?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);
}
/**
* @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'],
// Served from this domain, an SVG is a document that can run script.
'an SVG' => ['logo.svg', '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'],
];
}
#[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 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));
}
}