Бакет это просто папка которая говорит HTTP
Каждое изображение и видео в этом блоге живёт в MinIO, S3-совместимом хранилище объектов которое можно запустить самому вместо оплаты Amazon. Я слышал «S3» используемый как синоним к «облачному хранилищу» много лет не зная что это на самом деле. Оказывается мысленная модель почти оскорбительно проста: бакет это именованный контейнер, объект это файл с ключом (в основном путь), и говоришь со всем этим через чистый HTTP с ключом доступа и секретом вместо, например, строки подключения к базе. Docker compose запускает два бакета при первой загрузке:
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 и avatars, оба установлены на публичную загрузку так что кто угодно может посмотреть URL изображения напрямую, но никто не может листать или писать в них без действительных кредентиалов. Только блог и auth сервисы когда-либо имеют эти.
Что действительно проверяется перед сохранением файла
Загрузка это не просто «взять байты и отправить в MinIO». Блог сервис нюхает первые 512 байт всего что отправляется чтоб узнать его реальный тип содержимого, вместо того чтоб доверять расширению имени файла или чему браузер случайно сказал:
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
}Переименуй .exe в photo.png и проверка расширения счастливо поверит тебе, но нюхание действительной байтовой сигнатуры в начале файла нет. Изображения и видео получают разные потолки размера, изображение топает на 10MB, видео получает целый гигабайт, что имело намного больше смысла как только я действительно подумал о том сколько весит видеоролик с телефона рядом с фото.
Аватары получают маленькое, строгое обращение
Фотографии профиля проходят через намного более плотный потолок чем медиа поста, 2 мебибайта на auth сервисе, что звучит жестоко пока ты не вспомнишь что большинство камер телефонов производят фото в пять-десять раз этого размера без усилий. Вместо просто отклонения всего что слишком большое и заставления кого-то идти искать редактор изображений, форма профиля пытается сжать это для тебя первой, полностью в браузере, перед тем как это вообще отправлено:
/** Цель наибольшей стороны для переделанного аватара. */
export const AVATAR_MAX_SIDE = 512;
/** Зеркалирует maxAvatarBytes auth сервиса (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))
};
}Это прогрессивное улучшение, не гарантия. Если canvas недоступна, или браузер полностью отключил JavaScript, оригинальный файл просто идёт необработанный и собственный потолок сервера это настоящая подпорка в любом случае, как это всегда было. Масштабирование вниз чисто там так что обычный случай, чьё-то фото по умолчанию с телефона, никогда даже не касается того стены.
Ошибка: загрузки умирают ровно на 3MB
На некоторое время, любая загрузка прошедшая определённый размер просто падала, никакой полезной ошибки, просто мёртвый запрос. Постоянно происходило почти на одном размере, где-то около 3MB, что было подозрительно само по себе так как ни один из моих действительных потолков в Go коде нигде близко не был так низко.
Провёл постыдное количество времени в обработчике загрузки блога, убеждённый что я неправильно установил MaxImageBytes или многочастный читатель как-то, добавляя логирование которое никогда не срабатывало, потому запрос никогда не дошёл до блога. Действительный потолок жил на один уровень дальше, в Caddy, который сидит впереди всего как обратный прокси:
# раньше: один общий потолок для всего admin API, включая загрузку медиа
handle /api/admin/* {
request_body {
max_size 3MB
}
reverse_proxy frontend:3000
}Caddy ограничивает тело запроса перед тем как оно достигнет фронтенда контейнера, не говоря уж о блоге. 3MB фото упал на эту стену и был отклонен прямо там, пустой, общий 413 без красивой JSON ошибки что блог иначе бы отправил, потому что блог никогда не получил шанс запуститься. Я смотрел на код неправильного сервиса целый вечер.
Заправил это дав загрузкам медиа свой собственный, намного большей, путь. Caddy сортирует эти блоки по насколько специфичен путь, не по порядку файла, так что более специфичное совпадение для /api/admin/media* выигрывает даже сидя над общим блоком в файле:
# Пути больших загрузок: admin медиа и загрузка аватара нужны место для
# законных загрузок (потолки на уровне сервиса остаются на аватаре 2MiB, медиа 10MB/1GB).
handle /api/admin/media* {
request_body {
max_size 1100MB
}
reverse_proxy frontend:3000
}
# Остаток admin API (посты, комментарии, пользователи, отчёты и т.д.)
# нуждаются в месте выше 2MB по умолчанию для admin тел постов (потолок на уровне сервиса 4MB),
# но далеко ниже блока медиа выше.
handle /api/admin/* {
request_body {
max_size 5MB
}
reverse_proxy frontend:3000
}Обвинение неправильного сервиса и где настоящие потолки живут
Урок что застрял: запрос этого размера проходит через три отдельных слоя перед тем как вообще какой из моего приложения код запустится, Caddy max_size, потом SvelteKit собственный BODY_SIZE_LIMIT, потом наконец блог собственный MaxImageBytes/MaxVideoBytes проверка. Любой из них может отклонить запрос, и какую ошибку ты действительно видишь полностью зависит от какой слой сказал нет первым. Фронтенд имеет маленький кусок кода специально чтоб останавливать это смешение от утечки кому-то кто действительно загружает:
// Действительный сетевой отказ (DNS, соединение отклонено) несёт никаких `status`
// где-либо в цепи, как два различаются без того чтоб SvelteKit вывез внутренний класс ошибки на `instanceof`-чек.
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;
}Какой слой действительно отклонил файл, человек загружающий только когда-либо видит «загрузка слишком большая», никогда не «сервис недоступен», что сырое потопленное соединение иначе выглядело бы как со их стороны. Взял действительно сломанный путь загрузки, и вечер смотрения неправильные логи, чтоб заставить меня заботиться об этом различии вообще.
