en bøtte er bare en mappe som snakker HTTP
Hvert bilde og hver video på denne bloggen bor i MinIO, en S3-kompatibel objektlagring du kan drifte selv i stedet for å betale Amazon for det. Jeg hadde hørt "S3" brukt nesten som et synonym for "skylagring" i årevis uten noensinne å trenge å vite hva det egentlig var. Det viser seg at den mentale modellen er nesten fornærmende enkel: en bøtte er en navngitt beholder, et objekt er en fil inni den med en nøkkel (i praksis en sti), og du snakker med hele greia over vanlig HTTP med en tilgangsnøkkel og en hemmelighet i stedet for, si, en databasetilkoblingsstreng. Docker compose starter to bøtter første gang det booter:
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 og avatars, begge satt til offentlig nedlasting slik at hvem som helst kan se en bilde-URL direkte, men ingen kan liste opp eller skrive til dem uten de faktiske innloggingsopplysningene. Blog- og auth-tjenestene er de eneste to tingene som noensinne har dem.
hva som faktisk sjekkes før en fil blir lagret
Opplasting er ikke bare "ta bytene og gi dem til MinIO." Blog-tjenesten snuser på de første 512 bytene av det som blir sendt for å finne ut den faktiske innholdstypen, i stedet for å stole på filnavnets endelse eller det nettleseren tilfeldigvis påsto:
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
}Døp om en .exe til photo.png og endelse-sjekken ville gladelig trodd deg, men å snuse på den faktiske bytesignaturen fremst i filen gjør det ikke. Bilder og videoer får også forskjellige størrelsestak, et bilde topper ut på 10MB, en video får en hel gigabyte, noe som ga mye mer mening da jeg faktisk tenkte over hva en mobilvideoklipp veier ved siden av et bilde.
avatarer får den lille, strenge behandlingen
Profilbilder går gjennom et mye trangere tak enn et innleggs medieinnhold, 2 mebibyte på auth-tjenesten, noe som høres slemt ut helt til du husker at de fleste mobilkameraer produserer bilder fem til ti ganger den størrelsen uten engang å anstrenge seg. I stedet for bare å avvise alt som er for stort og få noen til å gå finne et bilderedigeringsprogram, prøver profilskjemaet å krympe det for deg først, helt i nettleseren, før det noensinne sendes:
/** 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))
};
}Det er en progressiv forbedring, ikke en garanti. Hvis canvas ikke er tilgjengelig, eller nettleseren har JavaScript helt avslått, går originalfilen bare opp urørt, og serverens eget tak er den reelle bakstopperen uansett, akkurat som det alltid har vært. Nedskaleringen er der utelukkende for at det vanlige tilfellet, noens standard mobilbilde, aldri engang treffer den veggen i utgangspunktet.
buggen: opplastinger som døde ved nøyaktig 3MB
En stund feilet enhver opplasting forbi en viss størrelse bare, ingen nyttig feilmelding, bare en død forespørsel. Skjedde hver gang ved nesten nøyaktig samme størrelse, et sted rundt 3MB, noe som var mistenkelig i seg selv siden ingen av mine faktiske grenser i Go-koden var i nærheten av så lavt.
Brukte en pinlig mengde tid i blogs opplastingshåndterer, overbevist om at jeg hadde feilkonfigurert MaxImageBytes eller multipart-leseren på en eller annen måte, la til logging som aldri en eneste gang trigget, fordi forespørselen aldri nådde blog i det hele tatt. Den faktiske grensen bodde ett lag lenger ute, i Caddy, som sitter foran alt som reverse-proxy:
# 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 setter tak på forespørselskroppen før den noensinne når frontend-containeren, langt mindre blog. Et 3MB-bilde traff den veggen og ble avvist rett der, en tom, generisk 413 uten noen av de fine JSON-feilene blog ellers ville ha sendt, fordi blog aldri fikk sjansen til å kjøre i det hele tatt. Jeg stirret på feil tjenestes kode mesteparten av en kveld.
Fikset det ved å gi medie-opplastinger sin egen, mye større, sti-match. Caddy sorterer disse blokkene etter hvor spesifikk stien er, ikke etter filrekkefølge, så en mer spesifikk match for /api/admin/media* vinner over den generelle admin-blokken selv om den sitter over den i filen:
# 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
}å skylde på feil tjeneste, og hvor de faktiske grensene egentlig bor
Leksjonen som satte seg: en forespørsel av denne størrelsen passerer gjennom tre separate lag før noe av min egen applikasjonskode i det hele tatt kjører, Caddys max_size, så SvelteKits egen BODY_SIZE_LIMIT, så til slutt blogs egen MaxImageBytes/MaxVideoBytes-sjekk. Hvilket som helst av disse kan avvise forespørselen, og hvilken feil du faktisk ser avhenger helt av hvilket lag som sa nei først. Frontend har en liten kodebit spesifikt for å hindre den forvirringen fra å lekke ut til den som faktisk laster opp:
// 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;
}Uansett hvilket lag som faktisk avviste filen, ser personen som laster opp bare noensinne "opplastingen er for stor," aldri "tjenesten er utilgjengelig," som er hva en rå, avbrutt tilkobling ellers ville sett ut som fra deres side. Tok en genuint ødelagt opplastingssti, og en kveld med stirring på feil logger, for å få meg til å bry meg om det skillet i det hele tatt.
