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

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

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

Урок 11 из 25 ~20 мин +50 XP

Формы и загрузка файлов

Тема «fiber загрузка файлов» забирает то, что десятый урок оставил на потом: там validateStruct проверял поля формы «бесплатно», а файлам обещали собственные проверки размера и типа. Сегодня договорим до конца. Bind().Form разбирает и application/x-www-form-urlencoded, и multipart/form-data — по одним и тем же тегам form; тройка FormValue, FormFile и MultipartForm даёт точечный доступ; SaveFile и SaveFileToStorage записывают файл, а BodyLimit ограничивает и разбор, и запись. Всё сверено с исходниками тега v3.5.0: bind.go, binder/form.go, ctx.go, req.go и app.go. Канон курса не меняется: API на :8080, группа /api со стражем requireAPIKey("s3cret") из седьмого урока, валидатор из десятого, и к нему — вложения к Task.

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

fiber загрузка файлов: два Content-Type одного POST#

HTML-форма — второй после JSON способ раздать данные серверу, и у POST-формы два режима тела. По умолчанию браузер шлёт application/x-www-form-urlencoded: тот самый формат имя=значение&имя=значение, который вы видели в query-строке четвёртого урока. Но стоит дописать в тег form атрибут enctype="multipart/form-data" — и то же самое превратится в составное тело: части склеены границей-boundary, каждое поле — свой фрагмент со своими заголовками. Именно в multipart живёт загрузка файлов: байты документа нельзя честно вложить в строку параметров, а в отдельную часть — легко. Выбор кодировки делает браузер, разгребать её — сервер, и сегодняшняя тема «fiber загрузка файлов» — про обе кодировки разом.

Терминальные двойники уже у вас в руках: curl -d шлёт urlencoded (и молча ставит этот Content-Type, если вы забыли заголовок — ловушка девятого урока, на которой построен predict десятого), а curl -F собирает multipart с boundary сам, без единого ручного заголовка. У Fiber оба формата разгребает один и тот же форм-биндер, поэтому мы пойдём по исходникам один раз, а применять будем двумя способами: текстовые поля формы — тегами и валидатором из десятого урока, файлы — вручную, потому что у валидатора нет линейки для размера.

application/x-www-form-urlencoded: bind.go и тег form#

Сверимся с исходниками тега v3.5.0. В bind.go метод Form задокументирован дословно так: Form binds the form into the struct, map[string]string and map[string][]string. If Content-Type is "application/x-www-form-urlencoded" or "multipart/form-data", it will bind the form values. Multipart file fields are supported using multipart.FileHeader, []multipart.FileHeader, or []multipart.FileHeader — четыре факта в одном абзаце: разбирает форму в структуру или карту, берёт оба Content-Type, файлы кладёт в поля-FileHeader, а ошибки возвращает по уже знакомой схеме BindError на разборе и Error со статусом 400 в авторежиме. В конце метода стоит validateStruct — та самая, из десятого урока: правила полей формы исполняются сами.

Заглянем и глубже. Bind().Body из девятого урока в switch по Content-Type держит строку case MIMEApplicationForm, MIMEMultipartForm: return b.Form(out) — оба формата заведены на один метод. Внутри binder/form.go парсер различает их по регистру: EqualFold сравнивает заголовок с multipart/form-data — и если совпало, уходит в bindMultipart, о котором ниже; иначе идёт цикл по req.PostArgs().All() — пары urlencoded собираются в карту и прогоняются через parse. Имя словаря — «form»: у полей структуры тег form, а не json. Есть и свежая опция конфига EnableSplittingOnParsers (app.go, Optional: false): если включить, значения через запятую расщепляются в срезы — для форм это поведение включается той же настройкой, что для query и заголовков.

Форма создания задачи: Bind().Form по тегам form

GO
// TaskForm — поля HTML-формы: тег form, а не json
type TaskForm struct {
        Title    string `form:"title"    validate:"required,min=3,max=100"`
        Priority string `form:"priority" validate:"omitempty,oneof=low medium high"`
}

