Ir al contenido

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:

cms.config.ts
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:

cms.config.ts
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.

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.

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.

Desde una plantilla, con la API local:

---
const doc = await Astro.locals.cms.collections.ssd.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/ssd/upload \
-H "Cookie: <tu sesión>" \
-F "file=@foto.png" \
-F "folder=fotos" \
-F "alt=Playa al atardecer"

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' }.

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 Content-Length declarado contra ese techo de 25 MB. Si se pasa, 413 sin 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 envoltorio multipart entero, no solo el fichero—, así que el tamaño exacto se vuelve a mirar en cuanto el fichero está parseado, y ese 413 tampoco ha escrito nada.
  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.

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 createid, uploadId, key y el filename— y con eso vuelve a deducir la clave, que es determinista. La key es 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 y los campos de tu colección. Un campo obligatorio que falte falla ahí, 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 415 o un 413 lo borran antes de contestarte. Es la única desviación de la regla de arriba, y está pagada.
  • El filesize de la fila sale de lo que R2 midió, nunca del size que 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 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:

fotos/01J8XYZ.../foto-portada.png
fotos/01J8ABC.../foto-portada.png

El 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.

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.

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.

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:

cms.config.ts
export default defineConfig({
collections: [posts],
media: { access: { list: 'public' } },
})

Es la única palanca que hay sobre la biblioteca, y afecta solo a list y count por REST:

  • GET /api/cms/ssd/<id> y las dos rutas de /api/cms/cdn/ssd/... siguen respondiendo sin sesión, con o sin media.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 aplica ninguna de las dos. Es código tuyo corriendo en tu servidor, y el panel lee la biblioteca por ahí.
  • Una colección upload que declares tú no entra aquí: su listado va por su propio access.read.

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.

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.

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.

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.