Ir al contenido

Ficheros

Una colección que declara upload guarda ficheros: los bytes van a R2 y los metadatos a D1, en la misma fila que los campos que tú declares. El resto de colecciones apuntan a ella con un campo upload.

cms.config.ts
const media = defineCollection({
slug: 'media',
upload: {
mimeTypes: ['image/*', 'application/pdf'],
maxFileSize: 10 * 1024 * 1024,
},
fields: [{ name: 'alt', type: 'text', required: true }],
})
const posts = defineCollection({
slug: 'posts',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'cover', type: 'upload', to: 'media' },
],
})

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.

wrangler.jsonc
{ "r2_buckets": [{ "binding": "R2", "bucket_name": "mi-sitio" }] }

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.

Desde una plantilla, con la API local:

---
const doc = await Astro.locals.cms.collections.media.upload(file, { alt: 'Playa al atardecer' })
---

O por HTTP, con multipart/form-data. El fichero va en file y el resto de campos como campos del formulario:

Ventana de terminal
curl -X POST https://mi-sitio.com/api/cms/media/upload \
-H "Cookie: <tu sesión>" \
-F "file=@foto.png" \
-F "alt=Playa al atardecer"

Subir es una escritura: exige sesión aunque la colección tenga access: { read: 'public' }.

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:

  1. Se mira el tamaño contra maxFileSize. Si se pasa, 413 y no se ha leído el fichero.
  2. Se leen los primeros bytes y se deduce el tipo real. Si no encaja con mimeTypes, 415.
  3. Si es imagen, se sacan el ancho y el alto de la cabecera. La imagen no se decodifica.
  4. Se valida el resto de campos.
  5. Se escriben los bytes en R2.
  6. Se inserta la fila en D1.
  7. 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.

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 y HTML. 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 Se guarda como text/html y se sirve como descarga 415
Un .docx application/octet-stream, se sirve como descarga 415

Sin mimeTypes se acepta todo. Es permisivo, no inseguro: lo que no es visualizable se sirve con Content-Disposition: attachment y las cabeceras que impiden que el navegador lo ejecute.

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:

media/01J8XYZ.../foto-portada.png
media/01J8ABC.../foto-portada.png
---
const post = await Astro.locals.cms.collections.posts.findByID(id)
const media = Astro.locals.cms.collections.media
---
<img src={media.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/media/<id>/<filename>
Un id suelto /api/cms/cdn/media/<id>, que redirige 302 a la de arriba

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.

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. Si tu media no lo declara public, sus ficheros piden sesión.

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.
  • ETag, y 304 si mandas un If-None-Match que coincide.
  • Range, para que un vídeo o un audio puedan buscar: 206 con su Content-Range.
  • Content-Type el detectado al subir, leído de la fila.
  • Content-Disposition: inline para imagen, vídeo, audio y PDF; attachment para todo lo demás.
  • X-Content-Type-Options: nosniff y Content-Security-Policy: default-src 'none'; sandbox.

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: 'media', 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 media.

Se comprueba al escribir el documento y no al subir el fichero, porque son dos momentos distintos: la subida va contra media 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.

Borrar el documento borra su objeto de R2.

Si R2 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.

El límite de tamaño real es más bajo de lo que parece. Por defecto son 25 MB, y puedes subirlo con maxFileSize, pero no mucho: la subida carga el fichero en memoria y un Worker tiene unos 128 MB. Pasarse no da un 413 limpio, mata el isolate. Para ficheros grandes de verdad hacen falta la subida multiparte de R2 o URLs presignadas, y eso todavía no está.

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.