// POST /api/tasks/form — создание задачи из формы без JSON
api.Post("/tasks/form", func(c fiber.Ctx) error {
        var in TaskForm
        // один вызов разбирает оба Content-Type: urlencoded и multipart
        if err := c.Bind().Form(&in); err != nil {
                // ветки ошибок — те же, что в десятом уроке
                return err
        }
        nextID++
        task := Task{ID: nextID, Title: in.Title, Priority: in.Priority,
                CreatedAt: time.Now().Format(time.RFC3339)}
        tasks = append(tasks, task)
        return c.Status(fiber.StatusCreated).JSON(task)
})

// Проверка:
// curl -X POST localhost:8080/api/tasks/form \
//   -H "X-API-Key: s3cret" -d "title=позвонить клиенту&priority=high"

Три строки логики — и форма жива. Bind().Form читает тело, раскладывает поля по тегам form и зовёт validateStruct: адаптер из десятого урока обязан сидеть в fiber.Config, иначе правила required,min=3,oneof останутся декларацией. curl -d без заголовка — ровно та ветка, которую разобрал predict десятого урока: urlencoded под капотом, форм-биндер на разборе, валидатор после него. Битый title вернёт 400 с {errors:[{field,rule,param}]} — форма ошибок из десятого урока работает для форм без единой правки хендлера.

Проверьте себя: FormValue и лимит тела

Приложение поднято с fiber.Config{BodyLimit: 16 * 1024 * 1024}. Прилетел multipart-запрос на 10 МиБ, хендлер вызвал c.FormValue("note"). Что происходит?

Точечный доступ: FormValue, FormFile, MultipartForm#

Когда структура под форму избыточна, у Ctx есть семья точечных методов — в v3.5.0 они объявлены на Req, а Ctx отдаёт их как свои (в тестах исходников это c.FormValue("name")). FormValue(key string, defaultValue ...string) string возвращает первое значение по ключу и — цитата документации — Defaults to the empty string "" if the form value doesn't exist: отсутствие ключа не ошибка, а пустая строка или ваш дефолт. FormFile(key string) (multipart.FileHeader, error) достаёт файл: docs формулируют — the first file from the given key is returned, первый файл по ключу; ошибка вернётся, если multipart не разобрался. MultipartForm() (multipart.Form, error) отдаёт форму целиком — Value для текстовых полей и File для файлов, к которым вернёмся ниже; parse-ing ограничен BodyLimit, docs: Parsing is bounded by the app BodyLimit.

Два честных штриха из сверки. Первый: FormValueAny из v2 в v3 не переехал — точных типов больше нет, для них существует Bind в структуру. Второй: у всех трёх методов есть общее побочное действие — на форм-запросе они нормализуют Content-Type (приводят регистр multipart/form-data и boundary= к каноническому виду, иначе fasthttp с его чувствительностью к регистру не найдёт границу). Заголовок переписывается в байтах запроса: если вам нужен исходный Content-Type — скопируйте его до первого FormValue, алертом об этом служит caution в документации.

multipart fiber: анатомия составного тела#

Multipart-запрос по запросу «multipart fiber» лежит в тех же исходниках, что и всё остальное: binder/form.go, метод bindMultipart. Схема тела проста: части отрезаны друг от друга границей boundary, случайной строкой из заголовка Content-Type; каждая часть несёт заголовки Content-Disposition (имя поля и, для файлов, имя файла) и Content-Type части. Браузер проставляет всё это из enctype="multipart/form-data", curl -F — сам; руками boundary не собирают никогда.

Что делает Fiber. bindMultipart зовёт req.MultipartFormWithLimit(b.MaxBodySize) — и это место, где конфиг встречается с парсером: в bind.go строка bind.MaxBodySize = b.ctx.App().config.BodyLimit присваивает лимит телу биндеру. Дальше карта раскладывается на два словаря: multipartForm.Value — текстовые поля, multipartForm.File — файлы, map[string][]*multipart.FileHeader; оба прогоняются через formatBindData и один общий parse. Итого форм-биндер не различает «форму с файлами» и «форму без файлов»: один вход Bind().Form, один parse, один validateStruct — различие только в том, есть ли в multipartForm.File хоть одна часть с filename. Проверить, что вы имеете дело с multipart, не разбирая тело, можно методом IsMultipart() — docs: Reports whether the Content-Type header is multipart form data.

