Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 13 из 25 ~18 мин +50 XP

Загрузка файлов

Файлы — первый ресурс, который JSON не переварит: аватарки, вложения, импорт CSV. В gin загрузка файлов строится на multipart/form-data: c.FormFile достаёт *multipart.FileHeader, c.SaveUploadedFile пишет на диск, а разбор тела делает ParseMultipartForm с порогом MaxMultipartMemory. Разберём, что реально лежит в FileHeader, почему имя и Content-Type от клиента врать не перестанут, как принять пачку файлов одним POST-ом, чем MaxMultipartMemory отличается от настоящего лимита и как ставить его звеном с http.MaxBytesReader и статусом 413. Текстовые поля форм привязывались в [уроке про привязку](/courses/gin/lessons/04-binding/), звенья для лимитов — из [урока про middleware](/courses/gin/lessons/06-middleware/), а отдавать сохранённое будем статикой прошлого урока.

Содержание урока · 12 разделов

gin загрузка файлов: multipart/form-data изнутри#

До сих пор наш трекер питался JSON: TodoInput из урока про привязку, конверты Envelope из урока про ответы. Но JSON таскает только текст: аватар, вложение письма, импорт CSV в него не кладёшь — байты пришлось бы кодировать base64 (треть лишнего трафика) и держать всё тело в памяти до конца декодирования. Для файлов есть стандарт — multipart/form-data: тело режется на самодостаточные части (parts), каждая со своими заголовками Content-Disposition и Content-Type, а между частями стоит boundary — случайная строка из заголовка запроса. HTML-форма должна просить этот формат явно: <form method="post" enctype="multipart/form-data">, — забудете enctype, и форма уедет как application/x-www-form-urlencoded: сервер получит только имя файла, а не содержимое. curl собирает multipart за вас: curl -F "avatar=@cat.jpg" localhost:8080/upload — флаг -F читает файл, ставит Content-Type по расширению и режет boundary сам. На стороне gin gin загрузка файлов — это одна пара вызовов: c.FormFile достаёт файл из разобранной формы, c.SaveUploadedFile пишет его на диск. Всё остальное в уроке — гигиена вокруг этой пары: куда уходят большие файлы, почему имя и тип от клиента нельзя верить, как принять пачку за один POST и где стоит настоящий лимит размера. Хендлер повесим на POST: загрузка создаёт ресурс, и 201 Created здесь уместнее 200, как и для POST /todos из четвёртого урока.

Минимальный хендлер: FormFile и SaveUploadedFile

GO
package main

import (
        "net/http"
        "path/filepath"

        "github.com/gin-gonic/gin"
)

func main() {
        gin.SetMode(gin.ReleaseMode)
        r := gin.Default()

        r.POST("/upload", func(c *gin.Context) {
                fh, err := c.FormFile("avatar")
                if err != nil {
                        c.JSON(http.StatusBadRequest, gin.H{
                                "error":   "BAD_REQUEST",
                                "message": "файл не найден в поле avatar",
                        })
                        return
                }

                dst := filepath.Join("./uploads", filepath.Base(fh.Filename))
                if err := c.SaveUploadedFile(fh, dst); err != nil {
                        c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
                        return
                }

                c.JSON(http.StatusCreated, gin.H{
                        "saved": dst,
                        "name":  fh.Filename,
                        "size":  fh.Size,
                })
        })

        if err := r.Run(":8080"); err != nil {
                panic(err)
        }
}

c.FormFile("avatar") возвращает *multipart.FileHeader и error; SaveUploadedFile(fh, dst) пишет содержимое в ./uploads. filepath.Base — страховка от клиентских путей в имени, ниже разберём, почему она обязательна. Ответ — привычный JSON из c.JSON: имя сохранённого файла, оригинальное имя и размер — ровно те поля, которые фронтенд покажет сразу после загрузки.

Анатомия FormFile: ParseMultipartForm и FileHeader#

