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.
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.
{ "r2_buckets": [{ "binding": "R2", "bucket_name": "mi-sitio" }] }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.
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:
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:
- Se mira el tamaño contra
maxFileSize. Si se pasa,413y no se ha leído el fichero. - Se leen los primeros bytes y se deduce el tipo real. Si no encaja con
mimeTypes,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.
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.
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:
media/01J8XYZ.../foto-portada.pngmedia/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, y304si mandas unIf-None-Matchque coincide.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.X-Content-Type-Options: nosniffyContent-Security-Policy: default-src 'none'; sandbox.
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: '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.
Lo que no hay
Sección titulada «Lo que no hay»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.