Один файл: *multipart.FileHeader и SaveFile

GO
// Attach — поля multipart-формы: строка и один файл
type Attach struct {
        Note   string               `form:"note"`
        Avatar *multipart.FileHeader `form:"avatar"`
}

// POST /api/tasks/preview — черновик: сохраняем аватар как есть
api.Post("/tasks/preview", func(c fiber.Ctx) error {
        var in Attach
        // поля и файл разбираются одним вызовом
        if err := c.Bind().Form(&in); err != nil {
                return err
        }
        // пока наивно: имя клиента идёт в путь как есть — см. ниже
        dst := path.Join("avatars", in.Avatar.Filename)
        // SaveFile пишет multipart-файл на диск
        if err := c.SaveFile(in.Avatar, dst); err != nil {
                return err
        }
        return c.Status(fiber.StatusCreated).JSON(fiber.Map{
                "note": in.Note,
                "file": in.Avatar.Filename, // имя, присланное клиентом
                "size": in.Avatar.Size,     // размер в байтах
        })
})

// Проверка:
// curl -X POST localhost:8080/api/tasks/preview -H "X-API-Key: s3cret" \
//   -F "note=логотип" -F "avatar=@logo.png"

Это почти дословный пример из документации Fiber: структура Person с Avatar *multipart.FileHeader form:"avatar" и curl -F name=john -F pass=doe -F 'avatar=@filename'. Срезы не нужны, когда поле одно: скалярный указатель получает первый файл по ключу. Три поля FileHeader используются в ответе: Filename — имя из части multipart (его прислал клиент), Size — размер в байтах, Header — заголовки части; FileHeader — это стандартный тип net/http/mime/multipart, Fiber не заводит своего. Обратите внимание на dst: path.Join склеил каталог с клиентским именем — код нарочно наивен, через два блока покажем, чем это чревато. Эквивалент без структуры: file, err := c.FormFile("avatar").

Проверьте себя: чей это Content-Type

В хендлере загруженного файла вы видите fh.Header["Content-Type"][0] == "image/png" и расширение .png. Можно ли сохранять файл, доверяя этим двум фактам?

Несколько файлов: срез FileHeader и MultipartForm#

Для нескольких файлов под одним именем поля документация закрепляет ровно три варианта типа поля: multipart.FileHeader — первый файл, []multipart.FileHeader — все файлы, []multipart.FileHeader — то же с указателем. На стороне формы полю ставится атрибут multiple, а curl повторяет -F "attachments=@a.pdf" -F "attachments=@b.pdf" — два файла под одним ключом. В структуру они ложатся как Attachments []*multipart.FileHeader с тегом form:"attachments": парсинг всё тот же, файлы приходят срезом.

Второй путь — без структуры: через c.MultipartForm() достать *multipart.Form и взять form.File["attachments"] — docs показывают ровно этот шаблон в примере: files := form.File["documents"], затем цикл с fmt.Println(file.Filename, file.Size, file.Header["Content-Type"][0]) и c.SaveFile(file, ...) на каждой итерации. Здесь нужна одна честная поправка из сверки исходников: метода c.File() в v3 нет — файлы достаются либо FormFile (первый), либо MultipartForm (все), либо Bind().Form в срез; не ищите «итератор файлов» и в методе Next() — тот относится к цепочке middleware из шестого урока, к файлам отношения не имеет. Если план урока или старая статья обещают c.File — это след из другой версии, в теге v3.5.0 такого метода на Ctx нет.

Два файла, поле-скаляр: что сохранится?

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

// POST /api/tasks/preview: Avatar — указатель на ОДИН файл
api.Post("/tasks/preview", func(c fiber.Ctx) error {
        var in Attach
        if err := c.Bind().Form(&in); err != nil {
                return err
        }
        // сохраняем ровно то, что лежит в поле
        if err := c.SaveFile(in.Avatar,
                "avatars/"+filepath.Base(in.Avatar.Filename)); err != nil {
                return err
        }
        return c.Status(fiber.StatusCreated).JSON(
                fiber.Map{"saved": in.Avatar.Filename})
})

