Shanraq.org Shanraq.org
Формы в Go: принять то, что прислал читатель
IT

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>&lt;script&gt;alert(1)&lt;/script&gt;</li>

Это html/template из прошлого урока. Разница в том, что до сегодняшнего дня данные приходили от вас, а теперь — от постороннего человека, и защита понадобилась по-настоящему.

Карта урока

Карта урока: читатель отправляет данные, обработчик отвечает редиректом

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему required в HTML не заменяет проверку в обработчике?
  2. Что случится, если обработчик /add нарисует страницу сам, а читатель нажмёт F5?
  3. Читатель отправил форму, не заполнив поле. Как это выглядит для r.FormValue("title")?

Задание

Обязательное. Добавьте в форму второе поле — текст статьи, <textarea name="body">. Проверьте на сервере, что оно не пустое и не длиннее 5000 рун. При ошибке верните обе строки, которые ввёл человек, а не только одну. Покажите текст статьи в списке под заголовком.

По желанию.

  • Отправьте форму через curl без поля title совсем и посмотрите, чем это отличается от пустого поля.
  • Замените 303 на обычный вывод страницы, добавьте статью и нажмите F5. Прочитайте, что спросит браузер.
  • Поставьте в форму два поля name="title" с разными значениями и узнайте, какое вернёт r.FormValue.

Куда это встанет в блоге

Блог научился принимать. Дальше — статика: CSS, шрифты, картинки, чтобы сайт выглядел, а не только работал.

Три долга, которые мы взяли сегодня и вернём позже.

blog — обычная переменная, и меняют её обработчики, а они выполняются одновременно. Два читателя, нажавшие «Добавить» в одну секунду, — это гонка. Она в уроке о параллельности, там же go run -race.

Срез живёт в памяти. Перезапустили сервер — статей нет. База данных ждёт в четвёртом модуле.

И главное: чужая страница может отправить форму на ваш адрес от имени вашего же читателя. Это называется CSRF, и от него защищаются отдельно. Урок о безопасности идёт после форм не случайно — сначала надо было завести то, что можно взломать.

Ответы

Показать ответы
  1. Потому что required живёт в браузере, а запрос может прийти откуда угодно: из curl, из чужой программы, из формы на постороннем сайте. Проверка в браузере помогает человеку не ошибиться; от неправильных данных защищает только обработчик.
  2. Появится вторая такая же статья. Страница по адресу /add — это ответ на отправку формы, и обновить её можно, только отправив форму ещё раз. Поэтому обработчик, который изменил данные, отвечает 303 и отправляет браузер на страницу, которую можно обновлять сколько угодно.
  3. Он вернёт пустую строку — ровно то же, что и при отсутствующем поле; отличить одно от другого FormValue не позволяет. Если разница нужна, вызовите r.ParseForm() и спросите r.PostForm.Has("title").

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.