Ficheros
El CMS trae su propio almacén de ficheros: ssd, la biblioteca de medios del panel. No se
declara —el paquete la inyecta, como users— y es el destino habitual de un campo
upload, que siempre nombra el suyo en to:
const posts = defineCollection({ slug: 'posts', fields: [ { name: 'title', type: 'text', required: true }, { name: 'cover', type: 'upload', to: 'ssd' }, ],})Dentro de ssd los ficheros se organizan en carpetas: cada fila lleva su folder, y una carpeta
existe exactamente mientras exista un fichero dentro. Las que empiezan por . son del CMS —.system
es donde Ajustes guarda el favicon y el logo— y no se listan ni se navegan desde la biblioteca.
Declarar una colección propia con upload sigue valiendo, y es lo que quieres cuando ese almacén
necesita sus propias reglas: su tabla aparte, sus mimeTypes, su maxFileSize, sus campos y su
access.read. Los bytes van a R2 y los metadatos a D1, en la misma fila que los campos que declares:
const assets = defineCollection({ slug: 'assets', upload: { mimeTypes: ['image/*', 'application/pdf'], maxFileSize: 10 * 1024 * 1024, }, fields: [{ name: 'alt', type: 'text', required: true }],})En la biblioteca del panel esa colección aparece como una carpeta más, con su slug por nombre. Lo que
no puedes es llamarla ssd: es un slug reservado y defineConfig lo rechaza al arrancar.
Necesitas el binding de R2 declarado. Si una colección dice upload y no hay bucket, el sitio no
arranca: es un error de configuración, no un 500 a mitad de una petición.
{ "r2_buckets": [{ "binding": "R2", "bucket_name": "mi-sitio" }] }Dónde se guardan los bytes
Sección titulada «Dónde se guardan los bytes»Por defecto, en ese bucket. Una colección con upload (y la biblioteca, con media.storage) puede
ir a otro sitio con un perfil de almacenamiento que declaras en storage y nombras en
upload.storage: otro bucket de R2 por su binding, o un servicio compatible con S3.
export default defineConfig({ storage: { contratos: { adapter: 'r2', binding: 'R2_CONTRATOS' } }, collections: [ defineCollection({ slug: 'contratos', upload: { storage: 'contratos' }, fields: [] }), ],})Todo lo que sigue en esta página —el orden de la subida, el detector de tipos, las cabeceras al servir, la guarda al borrar— vale igual con cualquier perfil. Los perfiles, los secretos, qué está probado y cómo cambiar una colección de sitio sin dejar 404 en los ficheros que ya tiene están en Almacenamiento de ficheros.
Los cinco campos que añade
Sección titulada «Los cinco campos que añade»Toda colección con upload recibe estos cinco por delante de los tuyos. No los declares: son tuyos
solo para leerlos, y redefinirlos es un error al arrancar.
| Campo | Tipo | Qué es |
|---|---|---|
filename |
string |
El nombre saneado. Sin unique: dos ficheros pueden llamarse igual |
mimeType |
string |
El tipo detectado, nunca el que declaró el cliente |
filesize |
number |
Bytes |
width |
number | null |
Solo imágenes |
height |
number | null |
Solo imágenes |
A partir de ahí son campos normales: salen en los tipos generados, se filtran y ordenan por la API local y viajan en las respuestas de la API REST como cualquier otro.
La biblioteca ssd lleva además dos campos propios, que el paquete declara por ti: folder, la
carpeta del fichero —indexado, y fuera de los formularios del editor: lo escribe la subida y no se
mueve después— y alt, que no es obligatorio y se rellena al subir o después, desde el panel
del fichero.
Tamaños de imagen: imageSizes
Sección titulada «Tamaños de imagen: imageSizes»Cada tamaño es un objeto { name, width?, height?, fit?, format? }. Se declara en la colección de subida, o
en media para la biblioteca ssd, con el mismo contrato:
const photos = defineCollection({ slug: 'photos', upload: { mimeTypes: ['image/*'], imageSizes: [ { name: 'thumb', width: 150 }, { name: 'hero', width: 1600, height: 900, fit: 'cover', format: 'jpeg' }, ], }, fields: [{ name: 'alt', type: 'text' }],})
export default defineConfig({ collections: [photos], // Los mismos tamaños para la biblioteca ssd (la de los campos `upload` con `to: 'ssd'`) media: { imageSizes: [{ name: 'thumb', width: 150 }] },})| Opción | Qué vale |
|---|---|
name |
^[a-z][a-zA-Z0-9]*$, hasta 32 caracteres, distinto de id y único sin distinguir mayúsculas. Sin _ ni -: va a nombres de columna |
width, height |
Enteros de 1 a 4096. Hace falta al menos uno de los dos |
fit |
scale-down (si se omite: nunca amplía), contain, pad, squeeze, cover o crop |
format |
webp (si se omite), jpeg o png |
Los valores por defecto de fit y format los aplicará quien genere los tamaños: la configuración que lees
de vuelta trae lo que escribiste, sin rellenar.
Qué añade a la colección
Sección titulada «Qué añade a la colección»Un campo sizes, de solo lectura, después de los cinco y delante de los
tuyos: un group con un subgrupo por tamaño y cinco subcampos, todos nullable. Con los dos tamaños de
arriba, la tabla photos gana diez columnas:
sizes_thumb_width sizes_thumb_height sizes_thumb_filename sizes_thumb_filesize sizes_thumb_mimeTypesizes_hero_width sizes_hero_height sizes_hero_filename sizes_hero_filesize sizes_hero_mimeTypeEn la API y en los tipos generados es un objeto anidado (doc.sizes.thumb.width), que se filtra y se ordena
como cualquier group (where: { 'sizes.thumb.width': { equals: 150 } }). Es de solo lectura: un PATCH
o una subida con sizes en el cuerpo se rechaza con READ_ONLY_FIELD, por la API local, por REST y por la
subida por trozos, igual que filename o filesize. El editor del panel no lo pinta.
- Sin
imageSizes, la colección es exactamente la de antes: ni una columna, ni un campo, ni un cambio en lo que ve el navegador. La reserva del nombresizesva incluida: sinimageSizes, un campo tuyo llamadosizessigue valiendo. - Con
imageSizes,sizeses del CMS. Un campo (o ungroup) tuyo con ese nombre lanzaRESERVED_UPLOAD_FIELDal arrancar. Si ya guarda datos, renómbralo antes de declarar los tamaños con la receta de renombrado que imprimecms db:migrate: conserva las filas. - Las dimensiones, el
fity elformatno viajan al navegador. Los nombres de los tamaños sí, como las columnas delgroupque son.
Se valida al construir el config
Sección titulada «Se valida al construir el config»El binding IMAGES que usa wrangler dev acepta casi todo (fit: 'bogus', width: 0, rotate: 45) y
producción no, así que defineConfig es estricto: una clave que no es una opción de un tamaño (rotate,
quality…) también es un error. Cada error es un ConfigError que nombra la colección, el tamaño y el valor.
| Código | Cuándo |
|---|---|
INVALID_IMAGE_SIZES |
imageSizes no es una lista, o es una lista vacía (quita la clave en vez de dejarla vacía) |
TOO_MANY_IMAGE_SIZES |
Más de 10 tamaños |
INVALID_IMAGE_SIZE |
Un elemento que no es un objeto, o una clave desconocida |
INVALID_IMAGE_SIZE_NAME |
Un name que no cumple el patrón, de más de 32 caracteres o id |
DUPLICATE_IMAGE_SIZE |
Dos tamaños con el mismo nombre, sin distinguir mayúsculas |
INVALID_IMAGE_SIZE_DIMENSION |
Un width o height que no es un entero de 1 a 4096, o ninguno de los dos |
INVALID_IMAGE_SIZE_FIT |
Un fit que no es uno de los seis |
INVALID_IMAGE_SIZE_FORMAT |
Un format que no es webp, jpeg o png |
MISPLACED_IMAGE_SIZES |
imageSizes escrito en la colección y no dentro de upload |
TOO_MANY_COLUMNS |
La tabla, con los tamaños, pasaría de las 100 columnas que admite D1 |
RESERVED_UPLOAD_FIELD |
Un campo propio llamado sizes en una colección con imageSizes |
Cada tamaño gasta cinco columnas: con 10 tamaños son 50, y a las de tu colección se suman las 3 de sistema y
las 5 del upload. TOO_MANY_COLUMNS cuenta las reales, también las que tus
group y point aplanan, y solo se mira en colecciones que declaran imageSizes.
Evolucionar los tamaños: qué pasa con los datos
Sección titulada «Evolucionar los tamaños: qué pasa con los datos»| Haces | Qué pasa |
|---|---|
| Añadir un tamaño | Cinco ALTER TABLE … ADD de columnas nullable, sin recrear la tabla. Las filas existentes quedan con el tamaño en NULL hasta que se genere. No se pierde nada |
Quitar un tamaño (o imageSizes entero) |
Cinco DROP COLUMN por tamaño: se pierden sus metadatos en todas las filas. cms db:migrate pregunta antes de generarla. Cuando la generación exista, los ficheros ya generados quedarán en el almacén sin fila que los nombre. Hoy no hay ninguno |
| Renombrar un tamaño | drizzle-kit no sabe que es un renombrado: sin pista se para (missing_hints, salida 2) y db:migrate imprime la receta. Contestar «creada» borra las cinco columnas viejas con sus datos; con la pista rename se conservan |
Cambiar width, height, fit o format |
No hay migración y no hay aviso: el esquema es el mismo. Las filas conservarán los tamaños con las medidas viejas hasta que se vuelvan a generar |
Desde una plantilla, con la API local:
---const doc = await Astro.locals.cms.collections.ssd.upload( file, { alt: 'Playa al atardecer' }, { overrideAccess: true },)---El tercer argumento dice quién sube: { overrideAccess: true } si sube el servidor, o { user } si sube una
persona. Sin él la llamada es anónima y upload() lanza UnauthorizedError, porque subir es una escritura.
Ver Control de acceso.
O por HTTP, con multipart/form-data. El fichero va en file y el resto de campos como campos del
formulario:
curl -X POST https://mi-sitio.com/api/cms/ssd/upload \ -H "Cookie: <tu sesión>" \ -F "file=@foto.png" \ -F "folder=fotos" \ -F "alt=Playa al atardecer"El subcampo de un group va con su ruta con puntos
(-F "credit.author=Ana" escribe { credit: { author: 'Ana' } }). Un point no viaja así: es un par y
un formulario solo lleva texto, así que se escribe por JSON.
En ssd, folder es un campo más del formulario: sin él el fichero va a la raíz, y con él a esa
carpeta, que nace con este primer fichero. Una carpeta que empiece por . se rechaza con 400 y
código RESERVED_FOLDER, y el slug de una colección upload tuya, con FIXED_FOLDER: esa carpeta
tiene su propia tabla y se sube entrando en ella.
Subir es una escritura: exige sesión aunque la colección tenga access: { read: 'public' }. Y en una
colección upload tuya, si declaras una función de create, la decide ella.
El orden en que pasan las cosas importa, y está pensado para fallar barato — todo lo que puedes provocar tú se rechaza antes de que un solo byte llegue a R2:
- Se mira el
Content-Lengthdeclarado contra ese techo de 25 MB. Si se pasa,413sin tocar el cuerpo; si la cabecera no viene,411, porque sin ella no hay forma de acotar el envío sin leerlo. Es una cota superior —cubre el envoltoriomultipartentero, no solo el fichero—, así que el tamaño exacto se vuelve a mirar en cuanto el fichero está parseado, y ese413tampoco ha escrito nada. - Se leen los primeros bytes y se deduce el tipo real. Si el tipo —o la extensión del nombre— es de
riesgo,
415. Si no encaja conmimeTypes,415. - Si es imagen, se sacan el ancho y el alto de la cabecera. La imagen no se decodifica.
- Se valida el resto de campos.
- Se escriben los bytes en R2.
- Se inserta la fila en D1.
- Si el paso 6 falla, se borra el objeto de R2 y se propaga el error original.
R2 antes que D1 a propósito: si falla D1 queda un objeto huérfano, que es basura recogible. Al revés quedaría una fila apuntando a un fichero que no existe, y eso es un error en producción cada vez que alguien abra esa página.
Los cinco campos derivados no se pueden mandar. Si el formulario trae filename o mimeType,
es un 400 con código READ_ONLY_FIELD, no un descarte en silencio.
Subir por trozos
Sección titulada «Subir por trozos»De un golpe caben 25 MB, porque el envío se carga entero en memoria. Para lo que no cabe está la
subida multiparte de R2, que trocea el fichero y manda cada trozo en su propia petición: ningún
trozo se bufferiza, así que aquí sí manda tu maxFileSize.
Son cuatro acciones sobre la misma ruta —?action=create, part, complete y abort—, y están
documentadas paso a paso con curl en la
API REST. Lo que conviene saber de ellas aquí:
- El servidor no guarda nada entre trozo y trozo. Lo que le devuelves en cada petición es el asa
que te dio
create—id,uploadId,keyy elfilename— y con eso vuelve a deducir la clave, que es determinista. Lakeyes la que dice dónde está el objeto: en la biblioteca del CMS lleva dentro la carpeta, así que sin ella las cuatro fases no coincidirían. Repítela tal cual. - Lo que se puede comprobar sin bytes, se comprueba al abrir: el tamaño que declaras, los campos
de tu colección y la extensión del nombre. Un campo obligatorio que falte, o un
.exe, falla ahí —con un415y sin abrir ninguna subida en R2—, no después de subir un giga. - El tipo, el tamaño real y las dimensiones se comprueban al cerrar, porque hasta el último trozo
no se saben. Ahí el objeto ya está en R2, así que un
415o un413lo borran antes de contestarte. Un HTML llamadofoto.pngpasa al abrir (el nombre no acusa nada) y se rechaza aquí. Es la única desviación de la regla de arriba, y está pagada. - El
filesizede la fila sale de lo que R2 midió, nunca delsizeque declaraste al abrir.
Son dos números distintos y conviene no confundirlos:
| Cuánto | Quién lo pone | |
|---|---|---|
| El techo de la plataforma | ~928 GiB | 10.000 partes por debajo de los 100 MB de una petición |
| La política por defecto | 5 GiB | maxFileSize, que es tuyo |
El maxSize que te devuelve create es el menor de los dos, así que sin tocar nada te topa en
5 GiB y no en 928 GiB. Para subirlo, súbelo en tu colección:
defineCollection({ slug: 'assets', upload: { maxFileSize: 50 * 1024 * 1024 * 1024 }, fields: [],})Y maxFileSize gobierna esta vía, no la de un golpe: por mucho que lo subas, un POST de un
solo envío sigue topado en 25 MB.
Por ahora esto es solo servidor: no hay cliente que trocee por ti. El troceado en el navegador, el porcentaje y el tiempo restante son otro ticket, y el panel sigue subiendo de un golpe.
El tipo sale del contenido, no de lo que te digan
Sección titulada «El tipo sale del contenido, no de lo que te digan»El detector reconoce PNG, JPEG, GIF, WebP, AVIF, PDF, MP4, WebM, MP3, OGG, WAV, ZIP, HTML, SVG y los
ejecutables de Windows (PE), Linux (ELF) y macOS (Mach-O). Lo que no reconoce sale como
application/octet-stream.
mimeTypes no sustituye al detector: es un filtro sobre su respuesta.
| Lo que subes | Sin mimeTypes |
Con mimeTypes: ['image/*'] |
|---|---|---|
| Un PNG | Se guarda como image/png |
Se guarda |
Un HTML llamado foto.png |
415: es text/html y está bloqueado |
415 |
| Un SVG | 415, también bloqueado |
415: image/* no lo levanta |
Un .docx |
application/octet-stream, se sirve como descarga |
415 |
Tipos de riesgo: lo que se bloquea por defecto
Sección titulada «Tipos de riesgo: lo que se bloquea por defecto»Un HTML y un SVG ejecutan script en el origen que los sirve, y un ejecutable se ejecuta en quien lo
descarga. Ninguno es un fichero que un sitio de contenido quiera recoger, así que toda colección con
upload, ssd incluida, los rechaza con un 415 (UNSUPPORTED_MEDIA_TYPE), sin configurar nada:
| Tipo | Por los bytes | Por la extensión |
|---|---|---|
text/html |
<!doctype html>, <html>, <head>, <body>, <script>, <!-- |
.html .htm .xhtml .shtml |
image/svg+xml |
una raíz <svg>, tras un BOM, un <?xml?>, comentarios o un <!DOCTYPE svg> |
.svg .svgz |
application/x-executable |
PE (.exe, .dll…), ELF y Mach-O |
.exe .dll .com .scr .msi .msp .cpl .sys .drv .ocx .bat .cmd .ps1 .vbs .vbe .wsf .wsh .hta .lnk .sh .bash .command .so .dylib .jar |
Cualquiera de los dos ejes basta, y se complementan: los bytes cazan un HTML llamado foto.png; la
extensión caza un .exe o un .bat que no tienen nada reconocible dentro. La extensión es la última
del nombre ya saneado: foto.png.exe es un .exe, y los puntos finales (setup.exe.) se ignoran, como
hace Windows. Una extensión interior (setup.exe.png) no cuenta: el tipo que se sirve sale de la fila, no
del nombre.
El bloqueo va antes que mimeTypes. Para admitir uno de estos tipos, nómbralo tal cual en
mimeTypes de tu colección:
const logos = defineCollection({ slug: 'logos', upload: { mimeTypes: ['image/svg+xml', 'image/png'] }, fields: [],})Un comodín no lo levanta: image/* admite imágenes y deja fuera el SVG, y application/* o text/* no
traen ejecutables ni HTML. Las mayúsculas y los parámetros de la lista no cuentan
('Image/SVG+XML; charset=utf-8' es image/svg+xml). Admitir un tipo no abre los demás: una colección que
nombra el SVG sigue rechazando un HTML.
La biblioteca y Ajustes: media.mimeTypes
Sección titulada «La biblioteca y Ajustes: media.mimeTypes»ssd no se declara, así que su mimeTypes no está en ninguna colección tuya: se escribe en el bloque
media del config, con el mismo contrato que el de una colección. Es lo que necesitas para un favicon
o un logo en SVG, que suben a ssd desde Ajustes:
export default defineConfig({ collections: [posts], // La biblioteca admite imágenes y, además, el SVG por su nombre media: { mimeTypes: ['image/*', 'image/svg+xml'] },})- Sin la clave no cambia nada: la biblioteca admite todo menos los tipos de riesgo,
y un SVG sigue siendo un
415. - Un tipo de riesgo solo pasa si lo nombras tal cual.
image/*no trae el SVG ytext/*no trae el HTML, también aquí. Nombrarimage/svg+xmlno abre el HTML ni los ejecutables, y un HTML con extensión.svgo un SVG llamado.htmlsiguen siendo un415: mandan los bytes y el nombre. - Mayúsculas y parámetros no cuentan:
'IMAGE/SVG+XML; charset=utf-8'esimage/svg+xml. Se guarda y se muestra en su forma normal, y lo que cambies después en tu array no altera la política. - Se valida al construir el config. Una lista que no lo es, una lista vacía (cerraría toda subida), una entrada que no
es
tipo/subtipootipo/*, o el comodín total (asterisco, barra, asterisco) lanzan unConfigErrorde códigoINVALID_MEDIA_MIME_TYPESque nombra el valor. - No toca tus colecciones: su
mimeTypeses el suyo. Pedir SVG en una colecciónuploadpropia sigue siendo nombrarlo en suupload.mimeTypes.
En Ajustes, el selector de fichero del favicon y del logo ofrece esa lista (image/* mientras no escribas la
clave), y un fichero que la biblioteca rechaza vuelve como un error bajo el campo, con el 415 en claro:
nombra el tipo y dice que hay que añadirlo a media.mimeTypes. Es todo o nada: si el favicon sube y el logo se
rechaza, el favicon se deshace y no se guarda nada.
Un SVG que sí admites —en una colección o en media.mimeTypes— se guarda como image/svg+xml y se sirve
siempre como descarga (Content-Disposition: attachment, nosniff, Content-Security-Policy: sandbox), con
su script dentro: el CMS no lo sanea ni lo transforma. Da igual que la opción siga puesta: un SVG ya guardado se
sirve así aunque la quites. Dentro de un <img> sigue pintándose —el navegador no ejecuta script en una imagen—; abierto
en una pestaña, se descarga en vez de ejecutarse.
Lo que no detecta, a propósito: un script con #! (un texto que empieza así es legítimo, y la
extensión .sh ya lo cubre), un ejecutable de DOS sin cabecera PE, y un SVG precedido de más de 64 KB de
comentarios (el detector lee la cabecera del fichero, no el fichero entero). En esos tres casos manda la
extensión, y un fichero con otra extensión se guarda como application/octet-stream y se sirve como
descarga. Tampoco escanea contenido ni sanea el SVG.
El nombre del fichero
Sección titulada «El nombre del fichero»Se sanea antes de tocar nada: se normaliza, se pasa a minúsculas y se sustituye por - todo lo que
no sea [a-z0-9._-]. Los guiones seguidos se colapsan y el resultado se recorta a 200 caracteres.
Si queda vacío, se llama file.
| Lo que subes | Lo que se guarda |
|---|---|
Foto Portada.PNG |
foto-portada.png |
Ñandú en la playa 🏖.jpg |
-and-en-la-playa-.jpg |
../../etc/passwd |
..-..-etc-passwd |
🎉.png |
-.png |
El / no sobrevive al saneado, así que un nombre con ../ no puede salirse del prefijo de su
colección. Y dos ficheros con el mismo nombre conviven sin pisarse, porque la clave lleva el id del
documento en medio:
fotos/01J8XYZ.../foto-portada.pngfotos/01J8ABC.../foto-portada.pngEl primer segmento es la carpeta: en ssd la que lleva la fila, y en una colección upload tuya
su propio slug. Un fichero en la raíz de la biblioteca no tiene ese segmento y su clave es
01J8XYZ.../foto-portada.png a secas.
---const post = await Astro.locals.cms.collections.posts.findByID(id)const ssd = Astro.locals.cms.collections.ssd---
<img src={ssd.url(post.cover)} alt="" />url() es el único sitio que construye rutas de fichero, y es síncrona: puedes llamarla una vez
por imagen en un listado sin convertir el render en una cascada de await.
| Le pasas | Te da |
|---|---|
| El documento | /api/cms/cdn/ssd/<id>/<filename> |
| Un id suelto | /api/cms/cdn/ssd/<id>, que redirige 302 a la de arriba |
La carpeta no sale en la URL, aunque sí esté en la clave del objeto: lo que identifica al fichero es su id, así que mover un fichero de carpeta no rompería ningún enlace publicado.
La forma corta existe porque con depth: 0 un campo upload es solo un id, y sin el filename no
se puede construir la URL canónica. Cuesta un salto de más; con el documento resuelto no lo pagas.
El último segmento, <filename>, tiene que ser el nombre del fichero: /cdn/ssd/<id>/otro.png, con
el id de uno que se llama foto.png, responde 404 igual que un id que no existe —sin cuerpo del
fichero, sin ETag, y sin repetir el nombre verdadero—, y también si llevas un If-None-Match bueno o
un Range. Se comprueba después del acceso (quien no puede leer el fichero recibe el 401, 403 o
404 de siempre, con cualquier nombre) y distingue mayúsculas, porque el nombre guardado es siempre
minúscula. Un navegador que codifica un espacio como %20 sigue sirviéndose. Las URL que construye
url() llevan siempre el nombre bueno, y la forma corta (/cdn/ssd/<id>, un 302) no lo lleva.
Los ficheros salen por una ruta del Worker y no por una URL pública del bucket. Cuesta una invocación por descarga y a cambio el bucket no queda abierto a internet, y el control de acceso por fichero se podrá añadir sin cambiar ninguna URL ya publicada.
Servir es una lectura: respeta el access.read de la colección. Los ficheros de ssd se sirven
siempre sin sesión, y eso no es configurable: un sitio enseña portadas y su logo sin que nadie
haya entrado. Lo mismo vale para GET /api/cms/ssd/<id>, que exige conocer el id igual que la
descarga. Lo que sí pide sesión es listar la biblioteca —ver más abajo—.
Las colecciones upload que declares tú lo eligen con su propio access.read, y sin
access.read: 'public' sus ficheros piden sesión.
Si en una colección upload tuya access.read es una función, decide ella, como en cualquier
colección: un fichero que su condición esconde responde 404, igual que un id que no existe, y no hay
ETag que delate que existe. Ver Control de acceso.
ssd no tiene todavía control por función: es pública y nada más
(ver lo que el control de acceso no cubre).
Lo que devuelve:
- El cuerpo en streaming. Un fichero nunca se carga entero en la memoria del Worker.
Cache-Control: public, max-age=31536000, immutable. La clave lleva el id, así que lo que hay bajo una URL no cambia nunca. Es lo que recibenssdy cualquier colección conaccess.read: 'public'(la cadena). Una colección con una función deread, o sinaccess.read, respondeCache-Control: private, no-store—también sus errores—: una caché compartida le serviría el fichero a quien no pasa el acceso, yimmutablese lo seguiría sirviendo un año.ETag, y304si mandas unIf-None-Matchque coincide. Las cabeceras de seguridad y de caché van igual en el200, el206y el304.Range, para que un vídeo o un audio puedan buscar:206con suContent-Range.Content-Typeel detectado al subir, leído de la fila.Content-Disposition: inlinepara imagen, vídeo, audio y PDF;attachmentpara todo lo demás. El SVG es la excepción dentro de las imágenes: siempreattachment, aunque su tipo empiece porimage/.X-Content-Type-Options: nosniffyContent-Security-Policy: default-src 'none'; sandbox.
Listar la biblioteca
Sección titulada «Listar la biblioteca»Servir un fichero y listar el inventario son dos cosas distintas. La primera exige conocer un id; la
segunda —GET /api/cms/ssd y GET /api/cms/ssd/count— entrega el filename, el mimeType y el
alt de todo lo que hay subido a quien no sabe nada de antemano. Por eso el listado de ssd por
REST pide sesión por defecto: sin ella responde 401, como cualquier colección cerrada.
Si tu sitio quiere un catálogo navegable sin iniciar sesión, ábrelo en el config:
export default defineConfig({ collections: [posts], media: { access: { list: 'public' } },})media.access.list afecta solo a list y count por REST (qué tipos admite la biblioteca es otra cosa:
media.mimeTypes):
GET /api/cms/ssd/<id>y las dos rutas de/api/cms/cdn/ssd/...siguen respondiendo sin sesión, con o sinmedia.access.list.- Las carpetas reservadas (
.system) siguen fuera del listado en cualquiera de los dos casos: las dos reglas se suman. - La API local —
cms.collections.ssd.find()y.count()desde una plantilla— no miramedia.access.list: sigue elaccess.readde la colección, y el dessdes'public'. Una llamada anónima, sin{ user }nioverrideAccess, lista la biblioteca igual. - Una colección
uploadque declares tú no entra aquí: su listado va por su propioaccess.read.
Restringir qué fichero acepta un campo
Sección titulada «Restringir qué fichero acepta un campo»mimeTypes gobierna qué entra en la colección. accept, en el campo que apunta a ella, gobierna qué
puede referenciar ese campo:
{ name: 'cover', type: 'upload', to: 'ssd', accept: ['image/*'] }Escribir en cover el id de un PDF es un 400 con código INVALID_FILE_TYPE, aunque el PDF esté
perfectamente guardado en la biblioteca.
Con hasMany: true el campo es una galería y accept se aplica a cada elemento: el issue lleva la
posición del que sobra (gallery.2) y los demás no se miran por separado. Un campo upload con hasMany
guarda ids de una sola colección; ver hasMany de relaciones.
Se comprueba al escribir el documento y no al subir el fichero, porque son dos momentos distintos: la
subida va contra ssd y no sabe qué campo acabará usando ese fichero. No cuesta nada cuando no
aplica — si la escritura no trae ningún campo upload con accept, no se hace ninguna consulta de
más.
Un fichero al que algo apunta no se borra. La negativa es un 400 con un issue de código
REFERENCED que nombra a quien lo usa —una colección, con su plural, el título del documento y el
campo, o un global, con su label y el campo—, y llega igual por la
API local, por el DELETE de la API REST y por el
panel: la guarda vive en la operación, no en la pantalla. El código está en
la tabla de códigos.
La guarda cuenta todo lo que apunta al fichero, pero nombra solo lo que quien borra puede leer:
un documento cuya colección o cuyo campo no lee, o un global cerrado a su read, no sale por su nombre y
entra en el «y N más». Si no puede leer ninguno, el mensaje es «lo usan documentos que no puedes ver (y N
más)», y el fichero sigue sin borrarse: nombrar un documento ajeno delataría una referencia que no le
toca ver.
Cuentan también los campos upload con hasMany (una galería): el mensaje nombra el campo —Entradas «Mi artículo» (gallery)—, esté el fichero una vez o veinte en la misma lista. La guarda lee la tabla hija del campo, no el
documento, así que el coste es una consulta más por galería que apunte a la colección.
Borrar el documento borra su objeto del almacén de su colección.
Si el almacén falla al borrar, la fila desaparece igual y el error queda en el log. Es deliberado: dejar la fila por un fallo de limpieza convertiría el documento en imborrable, y un objeto huérfano cuesta mucho menos que eso. Ningún proceso del CMS recoge esos huérfanos después: ver Ficheros huérfanos.
Lo que no hay
Sección titulada «Lo que no hay»maxFileSize es política tuya, no un límite técnico. Por defecto son 5 GiB y lo mueves donde
quieras, pero solo manda por trozos, donde el techo de la plataforma son unos
928 GiB. Lo técnico es por dónde entra el fichero: de un golpe caben 25 MB —ese sí se carga
entero en memoria— y subir maxFileSize no lo cambia.
No hay cliente que trocee. Las cuatro acciones existen y están documentadas, pero partir el fichero, mandar los trozos y reintentar el que falle lo escribes tú. Tampoco hay URLs presignadas.
Tampoco hay deduplicación por contenido: dos ficheros idénticos son dos objetos.
La biblioteca de medios del panel sí existe, y sube por envío de formulario
normal —sin progreso por fichero— para no saltarse nada de lo que hace upload(): el olfateo de
bytes, las dimensiones y la fila en D1. Su tope es 25 MB por envío, contando todos los ficheros
del formulario juntos, porque ese sí carga el envío entero en memoria. Que la API admita mucho más
que el panel es esperado: son dos caminos distintos.