// Запрос:
// curl -X POST localhost:8080/api/tasks/preview -H "X-API-Key: s3cret" \
//   -F "note=сканы" -F "avatar=@a.png" -F "avatar=@b.png"

SaveFile и SaveFileToStorage: точные сигнатуры#

Пара методов записи на Ctx — с ними сверимся построчно. Первая: SaveFile(fh multipart.FileHeader, path string) error — docs: Method is used to save any multipart file to disk. Внутри ctx.go две строки: nil-заголовок даёт ErrFileHeaderNil — errors.New("file: file header is nil") из error.go, иначе вызов fasthttp.SaveMultipartFile(fileheader, path): файл создаётся по точному пути, какой вы передали. Вторая: SaveFileToStorage(fileheader multipart.FileHeader, path string, storage Storage) error — Method is used to save any multipart file to an external storage system. Здесь файл читается в память с ограничением: maxUploadSize берётся из c.app.config.BodyLimit, fileheader.Size проверяется заранее, затем io.LimitReader(file, int64(maxUploadSize)+1) — и если буфер оказался больше лимита, возвращается ErrFileRead, обёрнутый вокруг fasthttp.ErrBodyTooLarge, вместе с именем файла через fmt.Errorf("%w: %q: %w", ...). Запись выполняет storage.SetWithContext(c.Context(), path, data, 0) — Storage из Fiber: в примере документации это memory.New(), но интерфейс реализуют и S3-адаптеры.

И одна поправка, ради которой стоило перечитать исходники: метода SaveFileToDisk в v3 нет — это имя из v2, в теге v3.5.0 пара выглядит как SaveFile (диск) и SaveFileToStorage (внешнее хранилище). Если встретите SaveFileToDisk в чужом коде Fiber — перед вами либо v2, либо опечатка; компилятор v3 его не знает. Обе функции возвращают ошибки уже обёрнутыми: ErrFileOpen при неудачном открытии, ErrFileRead при неудачном чтении, ErrFileStore при провале хранилища — все с именем файла внутри, все распаковываются errors.Is.

BodyLimit: почему файл не может быть больше четырёх мегабайт#

fiber.Config.BodyLimit (app.go) описан так: Max body size that the server accepts. Zero or negative values fall back to the default limit — и ниже Default: 4 1024 1024; константа DefaultBodyLimit = 4 1024 1024 живёт там же. Документация договаривает последствия: If the size exceeds the configured limit, it sends 413 - Request Entity Too Large response, и перечисляет, где именно лимит действует — в том числе when parsing multipart form data via Ctx.MultipartForm(). В app.go за это отвечает строка errors.Is(err, fasthttp.ErrBodyTooLarge) → ErrRequestEntityTooLarge — превышение тела превращается в готовую *Error со статусом 413 ещё до вашего хендлера.

Для уроков про файлы из этого следуют три вывода. Первый: лимит общий на ЗАПРОС, а не на файл — три вложения по два мегабайта уложатся в 6 МиБ и упрутся в дефолт; считайте бюджет формы перед тем, как поднимать цифру. Второй: подняли BodyLimit — выросли и буферы разбора; 16 МиБ на хендлер задач расточительно, для вложений честнее валидировать fh.Size каждого файла и держать общий лимит умеренным. Третий: у SaveFileToStorage лимит тот же — он читает файл в память и упирается в тот же maxUploadSize, так что «напишу в хранилище, минуя лимит» не выйдет: либо конфиг, либо уменьшение файлов.

Проверьте себя: дефолтный лимит

fiber.New() создан без конфига. Клиент отправляет multipart с файлом на 5 МиБ. Что получит клиент?

Безопасность: имя, расширение, содержимое#

Загрузка файлов — крупнейшая поверхность атаки из всех тем курса, и все три защиты стоят копейки. Первая — имя. Filename приходит из Content-Disposition части, и клиент волен прислать там ../../etc/hosts: наивный конкатенированный путь уведёт запись выше каталога. Лечение — filepath.Base: функция отрезает все каталоги и оставляет базовое имя; парой к ней идут filepath.Ext и strings.ToLower для расширения. Вторая — расширение из белого списка: map[string]bool с разрешёнными значениями, всё вне списка — отказ. Не строите чёрные списки запрещённых расширений: список запретов всегда короче списка бед.

