Part 7 mini-project: Guestbook

Chapter 01 promised three programs: a number guessing game, a text adventure, and a small guestbook web page. You have built the first two. This is the third: visitors leave a message, your program saves it, and it shows it to the next visitor. It is the first project that you open in a browser.

What to build

A server at http://localhost:8080 with two routes:

Route What it does
GET / Shows all the messages, oldest first, and a form.
POST /messages Checks the form, saves the message, and redirects to /.

The rules:

  • A message has a name and a text. Spaces around them are trimmed.
  • The name can be at most 40 characters, and the text at most 500. Neither can be empty.
  • A bad form gets status 400. The page shows the form again, with a message saying what is wrong, and with what the visitor typed still in the boxes.
  • A good form gets status 303, and the browser then asks for /.
  • Messages are saved to messages.json, and are still there after a restart.
  • <script> in a message shows up as text.

The finished server, from real runs. These lines are from the page after three messages, with the last one an attempt to attack it:

<p><strong>Keiko</strong> wrote:</p>
<p class="text">Hello there</p>
<p><strong>Arif</strong> wrote:</p>
<p class="text">Nice page</p>
<p><strong>&lt;script&gt;alert(&#34;hello&#34;)&lt;/script&gt;</strong> wrote:</p>
<p class="text">&lt;script&gt;alert(&#34;hello&#34;)&lt;/script&gt;</p>

The responses to a good form and some bad ones:

curl.exe -i -d "name=Keiko&text=Hello there" http://localhost:8080/messages
HTTP/1.1 303 See Other
Location: /

curl.exe -i -d "name=&text=x" http://localhost:8080/messages
HTTP/1.1 400 Bad Request
<p class="error">Please enter your name.</p>

curl.exe -i -d "name=Keiko&text=" http://localhost:8080/messages
HTTP/1.1 400 Bad Request
<p class="error">Please enter a message.</p>

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

Method Not Allowed

A text of 501 characters and a name of 41 characters give these lines:

<p class="error">The message can have at most 500 characters.</p>
<p class="error">The name can have at most 40 characters.</p>

A broken messages.json stops the server at the start:

Could not load the messages: messages.json is not valid: invalid character 'o' looking for beginning of object key string
Fix or remove the file, then start the server again.

File layout

The project is a module with one package of your own, as in Part 6. Both versions are under projects/06-guestbook/:

starter/
    go.mod
    main.go
    guestbook/
        guestbook.go
    templates/
        home.html
finished/
    go.mod
    main.go
    guestbook/
        guestbook.go
    templates/
        home.html

To work on the starter, go into its folder and use go run .:

cd projects/06-guestbook/starter
go run .

To run the finished version:

cd projects/06-guestbook/finished
go run .

To go back up to go-work afterwards:

cd ../../..

Both paths in the program are relative to the folder it runs from: it reads templates/home.html, and it reads and writes messages.json. The data file appears next to main.go the first time a message is saved. Delete it to start with an empty book. Running from any other folder gives this:

Could not read the template: open templates/home.html: no such file or directory
Run this program from its own folder, and check the template.

Naming the file from go-work fails earlier, for the reason in Chapter 25. It stops with an error containing package guestbookapp/guestbook is not in std.

The starter runs as it is. main.go is finished, so read it first. The page shows the form and a TODO line where the messages go. Sending the form gets status 400, with the line TODO: NewMessage is not written yet. on the page. The TODOs are in guestbook/guestbook.go and templates/home.html. Run go vet ./... in the same folder to check both packages.

What to build it from

main.go knows about the web and nothing about the rules. The package guestbook knows about the rules and the file, and nothing about the web. It could be used by a terminal program with no change. Here is finished/main.go, which the starter also has:

// Guestbook -- main (finished).
//
// Run from this folder, because the template and the data file are relative to
// it:
//   cd projects/06-guestbook/finished
//   go run .

package main

import (
    "bytes"
    "fmt"
    "html/template"
    "net/http"

    "guestbookapp/guestbook"
)

const templateFile = "templates/home.html"
const dataFile = "messages.json"

// homeData is everything the template needs.
type homeData struct {
    Error    string
    Name     string
    Text     string
    Messages []guestbook.Message
}

var homeTemplate *template.Template
var book *guestbook.Book

// showHome builds the page in memory, and sends it only if that worked.
func showHome(w http.ResponseWriter, status int, data homeData) {
    data.Messages = book.Messages()

    var page bytes.Buffer
    err := homeTemplate.Execute(&page, data)
    if err != nil {
        fmt.Println("Template error:", err)
        http.Error(w, "The page could not be built.", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.WriteHeader(status)
    page.WriteTo(w)
}

func handleHome(w http.ResponseWriter, r *http.Request) {
    showHome(w, http.StatusOK, homeData{})
}

func handleAdd(w http.ResponseWriter, r *http.Request) {
    name := r.FormValue("name")
    text := r.FormValue("text")

    message, err := guestbook.NewMessage(name, text)
    if err != nil {
        // Show the form again, with what the visitor typed.
        data := homeData{Error: err.Error(), Name: name, Text: text}
        showHome(w, http.StatusBadRequest, data)
        return
    }

    err = book.Add(message)
    if err != nil {
        fmt.Println("Save error:", err)
        http.Error(w, "The message could not be saved.", http.StatusInternalServerError)
        return
    }

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

func main() {
    parsed, err := template.ParseFiles(templateFile)
    if err != nil {
        fmt.Println("Could not read the template:", err)
        fmt.Println("Run this program from its own folder, and check the template.")
        return
    }
    homeTemplate = parsed

    opened, err := guestbook.Open(dataFile)
    if err != nil {
        fmt.Println("Could not load the messages:", err)
        fmt.Println("Fix or remove the file, then start the server again.")
        return
    }
    book = opened

    http.HandleFunc("GET /{$}", handleHome)
    http.HandleFunc("POST /messages", handleAdd)

    fmt.Println("Guestbook 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)
    }
}

The package guestbook needs:

  • Message, a struct with the fields Name and Text.
  • NewMessage(name, text), which trims, checks, and returns a Message and an error. Its error messages are written for visitors, because main.go shows them on the page.
  • Book, a struct with a sync.Mutex, the file name, and a slice of messages.
  • Open(filename), which returns a *Book and an error, with a missing file giving an empty book.
  • (b *Book) Messages(), which returns a copy of the messages.
  • (b *Book) Add(message), which saves first and keeps the new slice only if saving worked.

Hints

  • The mutex is now a field of Book, next to the data it protects, and you write b.mu.Lock(). The methods need pointer receivers. With a value receiver, each call would copy the lock, and go vet reports Messages passes lock by value.
  • Message has exported fields, which breaks the advice in Chapter 30 a little. encoding/json and html/template can only see exported fields, so there is no choice. Book keeps its fields private, so the slice can be reached only through its methods, and that is where the rules are.
  • NewMessage does not touch the book, so it needs no lock. main.go calls it first, which is how it can tell a visitor's mistake (400) from a problem with the disk (500).
  • Count characters with utf8.RuneCountInString, so that a name in another alphabet is not measured in bytes.
  • In the template, {{range .Messages}} has {{else}} for the empty case. A message in a <p> would lose its line endings, so the template's small <style> block sets white-space: pre-wrap on the class text, which keeps them. Everything is in the page itself, with no outside stylesheet.
  • The form's values go back into the page on an error. That is safe, because the template escapes them there too.

A bigger challenge (optional)

  • Newest first. Use slices.Reverse on the copy in Messages.
  • A limit. Refuse new messages once the book holds 1000, since the whole file is rewritten on every save.
  • A count. Show 3 messages at the top, using {{len .Messages}} in the template.
  • A package test by hand. Write a small terminal program that imports guestbook, to show that the package does not need the web.

None of these are required to finish the project.

Done?

When a message survives a restart, an empty form gets a useful error, reloading the page after sending does not send the message twice, and <script> shows up as text, your guestbook is done. That is the last of the three programs that Chapter 01 promised. The last project is the Web Scoreboard, which brings the whole book together.