Заглянем в context.go — там пять строк, и все про net/http. c.FormFile первым делом проверяет c.Request.MultipartForm и, если форма ещё не разобрана, зовёт c.Request.ParseMultipartForm(c.engine.MaxMultipartMemory): поле движка уходит в стандартную библиотеку как maxMemory. ParseMultipartForm читает всё тело запроса и раскладывает части: текстовые поля — в Value, файловые — в буфер объёмом maxMemory, а что в буфер не влезло, переливается во временные файлы в os.TempDir. Второй вызов разбора не случится: MultipartForm уже не nil, поэтому и c.FormFile, и c.PostForm, и c.MultipartForm на одном запросе спокойно делят одну разобранную форму. Возврат — Request.FormFile(name): первый *multipart.FileHeader поля. FileHeader — это снимок части: Filename дословно как назвал файл клиент, Size — фактический размер части в байтах, посчитанный при разборе, Header — MIME-заголовки самой части, включая Content-Type, присланный клиентом. fh.Open() отдаёт multipart.File — интерфейс io.Reader, io.ReaderAt, io.Seeker и io.Closer: для мелких файлов это окно в память, для крупных — чтение секции временного файла. Пара FormFile и SaveUploadedFile — это весь formfile gin: получить хедер, открыть, скопировать. Разница между «заработало» и «не утекло в прод» — в деталях вокруг: имени файла, типе содержимого, лимитах и поведении при пачке. Их и разберём по порядку, начиная с того, куда именно пишет SaveUploadedFile.

Проверка: файл не приложили

POST /upload содержит только текстовое поле caption, поле avatar отсутствует вовсе. Что вернёт c.FormFile("avatar")?

SaveUploadedFile: MkdirAll, os.Create и чужие пути#

SaveUploadedFile — обёртка из четырёх строк над net/http-миром: fh.Open(), os.MkdirAll(filepath.Dir(dst), 0o750), os.Create(dst) и io.Copy. Каталог назначения она создаёт сама — ./uploads не нужно готовить руками; файл получает права 0666 до umask, а в свежих версиях gin есть и SaveUploadedFileWithPermissions с явным perm. io.Copy работает потоково с буфером 32 КиБ: память хендлера не зависит от размера файла — хоть из буфера, хоть из временного файла на диске. Главная дыра здесь — fh.Filename: он приходит с клиента дословно. Браузер шлёт только базовое имя, но curl позволяет filename=..., а руками собранный multipart — вообще что угодно, хоть ../../.bashrc. filepath.Join фильтром не служит: он склеивает путь и вызывает Clean, а Clean честно разрешает ../.. — и путь выезжает за пределы каталога. Минимальная страховка — filepath.Base(fh.Filename), отрезающая каталоги; пустое имя (filename=) после Base превращается в «.», и os.Create на каталоге честно упадёт ошибкой — обрабатывайте её как 400. Норма для боевого кода — вообще не использовать клиентское имя для сохранения: метка времени плюс случайный суффикс, а оригинал хранить в базе и показывать в ответе. Это убивает разом traversal, коллизии двух пользователей с одинаковым cat.jpg и проблемы с юникодом в файловых системах.

Клиентское имя без Base: куда уйдёт файл

Прочитайте код и выберите, что он выведет:

r.POST("/upload", func(c *gin.Context) {
        fh, err := c.FormFile("avatar")
        if err != nil {
                c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_REQUEST"})
                return
        }
        dst := filepath.Join("./uploads", fh.Filename) // filepath.Base забыли
        if err := c.SaveUploadedFile(fh, dst); err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
                return
        }
        c.JSON(http.StatusCreated, gin.H{"saved": dst})
})

// curl -F "avatar=@cat.jpg;filename=../../evil.txt" localhost:8080/upload

MultipartForm: пачка файлов и текст рядом#

c.FormFile всегда возвращает только первый файл поля. Для пачки — галереи, набора вложений — есть c.MultipartForm(): она доводит ParseMultipartForm и возвращает multipart.Form с двумя картами: Value map[string][]string для текстовых полей и File map[string][]multipart.FileHeader для файлов. Ключи повторяются ровно как в форме: три раза -F "photos=..." дают File["photos"] из трёх хедеров — тот же механизм, что у повторяющихся query-ключей из третьего урока. Текст рядом с файлами достаётся c.PostForm("caption") — метод сам разберёт multipart, если это ещё не случилось, и вернёт первое значение поля. Есть и путь через binding: multipart/form-data у ShouldBind — отдельный биндер, и структура с полями multipart.FileHeader и []multipart.FileHeader по тегу form заполнится файлами так же, как TodoInput заполнялся JSON-ом; binding:"required" на поле-файле проверит, что файл приложили, а validate прогонит остальные теги — механика четвёртого урока без изменений. Ручной путь с c.MultipartForm оставляет больше контроля: пофайловые логи, частичный успех, свои сообщения об ошибках — для загрузок это обычно и нужно. Общий принцип прежний: binder — для простых случаев с готовой валидацией, ручные вызовы — когда обработка файла сама по себе логика.

