Manage technologies through the API, with SVG logos
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

/api/v1/technologies is a full CRUD with the panel's own rules: name and
slug are plain strings, the prose is translatable, `radar_ring: null`
takes one off the radar, and TechnologyPolicy still refuses to delete
one a project, member or call uses. The MCP server picks the tools up
from openapi.yaml on its own.

A logo is an SVG, so POST /v1/media now accepts one — sanitized with
enshrined/svg-sanitize (scripts, event handlers and remote references
removed), and served by Caddy under a Content-Security-Policy that
would stop a script regardless. `for` (pages or technologies) says what
an upload is for, which picks the directory the panel uses for it and
the permission it takes.

Creating a technology without a category answered 500: the column's
default never reached the model, so the response read a null enum. The
controller now states the default, as the panel does.

Beside a file, the MCP upload tool no longer demands the JSON body's
`url`: the body then carries the upload's other fields, such as `for`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:49:00 +02:00
parent a49816eb46
commit ac546eba2b
21 changed files with 789 additions and 38 deletions

View File

@@ -401,17 +401,24 @@ 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
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. 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`).
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
@@ -424,8 +431,8 @@ 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
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.
@@ -1256,6 +1263,8 @@ 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/technologies CRUD completo; filtros: category,
radar_ring (none = fuera del radar), search
POST /api/v1/media subir una imagen (fichero o URL)
```