Третья — содержимое. fh.Header["Content-Type"] — заявленный тип, не проверенный никем; настоящий тип узнаёт http.DetectContentType(head[:n]) по первым 512 байтам — стандартный сниффер mime-таблицы. Порядок проверок в хендлере важен: размер (fh.Size) — до чтения, расширение — до чтения содержимого, сниффинг — последним, когда дешёвые проверки уже отсеяли мусор. Две дисциплины напоследок: храните файлы под СВОИМИ именами (префикс с временем или счётчиком — коллизии и повторные атаки на одно имя исчезают), каталог создавайте os.MkdirAll заранее, и тогда SaveFile не упрётся в отсутствующий путь.

Вложения к Task: безопасный multipart-хендлер

GO
package main

import (
        "fmt"
        "mime/multipart"
        "net/http"
        "os"
        "path"
        "path/filepath"
        "strings"
        "time"

        "github.com/gofiber/fiber/v3"
)

// extAllow — белый список расширений вложений
var extAllow = map[string]bool{".png": true, ".jpg": true, ".pdf": true, ".txt": true}

// maxAttach — лимит одного вложения: 2 МБ
const maxAttach = 2 << 20

// AttachForm — поля multipart-формы: строка и срез файлов
type AttachForm struct {
        Note  string                  `form:"note" validate:"omitempty,max=200"`
        Files []*multipart.FileHeader `form:"attachments"`
}

// POST /api/tasks/attachments — прикрепить вложения к задаче
api.Post("/tasks/attachments", func(c fiber.Ctx) error {
        var in AttachForm
        // note проверит валидатор из десятого урока, файлы — нет
        if err := c.Bind().Form(&in); err != nil {
                return err
        }
        // количество файлов — ручная проверка: от 1 до 5
        if len(in.Files) == 0 || len(in.Files) > 5 {
                return fiber.NewError(fiber.StatusBadRequest,
                        "нужно от одного до пяти файлов")
        }
        // каталог вложений должен существовать до первой записи
        os.MkdirAll("uploads", 0o755)

        saved := make([]fiber.Map, 0, len(in.Files))
        for i, fh := range in.Files {
                // размер до всякого чтения: файл больше лимита — отказ
                if fh.Size > maxAttach {
                        return fiber.NewError(fiber.StatusRequestEntityTooLarge,
                                "вложение больше 2 МБ")
                }
                // имя клиента чистим до базового: ../ не пройдёт
                base := filepath.Base(fh.Filename)
                ext := strings.ToLower(filepath.Ext(base))
                // расширение — только из белого списка
                if !extAllow[ext] {
                        return fiber.NewError(fiber.StatusUnsupportedMediaType,
                                "тип файла не поддерживается")
                }
                // читаем голову и сверяем содержимое с заявленным типом
                f, err := fh.Open()
                if err != nil {
                        return err
                }
                head := make([]byte, 512)
                n, _ := f.Read(head)
                f.Close()
                // сниффер смотрит байты, а не слова клиента
                m := http.DetectContentType(head[:n])
                if !strings.HasPrefix(m, "image/") &&
                        m != "application/pdf" && m != "text/plain; charset=utf-8" {
                        return fiber.NewError(fiber.StatusUnsupportedMediaType,
                                "содержимое не совпало с расширением")
                }
                // своё имя: время и счётчик убирают коллизии и подмены
                dst := path.Join("uploads",
                        fmt.Sprintf("%d-%d%s", time.Now().UnixNano(), i, ext))
                if err := c.SaveFile(fh, dst); err != nil {
                        return err
                }
                saved = append(saved, fiber.Map{
                        "name": base, "size": fh.Size, "path": dst,
                })
        }
        return c.Status(fiber.StatusCreated).JSON(
                fiber.Map{"note": in.Note, "files": saved})
})

// Проверка:
// curl -X POST localhost:8080/api/tasks/attachments -H "X-API-Key: s3cret" \
//   -F "note=смета и скан" -F "attachments=@smeta.pdf" -F "attachments=@scan.png"