Пачка файлов: form.File["photos"]

GO
r.POST("/gallery", func(c *gin.Context) {
        form, err := c.MultipartForm()
        if err != nil {
                c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_REQUEST", "message": err.Error()})
                return
        }

        caption := c.PostForm("caption")
        saved := make([]string, 0, len(form.File["photos"]))
        for _, fh := range form.File["photos"] {
                name := fmt.Sprintf("%d-%s", time.Now().UnixNano(), filepath.Base(fh.Filename))
                dst := filepath.Join("./uploads", name)
                if err := c.SaveUploadedFile(fh, dst); err != nil {
                        continue // одна битая часть не отменяет остальные
                }
                saved = append(saved, dst)
        }

        c.JSON(http.StatusCreated, gin.H{
                "caption": caption,
                "saved":   saved,
                "total":   len(form.File["photos"]),
        })
})

form.File — карта поле-слайс: len и обход дают пачку целиком. caption через c.PostForm — форма уже разобрана, обращений к телу больше нет. UnixNano в имени спасает от коллизий при пачке одноимённых файлов; continue оставляет частичный успех — в ответе saved и total, по разнице видно, сколько не доехало.

Проверка: все файлы одного поля

Клиент отправил три файла в одном поле photos. Как достать все три?

MaxMultipartMemory: порог памяти, а не лимит размера#

Самое коварное место темы. r.MaxMultipartMemory = 8 << 20 — поле движка, по умолчанию 32 МиБ; gin передаёт его в ParseMultipartForm как maxMemory. Семантика у параметра одна: сколько байтов файловой части жить в памяти. Читатель multipart сначала заполняет буфер, а дальше переливает остаток части во временный файл в os.TempDir — загрузка в 2 ГиБ при maxMemory 8 МиБ завершится успешно: место на диске, а не в RAM. После возврата хендлера net/http удаляет временные файлы формы сам — чистить нечего, но и подхватить их не выйдет: копируйте содержимое в своём хендлере, как мы и делаем через SaveUploadedFile. Жёсткий же лимит тела в gin не встроен — его ставит http.MaxBytesReader: обёртка c.Request.Body в звене из шестого урока, c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit). При превышении ParseMultipartForm получает ошибку «http: request body too large», и хендлер отвечает 413 Request Entity Too Large. Дешёвый фильтр до разбора — проверка c.Request.Content-Length: клиент её может соврать, поэтому это только первый рубеж, авторитетный — MaxBytesReader, читающий по факту. Для очень больших файлов, где даже временные файлы не нужны, есть c.Request.MultipartReader() — потоковый разбор частей на лету без ReadForm-буферизации: NextPart, ручной разбор Content-Disposition и io.Copy сразу в хранилище. Gin-удобств там нет, но для гигабайтов и ограниченного диска это единственный путь; в 95% случаев хватает FormFile с нормальными лимитами.

Звено лимита: MaxBytesReader и 413

GO
const maxUpload = 10 << 20 // 10 МиБ на всё тело запроса

func limitBody() gin.HandlerFunc {
        return func(c *gin.Context) {
                c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUpload)
                c.Next()
        }
}

func buildUpload(r *gin.Engine) {
        r.MaxMultipartMemory = 8 << 20 // память на буфер, не лимит размера

        up := r.Group("/upload", limitBody())
        up.POST("/avatar", func(c *gin.Context) {
                fh, err := c.FormFile("avatar")
                if err != nil {
                        if errors.Is(err, http.ErrMissingFile) {
                                c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_REQUEST", "message": "файл не приложен"})
                                return
                        }
                        // сюда же прилетает «http: request body too large»
                        c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "TOO_LARGE"})
                        return
                }

                dst := filepath.Join("./uploads", fmt.Sprintf("avatar-%d", time.Now().UnixNano()))
                if err := c.SaveUploadedFile(fh, dst); err != nil {
                        c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
                        return
                }
                c.JSON(http.StatusCreated, gin.H{"saved": dst, "size": fh.Size})
        })
}

