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" }] }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.
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:
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:
- 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 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.
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 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
415o un413lo borran antes de contestarte. 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 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:
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.
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, 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.
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' } },})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 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 aplica ninguna de las dos. Es código tuyo corriendo en tu servidor, y el panel lee la biblioteca por ahí. - 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.
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.
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.