33. Request dan formulir

Sejauh ini, request hanya memilih halaman. Sekarang pengunjung mengirim data kepadamu, di alamat atau di formulir. Bab ini membaca data itu, memeriksanya, dan menjawab dengan status yang tepat. Bab ini juga menunjukkan satu kesalahan yang tidak boleh dilakukan programmer web.

Semua input tidak bisa dipercaya

Semua isi request datang dari luar programmu. Pengunjung bisa mengetik apa saja ke formulir, mengubah alamat dengan tangan, atau mengirim request tanpa browser sama sekali, seperti yang sudah kamu lakukan dengan curl.exe. Batasan di HTML-mu, seperti daftar pilihan, tidak membatasi apa yang bisa sampai.

Jadi setiap handler mengikuti langkah yang sama:

  1. Baca nilainya. Nilai itu selalu berupa string.
  2. Periksa. Kalau salah, jawab dengan status error dan return.
  3. Kerjakan tugasnya.

Ini validasi dari Bab 20, dengan kode status sebagai ganti bertanya lagi.

Query string

Bagian URL sesudah ? adalah query string. Isinya nama dan nilai:

http://localhost:8080/add?a=2&b=3

a bernilai 2, dan b bernilai 3, dengan & di antaranya. r.URL.Query() memisahkannya, dan .Get membaca satu nilai. Ini exercises/33/01-query-string.go:

// Chapter 33 example: reading values from the query string.
// Run with: go run exercises/33/01-query-string.go

package main

import (
    "fmt"
    "net/http"
    "strconv"
)

func handleHello(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    if name == "" {
        http.Error(w, "Please give a name, as in /hello?name=Keiko", http.StatusBadRequest)
        return
    }

    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    fmt.Fprintf(w, "Hello, %s!\n", name)
}

func handleAdd(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query()

    a, err := strconv.Atoi(query.Get("a"))
    if err != nil {
        http.Error(w, "a must be a whole number.", http.StatusBadRequest)
        return
    }

    b, err := strconv.Atoi(query.Get("b"))
    if err != nil {
        http.Error(w, "b must be a whole number.", http.StatusBadRequest)
        return
    }

    fmt.Fprintf(w, "%d + %d = %d\n", a, b, a+b)
}

func main() {
    http.HandleFunc("GET /hello", handleHello)
    http.HandleFunc("GET /add", handleAdd)

    fmt.Println("Server running at http://localhost:8080")
    fmt.Println("Press Ctrl+C to stop it.")

    err := http.ListenAndServe("localhost:8080", nil)
    if err != nil {
        fmt.Println("Server stopped:", err)
    }
}

Get mengembalikan string kosong untuk nama yang tidak ada, sama seperti untuk nilai kosong, jadi pemeriksaan pada name mencakup keduanya. Angka butuh strconv.Atoi dan pemeriksaan error-nya, seperti di Bab 21, dan teks yang buruk menjadi status 400. Pengunjung yang membuat kesalahan, jadi kodenya 4xx. Hasilnya:

curl.exe -i "http://localhost:8080/hello?name=Keiko"
HTTP/1.1 200 OK
Hello, Keiko!

curl.exe -i http://localhost:8080/hello
HTTP/1.1 400 Bad Request
Please give a name, as in /hello?name=Keiko

curl.exe -i "http://localhost:8080/add?a=2&b=3"
HTTP/1.1 200 OK
2 + 3 = 5

curl.exe -i "http://localhost:8080/add?a=abc&b=3"
HTTP/1.1 400 Bad Request
a must be a whole number.

Di PowerShell, beri tanda kutip ganda pada URL yang memuat &. Tanpa itu, PowerShell berhenti dengan error tentang &, bahkan sebelum curl.exe berjalan.

Spasi tidak bisa muncul begitu saja di URL, jadi ditulis sebagai %20 atau +: /hello?name=Keiko%20Ito memberikan Hello, Keiko Ito!. Get menerjemahkannya untukmu. Browser mengodekan URL untukmu ketika kamu mengetiknya.

handleHello mengatur Content-Type menjadi text/plain dengan sengaja. Namanya berasal dari pengunjung, dan ini berarti jawabannya tidak boleh dibaca sebagai HTML. Bagian tentang output yang tidak aman menjelaskan alasannya.

Jalankan exercises/33/01-query-string.go, dan buka http://localhost:8080/add?a=9223372036854775807&b=1. Jawabannya negatif. Atoi menerima kedua angka, dan jumlahnya tidak muat dalam int. Bagaimana kamu menolak angka yang terlalu besar?

