Содержание урока · 15 разделов
- fiber загрузка файлов: два Content-Type одного POST
- application/x-www-form-urlencoded: bind.go и тег form
- Точечный доступ: FormValue, FormFile, MultipartForm
- multipart fiber: анатомия составного тела
- Несколько файлов: срез FileHeader и MultipartForm
- SaveFile и SaveFileToStorage: точные сигнатуры
- BodyLimit: почему файл не может быть больше четырёх мегабайт
- Безопасность: имя, расширение, содержимое
- Форма и валидация: мост из десятого урока
- Карта методов: чем читать и писать формы
- Ловушки загрузки: событие, симптом, лечение
- Запуск, пять сценариев curl и что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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-хендлер
GOpackage 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 |
Допишите безопасный аплоад
Вставьте пропуски: тип поля для файлов, метод разбора формы, чистку имени и метод записи на диск.
Соберите путь multipart-запроса
Расставьте шаги запроса с вложением по порядку: от команды в терминале до JSON-ответа со списком файлов.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Правило на будущее: всё, что можно измерить тегом — required, min, oneof, — делегируйте validateStruct; всё, что требует линейки, — размеры, количество, содержимое — проверяйте кодом сразу после Bind. Поле *multipart.FileHeader валидатору подчиняется наполовину: required на nil-указателе сработает, а «не больше двух мегабайт» и «это правда png» в словаре validate не живут. Держите ручные проверки файлов одним блоком в начале хендлера — они читаются как чек-лист, и забытый пункт видно сразу.
Ловушки загрузки: событие, симптом, лечение#
| Событие | Что увидите | Лечение |
|---|---|---|
| тело больше BodyLimit | 413 Request Entity Too Large до хендлера | поднять BodyLimit в конфиге или ужать файлы формы |
| nil-заголовок в SaveFile | ErrFileHeaderNil: file: file header is nil | проверять err от FormFile и len(in.Files) до записи |
| файл крупнее лимита в SaveFileToStorage | ErrFileRead, обёрнутый вокруг fasthttp.ErrBodyTooLarge | сравнивать fh.Size с лимитом до чтения |
| Filename с ../../ в пути сохранения | запись вне каталога вложений — молча | filepath.Base или своё имя: путь строит сервер |
| .exe с расширением .txt и Content-Type image/png | SaveFile запишет байты как есть | http.DetectContentType по первым 512 байтам |
Словарь урока
Запуск, пять сценариев 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.
Личные заметкиПусто
Вопросы и ответы по уроку
Послал файл 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. Каждый слой дешёвый, и вместе они закрывают типовые атаки на загрузку.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Gin: самый популярный веб-фреймворк Go
Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.
- Уверенно владеть gin.Engine: маршруты, группы, версии
- Привязывать запросы к структурам (ShouldBindJSON и др.)
- Писать middleware и использовать встроенные
- Строить аутентификацию на JWT