limitBody оборачивает Body на группе — звено встанет в цепочку маршрутов, как мы собирали звенья в седьмом уроке. ErrMissingFile и превышение лимита приходят в один err: разделяем их errors.Is — первый это 400, второй честный 413. MaxMultipartMemory оставляем 8 МиБ, чтобы RAM не прыгал с трафиком, а потолок задаёт звено.

Сто мегабайт при буфере восемь

Прочитайте код и выберите, что он выведет:

r := gin.Default()
r.MaxMultipartMemory = 8 << 20 // 8 МиБ, MaxBytesReader не подключён

r.POST("/upload", func(c *gin.Context) {
        fh, err := c.FormFile("video") // клиент шлёт файл на 100 МиБ
        if err != nil {
                c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "TOO_LARGE"})
                return
        }
        if err := c.SaveUploadedFile(fh, "./uploads/video.mp4"); err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
                return
        }
        c.JSON(http.StatusCreated, gin.H{"size": fh.Size})
})

// curl -F "video=@big.mp4" localhost:8080/upload

Валидация: имя, размер и магические байты#

В загруженном файле доверять можно только фактам. Filename — слова клиента: годится для показа («вы загрузили cat.jpg») и ни для чего больше. Header.Get("Content-Type") — Content-Type части — тоже слова клиента: curl -F "avatar=@evil.exe;type=image/jpeg" подделывает его одним флагом. fh.Size — вычислен net/http по факту чтения части, ему верить можно; проверка верхней границы всё равно должна случиться заранее — звеном с MaxBytesReader, а не после сохранения двух гигабайт на диск. Настоящий тип говорит содержимое: http.DetectContentType по первым 512 байтам — тот самый sniffing из net/http с таблицей магических байтов: JPEG начинается с \xFF\xD8\xFF, PNG — с \x89PNG, PDF — с %PDF-. На неизвестных байтах вернёт application/octet-stream — «непонятно, скачивается, не исполняется». Механика: fh.Open(), io.ReadFull в буфер на 512 байтов, DetectContentType по прочитанному куску, а перед дальнейшим копированием — Seek(0, io.SeekStart): multipart.File поддерживает Seek, но голова уже съедена. Whitelist расширений (.jpg, .png, .gif) оставьте второй линией: совпадение расширения и сниффнутого типа — признак здравого файла, расхождение — повод для 415 Unsupported Media Type. И помните из девятого урока: validator валидирует структуру и строки, байты — ваша работа. Последний штрих — раскладка по каталогам: тысячи файлов в одной папке тормозят файловую систему, раскладывайте по дате или префиксу имени.

saveImage: тип, размер и своё имя

GO
var imageTypes = map[string]string{
        "image/jpeg": ".jpg",
        "image/png":  ".png",
        "image/gif":  ".gif",
}

func saveImage(fh *multipart.FileHeader) (string, error) {
        if fh.Size > 5<<20 { // 5 МиБ на один файл
                return "", errors.New("файл больше 5 МиБ")
        }

        f, err := fh.Open()
        if err != nil {
                return "", err
        }
        defer f.Close()

        head := make([]byte, 512)
        n, _ := io.ReadFull(f, head)
        ext, ok := imageTypes[http.DetectContentType(head[:n])]
        if !ok {
                return "", fmt.Errorf("тип %q не разрешён", http.DetectContentType(head[:n]))
        }

        name := fmt.Sprintf("%d-%x%s", time.Now().UnixNano(), randSuffix(4), ext)
        dst := filepath.Join("./uploads", time.Now().Format("2006/01/02"), name)

        f.Seek(0, io.SeekStart) // голова уже прочитана — перематываем
        if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
                return "", err
        }
        out, err := os.Create(dst)
        if err != nil {
                return "", err
        }
        defer out.Close()

        _, err = io.Copy(out, f)
        return dst, err
}

func randSuffix(n int) []byte {
        b := make([]byte, n)
        _, _ = rand.Read(b) // crypto/rand
        return b
}

DetectContentType по первым 512 байтам решает, картинка ли это на самом деле; whitelist imageTypes отсекает остальное. Имя своё: метка времени, случайный суффикс из crypto/rand и сниффнутое расширение — клиентское имя осталось только для ответа. Seek(0) перед копированием обязателен: ReadFull уже съел голову. Раскладка 2006/01/02 держит каталоги небольшими.

Проверка: настоящий тип файла

Клиент прислал файл с именем photo.jpg и Content-Type части image/jpeg. Как надёжно понять, что внутри?