Полный цикл одной проверки: количество, размер, имя, расширение, содержимое, запись. Поля формы Note и Files разобраны одним Bind().Form, Note ещё и проверена валидатором — required/min/max на текстовых полях формы живут точно так же, как на JSON из десятого урока. Файлы проверяет только этот хендлер: у валидатора нет правила «не больше 2 МБ» и нет правила «это правда png» — размер взяли у fh.Size, тип — у http.DetectContentType. Заметьте ответ клиенту: имена сохранённых файлов перечислены явно, и base там — чистое имя клиента, а dst — ваш внутренний путь: клиенту полезно знать, как файл называется для него, но не где он лежит на диске.

Путь с ../: что сделает наивный SaveFile?

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

// Наивный вариант из черновика: имя клиента идёт в путь как есть
api.Post("/tasks/preview", func(c fiber.Ctx) error {
        fh, _ := c.FormFile("avatar")
        // SaveFile пишет по точному пути — чисткой имени не занимается
        return c.SaveFile(fh, "./avatars/"+fh.Filename)
})

// Запрос:
// curl -X POST localhost:8080/api/tasks/preview -H "X-API-Key: s3cret" \
//   -F "avatar=@notes.txt;filename=../../evil.txt"

Форма и валидация: мост из десятого урока#

Десятый урок обещал, что формы пройдут проверку бесплатно, — сверим обещание с bind.go: validateStruct стоит после Form так же, как после Body, JSON и Query. Значит, на TaskForm с validate-тегами works required,min=3,max=100 на title и oneof на priority — из curl -d, из браузерной формы, из multipart: источник разбора валидатору неважен, это и была главная мысль tip-а десятого урока, теперь подкреплённая собственными хендлерами. Проваленная проверка прилетит в err как validator.ValidationErrors, и ветка errors.As из десятого урока вернёт 400 со списком {field, rule, param} — для формы ответ будет тем же словарём, что и для JSON.

Файлы из этого контракта выпадают ровно на одну ступень. validateStruct видит поле *multipart.FileHeader как обычный указатель на структуру: required на нём сработает — nil-указатель считается пустым и правило провалится, поэтому «файл обязателен» можно выразить тегом. Но правила про размер и содержимое в словаре validator отсутствуют: линейки у валидатора нет. Размер — fh.Size и сравнение с константой, содержимое — http.DetectContentType, количество файлов — len(in.Files): всё это пишется руками в хендлере сразу после Bind, как в безопасном варианте выше. Итого мост с десятого урока достроен: поля формы — тегами, файлы — кодом, и обе проверки живут в одном хендлере рядом.

Карта методов: чем читать и писать формы#

МетодЧто делаетНюанс из исходников v3.5.0
c.Bind().Form(&in)разбирает urlencoded и multipart в структуру по тегам formпосле разбора зовёт validateStruct; ошибки — BindError / Error 400
c.FormValue(key, default...)первое значение поля; при отсутствии — defaultпоиск: QueryArgs → PostArgs → MultipartForm; multipart парсится с BodyLimit приложения
c.FormFile(key)первый файл по ключу, *multipart.FileHeaderформа парсится с BodyLimit, чтобы не съесть память
c.MultipartForm()вся форма: Value для полей, File для файловdocs: Parsing is bounded by the app BodyLimit; form.File["attachments"] — срез
c.SaveFile(fh, path)пишет multipart-файл на дискfasthttp.SaveMultipartFile; fh == nil → ErrFileHeaderNil; путь не чистится
c.SaveFileToStorage(fh, path, storage)пишет во внешнее хранилище Storageпроверка Size + LimitReader по BodyLimit; ошибки ErrFileOpen/ErrFileRead/ErrFileStore

Допишите безопасный аплоад

Вставьте пропуски: тип поля для файлов, метод разбора формы, чистку имени и метод записи на диск.