Nilai di path

Pola bisa punya wildcard bernama di dalam kurung kurawal, yang cocok dengan satu bagian path. r.PathValue membacanya. Ini exercises/33/02-path-values.go, bagian handler dan di bawahnya:

func handlePlayer(w http.ResponseWriter, r *http.Request) {
    name := r.PathValue("name")

    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    fmt.Fprintf(w, "This is the page of %s.\n", name)
}

func handleSquare(w http.ResponseWriter, r *http.Request) {
    n, err := strconv.Atoi(r.PathValue("n"))
    if err != nil {
        http.Error(w, "The path must end in a whole number.", http.StatusBadRequest)
        return
    }
    if n < -1000 || n > 1000 {
        http.Error(w, "The number must be from -1000 to 1000.", http.StatusBadRequest)
        return
    }

    fmt.Fprintf(w, "%d squared is %d\n", n, n*n)
}

func main() {
    http.HandleFunc("GET /players/{name}", handlePlayer)
    http.HandleFunc("GET /square/{n}", handleSquare)

Hasilnya:

curl.exe -i http://localhost:8080/players/Keiko
HTTP/1.1 200 OK
This is the page of Keiko.

curl.exe -i http://localhost:8080/players/a/b
HTTP/1.1 404 Not Found
404 page not found

curl.exe -i http://localhost:8080/square/12
HTTP/1.1 200 OK
12 squared is 144

curl.exe -i http://localhost:8080/square/abc
HTTP/1.1 400 Bad Request
The path must end in a whole number.

curl.exe -i http://localhost:8080/square/99999
HTTP/1.1 400 Bad Request
The number must be from -1000 to 1000.

Pemeriksaan rentang menghentikan overflow yang kamu lihat di kotak Coba ini. {name} hanya cocok dengan satu bagian, jadi /players/a/b tidak cocok, begitu juga /players/.

Pakai path untuk hal yang mana yang kamu inginkan, dan query string untuk bagaimana kamu menginginkannya.

Formulir

Formulir HTML memungkinkan pengunjung mengetik nilai, dan mengubahnya menjadi request:

<form action="/message" method="post">
<p><label>Message: <input type="text" name="message"></label></p>
<p><button type="submit">Send</button></p>
</form>
  • action adalah path tujuan request.
  • method adalah get atau post.
  • name pada setiap input adalah nama nilainya. Pengunjung tidak pernah melihatnya, dan handlermu memintanya dengan nama ini.

GET atau POST

Metode mengatakan untuk apa request itu:

  • GET meminta sesuatu, dan tidak mengubah apa-apa. Formulir dengan method="get" menaruh nilainya di query string, tempat nilai itu bisa dilihat, disimpan sebagai bookmark, dan dimuat ulang. Kotak pencarian adalah contoh yang baik.
  • POST mengirim sesuatu yang mengubah hal lain, seperti menambah pesan. Nilainya ada di body request. Browser bertanya dulu sebelum mengirimnya dua kali.

Browser dan program lain boleh mengulang GET kapan saja, dan boleh mengambil tautan sebelum kamu mengkliknya. Jadi apa pun yang mengubah data harus berupa POST.

Membaca formulir

r.FormValue("message") membaca nilai dari formulir. Dia melihat ke body POST dan ke query string, jadi berlaku untuk keduanya. Seperti Get, dia mengembalikan string kosong untuk nilai yang tidak ada. Ini exercises/33/03-form-post.go:

// Chapter 33 example: a form, POST, validation, and a redirect.
// Run with: go run exercises/33/03-form-post.go

package main

import (
    "fmt"
    "net/http"
    "strings"
    "unicode/utf8"
)

const maxMessageLength = 100

const formPage = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Send a message</title>
</head>
<body>
<h1>Send a message</h1>
<form action="/message" method="post">
<p><label>Message: <input type="text" name="message"></label></p>
<p><button type="submit">Send</button></p>
</form>
</body>
</html>
`

const thanksPage = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Thank you</title>
</head>
<body>
<h1>Thank you</h1>
<p>The message arrived. Look at the terminal.</p>
<p><a href="/">Send another</a></p>
</body>
</html>
`

func handleForm(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    fmt.Fprint(w, formPage)
}

func handleMessage(w http.ResponseWriter, r *http.Request) {
    message := strings.TrimSpace(r.FormValue("message"))

    if message == "" {
        http.Error(w, "The message is empty.", http.StatusBadRequest)
        return
    }
    if utf8.RuneCountInString(message) > maxMessageLength {
        http.Error(w, "The message is too long.", http.StatusBadRequest)
        return
    }

    fmt.Printf("Received: %q\n", message)

    http.Redirect(w, r, "/thanks", http.StatusSeeOther)
}

func handleThanks(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    fmt.Fprint(w, thanksPage)
}

func main() {
    http.HandleFunc("GET /{$}", handleForm)
    http.HandleFunc("POST /message", handleMessage)
    http.HandleFunc("GET /thanks", handleThanks)

    fmt.Println("Server running at http://localhost:8080")
    fmt.Println("Press Ctrl+C to stop it.")

    err := http.ListenAndServe("localhost:8080", nil)
    if err != nil {
        fmt.Println("Server stopped:", err)
    }
}

Jalankan, dan buka http://localhost:8080. Ketik pesan dan kirim. Browser berakhir di halaman terima kasih, dan terminal menampilkan:

Received: "Hello there"

Handler itu memakai %q dengan sengaja. Teksnya berasal dari pengunjung dan mungkin memuat akhir baris, dan %q menjaganya tetap satu baris, di dalam tanda kutip.

handleMessage mengikuti ketiga langkah tadi. strings.TrimSpace lebih dulu, supaya pesan yang hanya berisi spasi dihitung kosong. Lalu dua pemeriksaan, masing-masing diakhiri return. Untuk menghitung karakter dan bukan byte, dia memakai utf8.RuneCountInString dari Bab 12. Baru sesudah itu dia mengerjakan tugasnya, yang di sini berupa satu baris pencetakan.

curl.exe -d mengirim POST dengan nilai yang kamu berikan. Inilah tiga jalur melalui handler itu. Pesan panjang di bawah adalah hasil nyata dengan 101 karakter, yang dipendekkan di sini:

curl.exe -i -d "message=Hello there" http://localhost:8080/message
HTTP/1.1 303 See Other
Location: /thanks

curl.exe -i -d "message=" http://localhost:8080/message
HTTP/1.1 400 Bad Request
The message is empty.

curl.exe -i -d "message=xxxxx...xxxxx" http://localhost:8080/message
HTTP/1.1 400 Bad Request
The message is too long.

Dan metode yang salah:

curl.exe -i http://localhost:8080/message
HTTP/1.1 405 Method Not Allowed
Allow: POST

Method Not Allowed

Go juga membatasi berapa banyak yang dibaca. r.FormValue membaca paling banyak 10 MB dari body formulir biasa. Body yang lebih besar ditolak: r.FormValue mengembalikan string kosong, jadi handler ini menjawab The message is empty. Itu cukup untuk contoh-contoh ini. Batas dalam pemeriksaanmu sendirilah yang dilihat pengunjung.

Redirect sesudah POST

POST yang berhasil tidak mengirim halaman. Dia mengirim redirect, yang menyuruh browser pergi ke tempat lain:

http.Redirect(w, r, "/thanks", http.StatusSeeOther)

Status 303, See Other, dan header Location: /thanks membuat browser mengirim GET /thanks yang baru. Mengapa tidak langsung menjawab dengan halaman terima kasih? Karena tombol muat ulang. Memuat ulang mengulang request terakhir. Kalau request terakhir adalah POST, memuat ulang mengirim pesan itu lagi. Sesudah redirect, request terakhir adalah GET yang tidak berbahaya, dan memuat ulang hanya menampilkan halaman terima kasih lagi.

Pola ini, POST lalu redirect lalu GET, dipakai pada hampir semua formulir di web. Pakai status 303, dan return kalau masih ada kode sesudah pemanggilan itu.

Di exercises/33/03-form-post.go, ganti baris http.Redirect dengan fmt.Fprint(w, thanksPage). Mulai ulang, kirim pesan, lalu muat ulang halamannya. Browser bertanya apakah formulirnya perlu dikirim lagi. Kalau kamu setuju, terminal menampilkan pesan itu untuk kedua kalinya. Kembalikan redirect-nya sesudah itu.

Lupa return

http.Error menulis response, tetapi tidak mengakhiri fungsimu. Hilangkan return dari handleHello, dan kodenya berlanjut. Request tanpa nama mendapat ini:

Please give a name, as in /hello?name=Keiko
Hello, !

Statusnya tetap 400, tetapi sisa handler berjalan dengan data yang buruk. Di handler yang menyimpan data, data itu akan tersimpan. Setiap pemeriksaan error di handler diakhiri dengan return.

Mencetak input ke HTML itu tidak aman

Inilah kesalahan yang dijanjikan bab ini. Kelihatannya tidak berbahaya. Ini exercises/33/04-unsafe-echo.go, dengan komentarnya, dan dia sengaja dibuat tidak aman:

func handleEcho(w http.ResponseWriter, r *http.Request) {
    text := r.URL.Query().Get("text")

    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    fmt.Fprintf(w, "<p>You said: %s</p>\n", text)
}

Dengan ?text=hello, halamannya berbunyi You said: hello. Sekarang minta ini, dengan sepotong HTML sebagai teksnya. -G menaruh nilainya di query string, dan --data-urlencode mengodekannya:

curl.exe -i -G --data-urlencode "text=<script>alert('hello')</script>" http://localhost:8080/echo

Body response-nya:

<p>You said: <script>alert('hello')</script></p>

Programmu sudah mengirim tag <script>, dan browser menjalankan apa yang ada di dalamnya. Kamu bisa mencobanya di browser, dengan membuka alamat di bawah. Sebuah kotak kecil seharusnya muncul.

http://localhost:8080/echo?text=<script>alert('hello')</script>

Dengan kata sederhana: pengunjung telah menulis sebagian halamanmu. Kotak itu tidak berbahaya, tetapi skrip bisa melakukan apa pun yang bisa dilakukan halaman itu. Lebih buruk lagi, teksnya tidak harus diketik oleh orang yang melihatnya. Seseorang bisa mengirim tautan berisi skrip kepada temannya. Atau, begitu server menyimpan pesan, pesan satu pengunjung bisa berjalan di browser semua pengunjung berikutnya.

Ini dikenal sebagai cross-site scripting. Perbaikannya adalah mengubah < menjadi &lt; dan karakter khusus lainnya menjadi kode serupa, supaya browser menampilkannya sebagai teks. Melakukan itu dengan tangan, di setiap handler, mudah terlupa, jadi kamu tidak akan melakukannya dengan tangan. Bab 34 memakai package yang melakukannya setiap saat.

Sampai saat itu, jawab dengan text/plain ketika kamu mencetak input pengunjung, seperti yang dilakukan handleHello. Browser tidak menjalankan apa pun di dalam teks biasa.

Jangan pernah menyalin handleEcho ke program sungguhan. Hal yang sama berlaku untuk membangun HTML dengan fmt.Fprintf dan nilai apa pun dari request.

PR

Starter PR ada di exercises/33/homework/. Masing-masing bisa langsung dijalankan dan menjawab dengan satu baris pengganti.

Soal 1 — Sapaan dari query string

Buka exercises/33/homework/01-greet.go. GET /greet?name=Keiko menjawab dengan teks biasa Good morning, Keiko. Nama yang tidak ada atau kosong menjawab dengan status 400 dan pesan pendek. Atur Content-Type menjadi text/plain, karena namanya berasal dari pengunjung.

Soal 2 — Kelipatan dua dari path

Buka exercises/33/homework/02-double.go. GET /double/21 menjawab dengan 21 doubled is 42. Pakai r.PathValue dan strconv.Atoi. Nilai yang bukan bilangan bulat, atau tidak berada di antara -1000000 sampai 1000000, menjawab dengan status 400.

Soal 3 — Formulir penilaian

Buka exercises/33/homework/03-rating-form.go. Formulirnya sudah selesai. Tulis handleRating: baca rating dengan r.FormValue, ubah dengan strconv.Atoi, dan jawab dengan status 400 kalau bukan bilangan bulat dari 1 sampai 5. Kalau tidak, cetak nilainya ke terminal, dan redirect ke /thanks dengan status 303.

Tantangan — Kalkulator

Buka exercises/33/homework/04-calculator.go. Tulis formulir dengan method="get" dan action="/calc", dengan input teks a dan b, dan <select> bernama op dengan pilihan add, sub, mul, dan div. Tulis handleCalc, yang menjawab dengan teks seperti 6 div 3 = 2.

Ini semua menjawab dengan status 400 dan pesan yang berguna: a atau b bukan bilangan bulat, a atau b tidak berada di antara -1000000 sampai 1000000, op bukan salah satu dari keempatnya, dan pembagian dengan 0. Pikirkan mengapa formulir ini memakai GET dan bukan POST.

Stuck atau sudah selesai? Buka halaman solusi PR.