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>
This commit is contained in:
2026-09-28 11:16:33 +02:00
parent daf6098203
commit a49816eb46
21 changed files with 1008 additions and 11 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
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 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
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/roles solo lectura
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
@@ -1419,6 +1451,12 @@ salvo `LANDING_MCP_HOST`/`LANDING_MCP_PORT`), para probarlo sin Docker.
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