un bucket es solo una carpeta que habla HTTP
Cada imagen y cada vídeo de este blog vive en MinIO, un almacén de objetos compatible con S3 que puedes operar tú mismo en lugar de pagarle a Amazon por ello. Llevaba años oyendo "S3" usado casi como sinónimo de "almacenamiento en la nube" sin necesitar saber nunca qué era en realidad. Resulta que el modelo mental es casi insultantemente simple: un bucket es un contenedor con nombre, un objeto es un archivo dentro de él con una clave (básicamente una ruta), y hablas con todo el asunto por HTTP normal con una clave de acceso y un secreto en lugar de, digamos, una cadena de conexión de base de datos. Docker compose levanta dos buckets la primera vez que arranca:
minio-init:
image: minio/mc:RELEASE.2025-08-13T08-35-41Z
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin} &&
mc mb --ignore-existing local/media &&
mc mb --ignore-existing local/avatars &&
mc anonymous set download local/media &&
mc anonymous set download local/avatars
"media y avatars, ambos configurados para descarga pública para que cualquiera pueda ver una URL de imagen directamente, pero nadie puede listar o escribir en ellos sin las credenciales reales. Los servicios de blog y auth son las únicas dos cosas que las tienen alguna vez.
lo que en realidad se comprueba antes de guardar un archivo
Subir un archivo no es solo "coger los bytes y dárselos a MinIO". El servicio de blog olfatea los primeros 512 bytes de lo que se envía para averiguar su tipo de contenido real, en lugar de confiar en la extensión del nombre de archivo o en lo que el navegador dijera que era:
head := make([]byte, 512)
n, err := io.ReadFull(part, head)
...
head = head[:n]
contentType, ext, ok := media.DetectType(head, part.FileName())
if !ok {
writeFieldError(w, r, http.StatusUnprocessableEntity, "unsupported_media_type", "file type not allowed", "file")
return
}
maxBytes := s.MaxImageBytes
if strings.HasPrefix(contentType, "video/") {
maxBytes = s.MaxVideoBytes
}Renombra un .exe como photo.png y la comprobación de la extensión se lo creería con gusto, pero olfatear la firma de bytes real al principio del archivo no. Las imágenes y los vídeos también tienen techos de tamaño distintos, una imagen tope en 10MB, un vídeo se lleva un gigabyte entero, cosa que tuvo mucho más sentido en cuanto pensé de verdad en cuánto pesa un clip de vídeo de móvil al lado de una foto.
los avatares reciben el trato pequeño y estricto
Las fotos de perfil pasan por un tope mucho más ajustado que el contenido multimedia de un post, 2 mebibytes en el servicio de auth, lo cual suena severo hasta que recuerdas que la mayoría de las cámaras de móvil producen fotos de cinco a diez veces ese tamaño sin ni siquiera esforzarse. En lugar de simplemente rechazar cualquier cosa demasiado grande y hacer que alguien tenga que ir a buscar un editor de imágenes, el formulario de perfil intenta encogerla por ti primero, enteramente en el navegador, antes de que se envíe siquiera:
/** Longest-side target for a re-encoded avatar. */
export const AVATAR_MAX_SIDE = 512;
/** Mirrors the auth service's maxAvatarBytes (2 MiB). */
export const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
export function targetDimensions(
width: number,
height: number,
maxSide: number = AVATAR_MAX_SIDE
): { width: number; height: number } {
if (width <= 0 || height <= 0) return { width, height };
const longest = Math.max(width, height);
if (longest <= maxSide) return { width, height };
const scale = maxSide / longest;
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale))
};
}Es una mejora progresiva, no una garantía. Si canvas no está disponible, o el navegador tiene JavaScript completamente desactivado, el archivo original simplemente sube tal cual, y el propio tope del servidor es el respaldo real de todos modos, igual que siempre fue. El reescalado está ahí únicamente para que el caso común, la foto de móvil por defecto de alguien, ni siquiera llegue a chocar con esa pared en primer lugar.
el bug: subidas que morían exactamente en 3MB
Durante un tiempo, cualquier subida que pasara de cierto tamaño simplemente fallaba, sin ningún error útil, solo una petición muerta. Pasaba casi con el mismo tamaño cada vez, alrededor de 3MB, lo cual ya era sospechoso de por sí, porque ninguno de mis límites reales en el código de Go estaba ni cerca de ser tan bajo.
Me pasé una cantidad vergonzosa de tiempo en el manejador de subidas de blog, convencido de que había configurado mal MaxImageBytes o el lector multipart de alguna manera, añadiendo registros que nunca se dispararon ni una sola vez, porque la petición nunca llegaba a blog en absoluto. El límite real vivía una capa más afuera, en Caddy, que se sienta delante de todo como proxy inverso:
# before: one blanket cap for the whole admin API, including media uploads
handle /api/admin/* {
request_body {
max_size 3MB
}
reverse_proxy frontend:3000
}Caddy limita el cuerpo de la petición antes de que llegue siquiera al contenedor del frontend, y mucho menos a blog. Una foto de 3MB chocaba contra esa pared y era rechazada ahí mismo, un 413 vacío y genérico sin ninguno de los agradables errores JSON que blog habría enviado en otro caso, porque blog nunca tuvo la oportunidad de correr en absoluto. Me quedé mirando el código del servicio equivocado la mayor parte de una tarde.
Lo arreglé dándole a las subidas de medios su propia ruta de coincidencia, mucho más grande. Caddy ordena estos bloques por cuán específica es la ruta, no por el orden en el archivo, así que una coincidencia más específica para /api/admin/media* gana sobre el bloque general de admin aunque esté encima de él en el archivo:
# Large-upload paths: admin media and avatar upload need enough room for
# legitimate uploads (service-level caps stay at avatar 2MiB, media 10MB/1GB).
handle /api/admin/media* {
request_body {
max_size 1100MB
}
reverse_proxy frontend:3000
}
# Rest of the admin API (posts, comments, users, reports, etc.) needs
# headroom above the 2MB default for admin post bodies (service-level
# cap is 4MB), but far below the media block above it.
handle /api/admin/* {
request_body {
max_size 5MB
}
reverse_proxy frontend:3000
}culpar al servicio equivocado, y dónde viven de verdad los límites reales
La lección que se me quedó: una petición de este tamaño pasa por tres capas separadas antes de que corra siquiera algo de mi propio código de aplicación, el max_size de Caddy, luego el propio BODY_SIZE_LIMIT de SvelteKit, y finalmente la comprobación MaxImageBytes/MaxVideoBytes propia de blog. Cualquiera de esas puede rechazar la petición, y qué error ves en realidad depende por completo de qué capa dijo que no primero. El frontend tiene un pequeño trozo de código específicamente para evitar que esa confusión se filtre hacia quien esté subiendo el archivo:
// A genuine network failure (DNS, connection refused) carries no `status`
// anywhere in the chain, which is how the two are told apart without
// SvelteKit exporting the internal error class to `instanceof`-check.
function bodyTooLargeError(thrown: unknown): { status?: unknown; message?: unknown } | undefined {
const direct = errorLike(thrown);
if (direct?.status === 413) return direct;
const cause = direct ? errorLike((direct as { cause?: unknown }).cause) : undefined;
if (cause?.status === 413) return cause;
return undefined;
}Sea cual sea la capa que rechace de verdad el archivo, la persona que sube solo ve nunca "la subida es demasiado grande", nunca "servicio no disponible", que es lo que parecería desde su lado una conexión rota en bruto. Hizo falta una ruta de subida realmente rota, y una tarde mirando los registros equivocados, para que me importara esa distinción en absoluto.