curl, HTML-форма и жизнь после сохранения#

Отправлять multipart удобно curl-ом: -F "avatar=@cat.jpg" читает файл и ставит Content-Type по расширению; -F "avatar=@cat.jpg;type=image/jpeg" задаёт тип части явно; текстовые поля — просто ещё один -F; повторяйте имя поля — получите пачку, как в form.File["photos"]. HTML-форма симметрична: method="post", enctype="multipart/form-data", input type="file" с name="avatar", атрибут multiple для пачки и accept="image/*" как фильтр диалога — но помните, что accept фильтрует только окно выбора, серверная проверка всё равно ваша. Забытый enctype — самый частый дебют: сервер получит urlencoded и упадёт с ErrNotMultipart на разборе. Обрезанный curl (Ctrl+C посреди передачи) даст ошибку unexpected EOF от multipart-читателя — отвечайте 400 с «файл не долетел». Дальше файл живёт своей жизнью: сохранить сохранили, а отдать — штатными средствами прошлого урока: r.Static("/uploads", "./uploads") раздаёт сохранённое по URL, c.FileAttachment отдаёт с Content-Disposition для скачивания под исходным именем. Для тестов тело собирает mime/multipart.NewWriter — разберём его в уроке 21-testirovanie вместе с httptest. И последняя карта — прод: локальный диск удобен на одном узле, а под горизонтальное масштабирование файлы уезжают в объектное хранилище, и тогда вместо SaveUploadedFile io.Copy пишет в клиент S3 — механика разбора формы не меняется ни на байт.

Справочник: инструменты загрузки#

ИнструментЧто делаетВозвращаетКогда брать
c.FormFile("avatar")разбирает форму при первом вызове и берёт первый файл поля*multipart.FileHeader, errorодно поле с одним файлом
c.MultipartForm()разбирает всё тело целиком*multipart.Form: Value и Fileпачка файлов и текстовые поля
fh.Open()открывает содержимое файлаmultipart.File: Reader + Seekerсвой копир, sniffing типа
c.SaveUploadedFile(fh, dst)MkdirAll + os.Create + io.Copyerrorбыстро сохранить на диск
r.MaxMultipartMemory = 8 << 20порог памяти ParseMultipartFormпо умолчанию 32 МиБограничить RAM, не размер
http.MaxBytesReader в звенежёсткий лимит тела запросаошибка too large, ответ 413боевой лимит загрузки
c.FileAttachment(path, name)отдаёт файл с Content-Disposition—скачивание сохранённого, урок 11

Семь правил загрузки файлов#

  • Файлы едут только multipart/form-data: без enctype в форме и с JSON-телом разбор упадёт ErrNotMultipart — проверяйте Content-Type до c.FormFile.
  • «Файла нет» — это http.ErrMissingFile, а не пустой хедер и не паника: для необязательного файла это нормальный исход, различайте его через errors.Is.
  • Filename и Content-Type части — слова клиента: filepath.Base для имени, http.DetectContentType для типа; расширение — вторая линия, не первая.
  • MaxMultipartMemory — про RAM: порог, излишек уходит во временные файлы и удаляется после хендлера; жёсткий лимит размера — http.MaxBytesReader в звене с ответом 413.
  • Сохраняйте под своим именем — метка времени плюс случайный суффикс; клиентское имя остаётся только для показа, traversal и коллизии исчезают вместе.
  • Один битый файл пачки не откатывает остальные: продолжайте цикл и верните отчёт saved/failed — partial success честнее 500 на всём запросе.
  • Сохранённое раздавайте Static из прошлого урока, скачивайте c.FileAttachment, а в проде уезжайте на объектное хранилище — разбор формы при этом не меняется.

Соберите хендлер загрузки аватара

Допишите загрузку: достать файл, отличить «не приложен» от лимита, сохранить под чистым именем и вернуть размер.

