Upload images through the API, and from the MCP server
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:
42
README.md
42
README.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user