---
title: "Загрузка файлов"
description: "Загрузка файлов в gin: FormFile и SaveUploadedFile, разбор MultipartForm, несколько файлов за раз, MaxMultipartMemory и лимиты через http.MaxBytesReader."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/13-fajly/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Gin: самый популярный веб-фреймворк Go», урок 13 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/13-fajly/*

Файлы — первый ресурс, который 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/), а отдавать сохранённое будем статикой прошлого урока.

### 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")?

- Пустой *multipart.FileHeader и nil-ошибку: файл просто не приложили
- ✅ Ошибку http.ErrMissingFile — отличайте её через errors.Is от остальных ошибок разбора
- Панику nil-pointer при первом обращении к fh.Filename
- Ошибку ErrNotMultipart: без файлов Content-Type не считается multipart

Форма разберётся успешно, Value получит caption, но ключа avatar в файловой карте нет — Request.FormFile вернёт ErrMissingFile («http: no such file»). Для необязательного аватара это валидный исход: сохранять нечего, но и 400 ставить не обязательно — продолжайте без файла. ErrNotMultipart ловится раньше, на ParseMultipartForm, — когда Content-Type не multipart вовсе, например клиент прислал JSON.

### 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: куда уйдёт файл

```go
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
```

**Что выведет программа?**

- Файл сохранится как ./uploads/../../evil.txt — Join оставит клиентский путь как есть
- ✅ Файл уйдёт в ../evil.txt — Clean схлопнет сегменты ../.., и путь выйдет за пределы uploads
- SaveUploadedFile откажется работать: путь с ../ запрещён и вернёт ошибку
- Файл сохранится в ./uploads с именем evil.txt — каталоги в filename отбрасываются автоматически

Join — не фильтр: он склеивает путь и вызывает Clean, а Clean честно разрешает родительские сегменты — ./uploads/../../evil.txt превращается в ../evil.txt, и запись случится рядом с uploads, вне раздаваемого каталога. Клиентский Filename — слова клиента: curl подставляет filename= как задано, а собранный руками multipart — что угодно. filepath.Base(fh.Filename) отрезает каталоги, а лучше — своё имя из метки времени и случайного суффикса: traversal и коллизии исчезают вместе.

### 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. Как достать все три?

- Трижды c.FormFile("photos"): каждый вызов вернёт следующий файл
- ✅ c.MultipartForm() и слайс File["photos"]: все хедеры поля лежат в []*multipart.FileHeader
- c.QueryArray("photos"): multipart-поля собираются так же, как query
- fh.Next(): FileHeader умеет перебирать файлы поля

c.FormFile всегда отдаёт первый элемент поля — три вызова вернут один и тот же хедер. Полный список живёт в MultipartForm.File, карте поле-слайс; для текстовых полей той же карты Value служит c.PostForm. QueryArray — из урока про параметры, он про query-строку и к multipart отношения не имеет. У FileHeader метода Next нет — список держит сама форма.

### 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 не прыгал с трафиком, а потолок задаёт звено.

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

```go
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
```

**Что выведет программа?**

- 413 Request Entity Too Large: файл больше MaxMultipartMemory, ParseMultipartForm отвергнет его
- ✅ 201 с size: MaxMultipartMemory — порог памяти, первые 8 МиБ в RAM, остаток уйдёт во временные файлы
- Ошибка multipart: NextPart: EOF — читатель не согласует границу части
- Хендлер отработает, но сохранённый файл окажется обрезанным до 8 МиБ

MaxMultipartMemory — про RAM, не про потолок: multipart-читатель заполняет буфер, а остаток части переливает во временный файл, и FileHeader.Open даёт к нему доступ как ни в чём не бывало. Ничего не обрезается: Size вернёт все сто мегабайт. Реальный потолок ставит http.MaxBytesReader, которого в этом коде нет — «поставил MaxMultipartMemory и защищён» опасная иллюзия, файл в 2 ГиБ молча ляжет на диск.

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

В загруженном файле доверять можно только фактам. 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. Как надёжно понять, что внутри?

- Достаточно расширения .jpg — браузер подставляет его сам и не врёт
- Сравнить fh.Header.Get("Content-Type") с image/jpeg — curl не умеет его менять
- ✅ Открыть файл и прогнать первые 512 байт через http.DetectContentType — магические байты флагом не подделать
- Проверить fh.Size — у каждого формата свой характерный размер

Имя и Content-Type части приходят от клиента дословно: curl -F "avatar=@evil.exe;type=image/jpeg" подделывает оба метаданных одним флагом, руками собранный multipart — вообще всё. DetectContentType смотрит на содержимое: первые байты JPEG или PNG — не поле формы, а факт. Расширение и сниффнутый тип обычно сверяют вместе: расхождение — повод для 415, а не для игры в детектив.

### 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.Copy | error | быстро сохранить на диск |
| 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, а в проде уезжайте на объектное хранилище — разбор формы при этом не меняется.

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

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

```go
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.___})
}
```

Подсказка: Файл достаёт c.FormFile; отсутствие файла — это 400; имя чистит filepath.Base; на диск пишет c.SaveUploadedFile; фактический размер — поле Size у FileHeader.

Правильные ответы в пропусках: `FormFile`, `StatusBadRequest`, `filepath`, `SaveUploadedFile`, `Size`

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

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

Правильный порядок строк:

1. `curl -F "avatar=@cat.jpg" собирает multipart/form-data: boundary и части с Content-Disposition`
2. `POST /upload/avatar находит маршрут в дереве, цепочка: limitBody, Logger, хендлер`
3. `limitBody оборачивает Request.Body в http.MaxBytesReader с потолком 10 МиБ`
4. `Хендлер зовёт c.FormFile("avatar")`
5. `Request.MultipartForm пуст — FormFile вызывает ParseMultipartForm(engine.MaxMultipartMemory)`
6. `net/http достаёт boundary из Content-Type и читает части multipart.Reader-ом`
7. `Текстовые поля ложатся в Value, файловые — в буфер maxMemory, излишек — во временные файлы os.TempDir`
8. `Часть avatar становится *multipart.FileHeader: Filename от клиента, Size по факту, Header части`
9. `Request.FormFile("avatar") возвращает первый хедер поля`
10. `fh.Open() отдаёт multipart.File — окно в память или секцию временного файла`
11. `saveImage проверяет 512 байт через http.DetectContentType и перематывает Seek-ом`
12. `io.Copy пишет файл на диск с именем из метки времени, c.JSON отдаёт 201, net/http удаляет временные файлы`

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

- **c.FormFile и ErrMissingFile** — FormFile при первом вызове разбирает форму (ParseMultipartForm с engine.MaxMultipartMemory) и возвращает первый *multipart.FileHeader поля. Отсутствие поля — ошибка http.ErrMissingFile («http: no such file»): необязательный файл различайте через errors.Is; остальное — битый multipart, ErrNotMultipart или превышенный лимит.
- **multipart.FileHeader** — Filename — как назвал файл клиент (не верить, чистить filepath.Base), Size — фактический размер части, Header — MIME-заголовки части. Open() даёт multipart.File: io.Reader + ReaderAt + Seeker + Closer — чтение и из памяти, и из временного файла.
- **c.SaveUploadedFile** — Open + os.MkdirAll(каталог, 0o750) + os.Create + io.Copy. Потоковая: память не зависит от размера файла. Каталог создаёт сама; в свежих версиях gin есть SaveUploadedFileWithPermissions с явным perm.
- **c.MultipartForm** — Разбирает всё тело в *multipart.Form: Value map[string][]string для текста, File map[string][]*multipart.FileHeader для файлов. Текст рядом достаёт c.PostForm. Binding-путь: ShouldBind заполняет *multipart.FileHeader-поля по тегу form и валидирует как обычную структуру.
- **MaxMultipartMemory** — Поле движка (по умолчанию 32 МиБ), maxMemory для ParseMultipartForm: порог памяти — излишек уходит во временные файлы и удаляется после хендлера. НЕ лимит размера: гигабайт сохранится с диска. Жёсткий лимит — http.MaxBytesReader в звене и ответ 413.
- **Проверка типа файла** — Filename и Content-Type части — слова клиента. Настоящий тип — http.DetectContentType по первым 512 байтам содержимого (магические байты: \xFF\xD8\xFF у JPEG, \x89PNG у PNG). После чтения головы — Seek(0) перед копированием. Расширение — вторая линия сверки.

> **Прогоните загрузку curl-ом.** Соберите buildUpload и проверьте весь маршрут: curl -F "avatar=@cat.jpg" localhost:8080/upload/avatar — 201 с saved и size; curl -F "avatar=@cat.jpg;filename=../../evil.txt" ... — с filepath.Base путь не вылезает за uploads, без него — уезжает в ../; файл больше лимита ловит 413 от звена; JSON вместо multipart — 400 с ErrNotMultipart; Ctrl+C посреди передачи — ошибка разбора и аккуратный BAD_REQUEST. Сохранённое отдайте r.Static("/uploads", "./uploads") из прошлого урока и откройте в браузере. В браузерной песочнице сайта сетевой код не исполняется, поэтому runner-ов в курсе нет — интерактивы здесь quiz, predict, fill, order и флешкарты, а примеры живут в локальном go run, как и весь [курс gin](/courses/gin/). Дальше — 14-cookie-sessii: cookie и сессии, SetCookie и sessions middleware — сервер уже принимает файлы, пора узнавать пользователя между запросами.

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

- Загрузка — это 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") и убедитесь, что файл открывается в браузере.

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

### Почему 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 или прямые ссылки хранилища.