func uploadAvatar(c *gin.Context) { // 1. Первый файл поля avatar. fh, err := c.("avatar") if err != nil { if errors.Is(err, http.ErrMissingFile) { c.JSON(http., gin.H{"error": "BAD_REQUEST", "message": "файл не приложен"}) return } c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "TOO_LARGE"}) return } // 2. Имя без клиентского пути. dst := filepath.Join("./uploads", .Base(fh.Filename)) // 3. MkdirAll + os.Create + io.Copy одним вызовом. if err := c.(fh, dst); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"}) return } c.JSON(http.StatusCreated, gin.H{"saved": dst, "size": fh.}) }

Соберите путь multipart-запроса

POST /upload/avatar при MaxMultipartMemory 8 МиБ и звене limitBody: разложите события от curl до 201 в логе.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: загрузка файлов

Термин

c.FormFile и ErrMissingFile

нажмите, чтобы перевернуть
Определение

FormFile при первом вызове разбирает форму (ParseMultipartForm с engine.MaxMultipartMemory) и возвращает первый *multipart.FileHeader поля. Отсутствие поля — ошибка http.ErrMissingFile («http: no such file»): необязательный файл различайте через errors.Is; остальное — битый multipart, ErrNotMultipart или превышенный лимит.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Загрузка — это multipart/form-data: c.FormFile возвращает первый *multipart.FileHeader поля; отсутствие поля — http.ErrMissingFile, различаемый через errors.Is, а не пустой хедер.
  • c.SaveUploadedFile(fh, dst) — MkdirAll, os.Create, io.Copy; имя целиком ваше: filepath.Base от клиентского Filename — минимум, метка времени со случайным суффиксом — норма.
  • c.MultipartForm() даёт Value и File целиком: пачка одного поля — File["photos"], текст рядом — c.PostForm; ShouldBind заполняет *multipart.FileHeader по тегу form.
  • MaxMultipartMemory — порог памяти (по умолчанию 32 МиБ): излишек уходит во временные файлы и удаляется после хендлера; это не лимит размера загрузки.
  • Жёсткий лимит — http.MaxBytesReader в звене: превышение ловится ошибкой разбора и отвечает 413; проверка Content-Length — лишь первый рубеж.
  • Настоящий тип файла — http.DetectContentType по первым 512 байтам; расширение и Content-Type части — слова клиента; сохранённое отдаётся Static и c.FileAttachment из урока 11.

Домашнее задание

Соберите загрузочный этаж трекера: POST /upload/avatar с c.FormFile и SaveUploadedFile в ./uploads под именем из метки времени, звено limitBody с http.MaxBytesReader на 10 МиБ и ответом 413, различение ErrMissingFile и превышения лимита через errors.Is. Добавьте POST /gallery с c.MultipartForm: пачка photos, текстовый caption через c.PostForm, отчёт saved и total. Проверьте тип через http.DetectContentType и отбейте не-картинки 415 Unsupported Media Type. Прогоните curl-ом четыре сценария: обычная загрузка, filename=../../evil.txt, файл больше лимита, JSON вместо multipart. Сохранённое отдайте через r.Static("/uploads", "./uploads") и убедитесь, что файл открывается в браузере.

Личные заметкиПусто
Хранятся только в вашем браузере (Gin · Загрузка файлов)

Вопросы и ответы по уроку

Почему c.FormFile вернул ErrNotMultipart?

Content-Type не multipart/form-data: в HTML-форме забыли enctype="multipart/form-data", или клиент прислал JSON. Файлы едут только multipart-ом; JSON-тело разбирают ShouldBindJSON и компания, FileHeader там не существует.

MaxMultipartMemory ограничивает размер загрузки?

Нет: это порог памяти ParseMultipartForm. Всё, что не влезло, net/http кладёт во временные файлы и удаляет после хендлера, поэтому и гигабайтный файл сохранится. Жёсткий лимит ставит http.MaxBytesReader в звене — с ответом 413.

Как принимать очень большие файлы без дискового буфера?

c.Request.MultipartReader() — потоковый разбор: NextPart в цикле, ручной разбор Content-Disposition и io.Copy сразу в хранилище. Gin-удобств FileHeader и SaveUploadedFile там нет, но диска и памяти уходит минимум — путь для файлов в гигабайты.

Где хранить загруженные файлы в проде?

Не на локальном диске пода, если узлов несколько: объектное хранилище (S3-совместимое) или общая файловая система. Локальный диск остаётся для разработки и одиночных узлов; раздача — через StaticFS или прямые ссылки хранилища.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Echo25 уроков~13 чпрактика25 уроков уже доступны

Echo: минималистичный быстрый фреймворк

Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.

  • Строить REST API на Echo
  • Использовать типизированные запросы/ответы (Request/Response)
  • Управлять ошибками через echo.HTTPError
  • Писать middleware, включая JWT-аутентификацию