// AttachForm — multipart-форма вложений type AttachForm struct { Note string `form:"note"` Files [] `form:"attachments"` } func upload(c fiber.Ctx) error { var in AttachForm // поля и файлы разбираются одним вызовом форм-биндера if err := c.Bind().(&in); err != nil { return err } for _, fh := range in.Files { // имя клиента чистим до базового: ../ не пройдёт name := .Base(fh.Filename) // пишем файл на диск if err := c.(fh, "uploads/"+name); err != nil { return err } } return c.Status(fiber.StatusCreated).JSON( fiber.Map{"saved": len(in.Files)}) }

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

Расставьте шаги запроса с вложением по порядку: от команды в терминале до JSON-ответа со списком файлов.

Строки

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

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

Ловушки загрузки: событие, симптом, лечение#

СобытиеЧто увидитеЛечение
тело больше BodyLimit413 Request Entity Too Large до хендлераподнять BodyLimit в конфиге или ужать файлы формы
nil-заголовок в SaveFileErrFileHeaderNil: file: file header is nilпроверять err от FormFile и len(in.Files) до записи
файл крупнее лимита в SaveFileToStorageErrFileRead, обёрнутый вокруг fasthttp.ErrBodyTooLargeсравнивать fh.Size с лимитом до чтения
Filename с ../../ в пути сохранениязапись вне каталога вложений — молчаfilepath.Base или своё имя: путь строит сервер
.exe с расширением .txt и Content-Type image/pngSaveFile запишет байты как естьhttp.DetectContentType по первым 512 байтам

Словарь урока

Термин

multipart/form-data

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

Составное тело POST: части склеены boundary из заголовка Content-Type; в каждой части — свои заголовки и своё поле. Единственный честный способ переслать файл. curl -F собирает его сам; в Fiber разбирает bindMultipart с лимитом BodyLimit.

1 / 6

Запуск, пять сценариев curl и что дальше#

Соберите каркас — конфиг с валидатором из десятого урока, слои восьмого (helmet, cors, logger), страж седьмого, группа /api — и прогоните пять сценариев. Первый: форма создания задачи, curl -d "title=позвонить клиенту&priority=high" на /api/tasks/form — 201 и задача с id; validateStruct проверил поля бесплатно. Второй: короткий title, -d "title=пв" — 400 и {"errors":[{"field":"title","rule":"min","param":"3"}]}: форма ошибок десятого урока в действии. Третий: один файл, curl -F "note=логотип" -F "avatar=@logo.png" на /api/tasks/preview — 201, в ответе имя и размер из FileHeader. Четвёртый: три вложения через повторяющиеся -F "attachments=@..." на /api/tasks/attachments — 201 и список сохранённых путей; два файла под скалярным полем вернули бы один — predict выше показал почему. Пятый: подмена имени, -F "avatar=@notes.txt;filename=../../evil.txt" — в безопасном хендлере отказ по белому списку, в наивном — запись вне uploads; попутно отправьте файл на 5 МиБ и получите 413 от BodyLimit.

Материал держится на трёх опорах: валидация полей — из десятого урока, форма ошибок и Content-Type-диспетчер Bind — из девятого, страж и слои — из седьмого и восьмого; полный план — на странице курса Fiber. Отдельный привет пятому уроку: там c.SendFile отдавала файлы клиенту, а сегодня мы научились их принимать — поставьте их рядом, и цикл «загрузил — сохранил — раздал» замкнётся. Песочница сайта сетевые сценарии не запускает — runner-ов в уроках Fiber нет, интерактивы здесь quiz, predict, fill, order и флешкарты; каркас собирайте локально в терминале.

Дальше — двенадцатый урок: Cookie и сессии. Загруженные вложения захотят знать, кто их прикрепил: Set/Get cookie, session middleware и хранилища дадут запросу память о пользователе — а форма входа, кстати, будет отправляться тем самым Bind().Form из этого урока.

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

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

