Go: с нуля до своего блога Урок 23 из 50
Формы в Go: принять то, что прислал читатель
Двадцать третий урок курса по Go. Данные впервые идут в обратную сторону — от читателя к серверу. Метод и имена полей, r.FormValue, почему проверка в браузере не защита, и почему обработчик, который что-то изменил, отвечает редиректом, а не страницей: иначе F5 создаст вторую статью.
Зачем это нужно
Двадцать два урока читатель только получал. Всё, что он мог сделать с сайтом, — открыть адрес.
Форма — первое место, где данные идут в обратную сторону. И первое место, где кончается доверие: всё, что пришло снаружи, может быть чем угодно, и проверять это придётся вам.
Сразу целиком
Новая папка, go mod init sabaq21. Внутри — папка templates и в ней list.html:
<!doctype html>
<html lang="ru">
<head><meta charset="utf-8"><title>{{ .Title }}</title></head>
<body>
<h1>{{ .Title }}</h1>
<form method="post" action="/add">
<label for="title">Заголовок статьи</label>
<input id="title" name="title" value="{{ .Draft }}" required maxlength="80">
<button type="submit">Добавить</button>
</form>
{{ if .Err }}<p class="err">{{ .Err }}</p>{{ end }}
<ul>
{{ range .Articles }}<li>{{ .Title }}</li>{{ end }}
</ul>
</body>
</html>
Рядом main.go:
package main
import (
"bytes"
"embed"
"html/template"
"log"
"net/http"
"strings"
"unicode/utf8"
)
//go:embed templates/*.html
var files embed.FS
type Article struct{ Title string }
type page struct {
Title string
Articles []Article
Draft string
Err string
}
var (
tpl = template.Must(template.ParseFS(files, "templates/*.html"))
blog = []Article{{Title: "О степи"}}
)
func show(w http.ResponseWriter, code int, p page) {
p.Title = "Мой блог"
p.Articles = blog
// Собираем в буфер и только потом отдаём — правило из урока про шаблоны.
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, "list.html", p); err != nil {
log.Println("шаблон:", err)
http.Error(w, "ошибка на сервере", http.StatusInternalServerError)
return
}
w.WriteHeader(code)
if _, err := buf.WriteTo(w); err != nil {
log.Println("шаблон:", err)
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
show(w, http.StatusOK, page{})
})
mux.HandleFunc("POST /add", func(w http.ResponseWriter, r *http.Request) {
title := strings.TrimSpace(r.FormValue("title"))
switch {
case title == "":
show(w, http.StatusBadRequest, page{Err: "заголовок пустой"})
return
case utf8.RuneCountInString(title) > 80:
show(w, http.StatusBadRequest, page{Draft: title, Err: "заголовок слишком длинный"})
return
}
blog = append(blog, Article{Title: title})
http.Redirect(w, r, "/", http.StatusSeeOther)
})
log.Println("http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
go run ., откройте http://localhost:8080, впишите заголовок и нажмите «Добавить». Статья появилась в списке, а адрес в строке браузера остался прежним — /, не /add. Почему так, разберём ниже; сначала это стоит увидеть.
Разбор
Форма — это метод, адрес и имена полей
<form method="post" action="/add">
<input name="title">
Три вещи, и все три обязательны для понимания.
action — куда уйдёт запрос. method — каким способом. name — ключ, по которому сервер найдёт значение. Последнее важнее, чем кажется: поле без name браузер не отправляет вовсе. Не «отправляет пустым» — не отправляет. Это самая частая причина того, что форма «не работает», а в обработчике всё правильно.
id и label for к отправке отношения не имеют. Они нужны, чтобы человек мог нажать на подпись и попасть в поле, а программа чтения экрана — прочитать, что это за поле.
GET показывает, POST меняет
Правило не про Go, а про HTTP.
GET — просьба показать. Её можно повторять сколько угодно: браузер повторит её сам при обновлении страницы, поисковик откроет её без спроса, «назад» вернёт её без вопросов. Ничего не должно измениться.
POST — просьба изменить: сохранить, удалить, оплатить.
Отсюда правило, которое стоит запомнить раньше, чем понадобится: ссылка «удалить статью», работающая через GET, — ошибка. Её откроет любой обходчик, который просто ходит по ссылкам вашего сайта, и статьи начнут исчезать сами.
Метод стоит прямо в маршруте, как в уроке о маршрутах:
mux.HandleFunc("POST /add", …)
Открыть /add в браузере теперь нельзя: это GET, обработчика для него нет, и ServeMux сам ответит 405 Method Not Allowed.
r.FormValue возвращает строку — и только строку
title := strings.TrimSpace(r.FormValue("title"))
FormValue разбирает тело запроса и достаёт значение по имени. Отдельно вызывать r.ParseForm() не нужно — FormValue делает это сам.
Три его свойства стоит знать заранее:
Он возвращает строку. Не число, не дату — строку. Всё остальное вы получите сами, strconv.Atoi и его родня.
Он возвращает одно значение, первое, даже если полей с таким именем несколько.
Пустое поле и отсутствующее поле для него неотличимы: и там и там "". Если разница важна — берите r.ParseForm() и r.PostForm.Has("title").
TrimSpace здесь не украшение. Строка из трёх пробелов не пустая, но заголовок из неё пустой.
Проверять надо на сервере, всегда
В форме есть required и maxlength. Браузер по ним не даст отправить пустое поле. Это удобство для человека — и только.
Запрос может прийти не из браузера:
curl -i -X POST -d "title= " http://localhost:8080/add
Здесь нет ни required, ни maxlength. Есть только ваш обработчик:
HTTP/1.1 400 Bad Request
Образ. Табличка «просьба не входить» и замок на двери — не одно и то же. Проверка в браузере — табличка: вежливая, полезная, и её видит только тот, кто и так собирался войти через дверь.
Длину считаем рунами, а не байтами:
utf8.RuneCountInString(title) > 80
Это правило из урока о строках: len вернёт байты, и заголовок из сорока казахских или русских букв внезапно окажется «слишком длинным».
POST → редирект → GET
Самое важное в уроке.
Представьте, что обработчик /add после сохранения просто нарисовал бы страницу. Читатель видит список, доволен — и нажимает F5. Браузер спрашивает: «Отправить данные повторно?» — и отправляет. Появляется вторая такая же статья. Третья. У страницы по адресу /add нет способа обновиться, не отправив форму ещё раз, потому что она и есть ответ на отправку формы.
Лечится одной строкой:
http.Redirect(w, r, "/", http.StatusSeeOther)
Обработчик, который что-то изменил, не рисует страницу. Он отправляет браузер на ту, которая рисует. Браузер идёт по / уже методом GET, и в адресной строке остаётся /. Теперь F5 — это обычный GET, и повторять нечего.
Код 303 See Other означает буквально: «смотри по этому адресу, методом GET». Не 301 (тот про переезд навсегда, и браузер его запомнит) и не 302 (тот исторически неоднозначен в части метода).
Образ. Чек в магазине. Заплатили один раз, а посмотреть чек можно сколько угодно, и от разглядывания деньги со счёта больше не спишутся.
Ошибка — это тоже ответ
show(w, http.StatusBadRequest, page{Draft: title, Err: "заголовок слишком длинный"})
Две вещи сразу.
Первая: 400, а не 200. Запрос был неправильный, и ответ обязан это сказать — не только человеку словами на странице, но и коду.
Вторая: Draft: title. В шаблоне это value="{{ .Draft }}" — поле возвращается заполненным. Человек написал восемьдесят пять букв; заставить его набрать их заново — грубость, а не проверка.
А защита от чужого кода уже работает
Впишите в форму:
<script>alert(1)</script>
На странице появится текст. Именно текст:
<li><script>alert(1)</script></li>
Это html/template из прошлого урока. Разница в том, что до сегодняшнего дня данные приходили от вас, а теперь — от постороннего человека, и защита понадобилась по-настоящему.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему
requiredв HTML не заменяет проверку в обработчике? - Что случится, если обработчик
/addнарисует страницу сам, а читатель нажмёт F5? - Читатель отправил форму, не заполнив поле. Как это выглядит для
r.FormValue("title")?
Задание
Обязательное. Добавьте в форму второе поле — текст статьи, <textarea name="body">. Проверьте на сервере, что оно не пустое и не длиннее 5000 рун. При ошибке верните обе строки, которые ввёл человек, а не только одну. Покажите текст статьи в списке под заголовком.
По желанию.
- Отправьте форму через
curlбез поляtitleсовсем и посмотрите, чем это отличается от пустого поля. - Замените
303на обычный вывод страницы, добавьте статью и нажмите F5. Прочитайте, что спросит браузер. - Поставьте в форму два поля
name="title"с разными значениями и узнайте, какое вернётr.FormValue.
Куда это встанет в блоге
Блог научился принимать. Дальше — статика: CSS, шрифты, картинки, чтобы сайт выглядел, а не только работал.
Три долга, которые мы взяли сегодня и вернём позже.
blog — обычная переменная, и меняют её обработчики, а они выполняются одновременно. Два читателя, нажавшие «Добавить» в одну секунду, — это гонка. Она в уроке о параллельности, там же go run -race.
Срез живёт в памяти. Перезапустили сервер — статей нет. База данных ждёт в четвёртом модуле.
И главное: чужая страница может отправить форму на ваш адрес от имени вашего же читателя. Это называется CSRF, и от него защищаются отдельно. Урок о безопасности идёт после форм не случайно — сначала надо было завести то, что можно взломать.
Ответы
Показать ответы
- Потому что
requiredживёт в браузере, а запрос может прийти откуда угодно: изcurl, из чужой программы, из формы на постороннем сайте. Проверка в браузере помогает человеку не ошибиться; от неправильных данных защищает только обработчик. - Появится вторая такая же статья. Страница по адресу
/add— это ответ на отправку формы, и обновить её можно, только отправив форму ещё раз. Поэтому обработчик, который изменил данные, отвечает303и отправляет браузер на страницу, которую можно обновлять сколько угодно. - Он вернёт пустую строку — ровно то же, что и при отсутствующем поле; отличить одно от другого
FormValueне позволяет. Если разница нужна, вызовитеr.ParseForm()и спроситеr.PostForm.Has("title").
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.