Вывод

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

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

  • Bind().Form разбирает и application/x-www-form-urlencoded, и multipart/form-data в структуру по тегам form (или в map); после разбора зовётся validateStruct — правила из десятого урока исполняются на полях формы бесплатно.
  • Точечный доступ: c.FormValue — первое значение с поиском QueryArgs → PostArgs → MultipartForm и defaultValue; c.FormFile — первый файл по ключу; c.MultipartForm — вся форма, парсинг ограничен BodyLimit. FormValueAny из v2 в v3 нет.
  • Файлы биндятся в *multipart.FileHeader (первый) или []*multipart.FileHeader (все); multipart.FileHeader — стандартный тип: Filename от клиента, Size в байтах, Header части. Метода c.File() в v3 нет.
  • c.SaveFile(fh, path) пишет на диск через fasthttp.SaveMultipartFile и не чистит путь; c.SaveFileToStorage(fh, path, storage) — во внешнее хранилище с проверкой Size и LimitReader; SaveFileToDisk — имя из v2, в v3 его нет.
  • BodyLimit — лимит на ЗАПРОС, дефолт 4 * 1024 * 1024; превышение — готовая *Error 413 Request Entity Too Large до хендлера; действует на разбор multipart и на SaveFileToStorage.
  • Безопасность трёхслойная: filepath.Base против ../../ в имени, белый список расширений, http.DetectContentType по первым 512 байтам против подделанного Content-Type; имя сохранения генерирует сервер.
  • Файлы валидатором не измеряются: required на nil-указателе сработает, но размер, количество и содержимое проверяются кодом в хендлере — чек-листом сразу после Bind().Form.

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

Расширьте вложения: добавьте поле TaskID в AttachForm, найдите задачу по id и верните 404, если её нет, — форма и маршрут POST /api/tasks/:id/attachments. Прогоните правило «не больше N мегабайт НА ФОРМУ целиком»: суммируйте fh.Size всех файлов и сравните с константой, отдельной от BodyLimit. Замените префикс времени на uuid-подобное имя и проверьте, что два файла с одинаковым клиентским именем не затирают друг друга. Напишите ветку, которая на errors.Is(err, fiber.ErrFileHeaderNil) отвечает 400, а на ErrFileRead с fasthttp.ErrBodyTooLarge внутри — 413, и проверьте её файлом на 9 МиБ при SaveFileToStorage. Наконец, соберите страницу-форму на c.SendFile-хостинге из пятого урока с enctype="multipart/form-data" и multiple, чтобы браузер отправлял то же, что ваш curl.

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

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

Послал файл curl -F, а Bind().Form вернул ошибку — что проверить первым?

Content-Type и структуру. Form-биндер принимает только application/x-www-form-urlencoded и multipart/form-data; curl -F ставит multipart сам, а вот curl -d без заголовка молча шлёт urlencoded — если поле файла ушло через -d, в multipartForm.File оно не попадёт. Второй кандидат — тег form: имя поля в структуре должно совпасть с именем части, и для файлов тип обязан быть *multipart.FileHeader или []*multipart.FileHeader, иначе значение потеряется.

Почему 413 приходит до моего хендлера и как дать пользователю загрузить больше?

Лимит тела держит сервер: fasthttp отказывает запросам больше fiber.Config.BodyLimit, а app.go переводит fasthttp.ErrBodyTooLarge в готовую ErrRequestEntityTooLarge — *Error со статусом 413. До Bind().Form дело не доходит, поэтому ловить нечего. Поднимите BodyLimit (учтите: дефолт 4 * 1024 * 1024, лимит общий на запрос со всеми файлами) и добавьте собственные проверки fh.Size — конфиг не знает, сколько весит один файл.

Обязательно ли проверять расширение, если уже сверил Content-Type сниффером?

Расширение — не защита, а часть контракта с пользователем: сниффер http.DetectContentType по содержимому надёжнее любых слов клиента, но пользователю удобно скачать файл с осмысленным именем, и белый список расширений отсекает мусор до чтения байтов. Практичный порядок: размер по fh.Size, чистка имени filepath.Base, белый список расширений, сниффинг последних — и только потом SaveFile. Каждый слой дешёвый, и вместе они закрывают типовые атаки на загрузку.

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

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

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

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

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

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

Gin: самый популярный веб-фреймворк Go

Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.

  • Уверенно владеть gin.Engine: маршруты, группы, версии
  • Привязывать запросы к структурам (ShouldBindJSON и др.)
  • Писать middleware и использовать встроенные
  • Строить аутентификацию на JWT