diff --git a/.gitignore b/.gitignore index deca6d6..a6090ec 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ go.work # air config .air.toml /tmp +tls/ diff --git a/cmd/web/context.go b/cmd/web/context.go new file mode 100644 index 0000000..3a5133a --- /dev/null +++ b/cmd/web/context.go @@ -0,0 +1,6 @@ +package main + +type contextKey string + +const isAuthenticatedContextKey = contextKey("isAuthenticated") +const userNameContextKey = contextKey("username") diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index 59d139d..22cddac 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -4,39 +4,132 @@ import ( "errors" "fmt" "net/http" - "strings" - "unicode/utf8" "git.32bit.cafe/32bitcafe/guestbook/internal/models" - "github.com/google/uuid" + "git.32bit.cafe/32bitcafe/guestbook/internal/validator" ) func (app *application) home(w http.ResponseWriter, r *http.Request) { - app.render(w, r, http.StatusOK, "home.tmpl.html", templateData{}) + data := app.newTemplateData(r) + app.render(w, r, http.StatusOK, "home.tmpl.html", data) +} + +type userRegistrationForm struct { + Name string `schema:"username"` + Email string `schema:"email"` + Password string `schema:"password"` + validator.Validator `schema:"-"` } func (app *application) getUserRegister(w http.ResponseWriter, r *http.Request) { - app.render(w, r, http.StatusOK, "usercreate.view.tmpl.html", templateData{}) + data := app.newTemplateData(r) + data.Form = userRegistrationForm{} + app.render(w, r, http.StatusOK, "usercreate.view.tmpl.html", data) } func (app *application) postUserRegister(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() + var form userRegistrationForm + err := app.decodePostForm(r, &form) if err != nil { - app.serverError(w, r, err) + app.clientError(w, http.StatusBadRequest) + return } - username := r.Form.Get("username") - email := r.Form.Get("email") - rawid, err := app.users.Insert(username, email) + + form.CheckField(validator.NotBlank(form.Name), "name", "This field cannot be blank") + form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank") + form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address") + form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank") + form.CheckField(validator.MinChars(form.Password, 8), "password", "This field must be at least 8 characters long") + + if !form.Valid() { + data := app.newTemplateData(r) + data.Form = form + app.render(w, r, http.StatusUnprocessableEntity, "usercreate.view.tmpl.html", data) + return + } + + shortId := app.createShortId() + err = app.users.Insert(shortId, form.Name, form.Email, form.Password) + if err != nil { + if errors.Is(err, models.ErrDuplicateEmail) { + form.AddFieldError("email", "Email address is already in use") + data := app.newTemplateData(r) + data.Form = form + app.render(w ,r, http.StatusUnprocessableEntity, "usercreate.view.tmpl.html", data) + } else { + app.serverError(w, r, err) + } + return + } + app.sessionManager.Put(r.Context(), "flash", "Registration successful. Please log in.") + http.Redirect(w, r, "/users/login", http.StatusSeeOther) +} + +type userLoginForm struct { + Email string `schema:"email"` + Password string `schema:"password"` + validator.Validator `schema:"-"` +} + +func (app *application) getUserLogin(w http.ResponseWriter, r *http.Request) { + data := app.newTemplateData(r) + data.Form = userLoginForm{} + app.render(w, r, http.StatusOK, "login.view.tmpl.html", data) +} + +func (app *application) postUserLogin(w http.ResponseWriter, r *http.Request) { + var form userLoginForm + + err := app.decodePostForm(r, &form) + if err != nil { + app.clientError(w, http.StatusBadRequest) + } + + form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank") + form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address") + form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank") + + if !form.Valid() { + data := app.newTemplateData(r) + data.Form = userLoginForm{} + app.render(w, r, http.StatusUnprocessableEntity, "login.view.tmpl.html", data) + return + } + + id, err := app.users.Authenticate(form.Email, form.Password) + if err != nil { + if errors.Is(err, models.ErrInvalidCredentials) { + form.AddNonFieldError("Email or password is incorrect") + data := app.newTemplateData(r) + data.Form = form + app.render(w, r, http.StatusUnprocessableEntity, "login.view.tmpl.html", data) + } else { + app.serverError(w, r, err) + } + return + } + + err = app.sessionManager.RenewToken(r.Context()) if err != nil { app.serverError(w, r, err) return } - id, err := encodeIdB64(rawid) + + app.sessionManager.Put(r.Context(), "authenticatedUserId", id) + + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (app *application) postUserLogout(w http.ResponseWriter, r *http.Request) { + err := app.sessionManager.RenewToken(r.Context()) if err != nil { app.serverError(w, r, err) return } - http.Redirect(w, r, fmt.Sprintf("/users/%s", id), http.StatusSeeOther) + + app.sessionManager.Remove(r.Context(), "authenticatedUserId") + app.sessionManager.Put(r.Context(), "flash", "You've been logged out successfully!") + http.Redirect(w, r, "/", http.StatusSeeOther) } func (app *application) getUsersList(w http.ResponseWriter, r *http.Request) { @@ -45,19 +138,14 @@ func (app *application) getUsersList(w http.ResponseWriter, r *http.Request) { app.serverError(w, r, err) return } - app.render(w, r, http.StatusOK, "userlist.view.tmpl.html", templateData{ - Users: users, - }) + data := app.newTemplateData(r) + data.Users = users + app.render(w, r, http.StatusOK, "userlist.view.tmpl.html", data) } func (app *application) getUser(w http.ResponseWriter, r *http.Request) { - rawid := r.PathValue("id") - id, err := decodeIdB64(rawid) - if err != nil { - app.serverError(w, r, err) - return - } - user, err := app.users.Get(id) + slug := r.PathValue("id") + user, err := app.users.Get(slugToShortId(slug)) if err != nil { if errors.Is(err, models.ErrNoRecord) { http.NotFound(w, r) @@ -66,13 +154,14 @@ func (app *application) getUser(w http.ResponseWriter, r *http.Request) { } return } - app.render(w, r, http.StatusOK, "user.view.tmpl.html", templateData{ - User: user, - }) + data := app.newTemplateData(r) + data.User = user + app.render(w, r, http.StatusOK, "user.view.tmpl.html", data) } func (app *application) getGuestbookCreate(w http.ResponseWriter, r* http.Request) { - app.render(w, r, http.StatusOK, "guestbookcreate.view.tmpl.html", templateData{}) + data := app.newTemplateData(r) + app.render(w, r, http.StatusOK, "guestbookcreate.view.tmpl.html", data) } func (app *application) postGuestbookCreate(w http.ResponseWriter, r* http.Request) { @@ -82,42 +171,37 @@ func (app *application) postGuestbookCreate(w http.ResponseWriter, r* http.Reque return } siteUrl := r.Form.Get("siteurl") - app.logger.Debug("creating guestbook for site", "siteurl", siteUrl) - userId := getUserId() - rawid, err := app.guestbooks.Insert(siteUrl, userId) - if err != nil { - app.serverError(w, r, err) - return - } - id, err := encodeIdB64(rawid) + shortId := app.createShortId() + _, err = app.guestbooks.Insert(shortId, siteUrl, 0) if err != nil { app.serverError(w, r, err) return } app.sessionManager.Put(r.Context(), "flash", "Guestbook successfully created!") - http.Redirect(w, r, fmt.Sprintf("/guestbooks/%s", id), http.StatusSeeOther) + http.Redirect(w, r, fmt.Sprintf("/guestbooks/%s", shortIdToSlug(shortId)), http.StatusSeeOther) } func (app *application) getGuestbookList(w http.ResponseWriter, r *http.Request) { - userId := getUserId() + userId := app.sessionManager.GetInt64(r.Context(), "authenticatedUserId") guestbooks, err := app.guestbooks.GetAll(userId) if err != nil { app.serverError(w, r, err) return } - app.render(w, r, http.StatusOK, "guestbooklist.view.tmpl.html", templateData{ - Guestbooks: guestbooks, - }) -} - -func (app *application) getGuestbook(w http.ResponseWriter, r *http.Request) { - rawId := r.PathValue("id") - id, err := decodeIdB64(rawId) + user, err := app.users.GetById(userId) if err != nil { app.serverError(w, r, err) return } - guestbook, err := app.guestbooks.Get(id) + data := app.newTemplateData(r) + data.Guestbooks = guestbooks + data.User = user + app.render(w, r, http.StatusOK, "guestbooklist.view.tmpl.html", data) +} + +func (app *application) getGuestbook(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("id") + guestbook, err := app.guestbooks.Get(slugToShortId(slug)) if err != nil { if errors.Is(err, models.ErrNoRecord) { http.NotFound(w, r) @@ -126,7 +210,7 @@ func (app *application) getGuestbook(w http.ResponseWriter, r *http.Request) { } return } - comments, err := app.guestbookComments.GetAll(id) + comments, err := app.guestbookComments.GetAll(guestbook.ID) if err != nil { app.serverError(w, r, err) return @@ -138,13 +222,8 @@ func (app *application) getGuestbook(w http.ResponseWriter, r *http.Request) { } func (app *application) getGuestbookComments(w http.ResponseWriter, r *http.Request) { - rawId := r.PathValue("id") - id, err := decodeIdB64(rawId) - if err != nil { - app.serverError(w, r, err) - return - } - guestbook, err := app.guestbooks.Get(id) + slug := r.PathValue("id") + guestbook, err := app.guestbooks.Get(slugToShortId(slug)) if err != nil { if errors.Is(err, models.ErrNoRecord) { http.NotFound(w, r) @@ -153,70 +232,83 @@ func (app *application) getGuestbookComments(w http.ResponseWriter, r *http.Requ } return } - comments, err := app.guestbookComments.GetAll(id) + comments, err := app.guestbookComments.GetAll(guestbook.ID) if err != nil { app.serverError(w, r, err) return } - app.render(w, r, http.StatusOK, "commentlist.view.tmpl.html", templateData{ - Guestbook: guestbook, - Comments: comments, - }) + data := app.newTemplateData(r) + data.Guestbook = guestbook + data.Comments = comments + app.render(w, r, http.StatusOK, "commentlist.view.tmpl.html", data) +} + +type commentCreateForm struct { + AuthorName string `schema:"authorname"` + AuthorEmail string `schema:"authoremail"` + AuthorSite string `schema:"authorsite"` + Content string `schema:"content,required"` + validator.Validator `schema:"-"` } func (app *application) getGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) { - rawId := r.PathValue("id") - id, err := decodeIdB64(rawId) + slug := r.PathValue("id") + guestbook, err := app.guestbooks.Get(slugToShortId(slug)) if err != nil { - http.NotFound(w, r) + if errors.Is(err, models.ErrNoRecord) { + http.NotFound(w, r) + } else { + app.serverError(w, r, err) + } + return } - app.render(w, r, http.StatusOK, "commentcreate.view.tmpl.html", templateData{ - Guestbook: models.Guestbook{ - ID: id, - }, - }) + data := app.newTemplateData(r) + data.Guestbook = guestbook + data.Form = commentCreateForm{} + app.render(w, r, http.StatusOK, "commentcreate.view.tmpl.html", data) } func (app *application) postGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) { - rawGbId := r.PathValue("id") - gbId, err := decodeIdB64(rawGbId) + guestbookSlug := r.PathValue("id") + guestbook, err := app.guestbooks.Get(slugToShortId(guestbookSlug)) if err != nil { - app.serverError(w, r, err) + if errors.Is(err, models.ErrNoRecord) { + http.NotFound(w, r) + } else { + app.serverError(w, r, err) + } return } - err = r.ParseForm() + + var form commentCreateForm + err = app.decodePostForm(r, &form) if err != nil { app.clientError(w, http.StatusBadRequest) return } - authorName := r.PostForm.Get("authorname") - authorEmail := r.PostForm.Get("authoremail") - authorSite := r.PostForm.Get("authorsite") - content := r.PostForm.Get("content") - fieldErrors := make(map[string]string) - if strings.TrimSpace(authorName) == "" { - fieldErrors["title"] = "This field cannot be blank" - } else if utf8.RuneCountInString(authorName) > 256 { - fieldErrors["title"] = "This field cannot be more than 256 characters long" - } - if strings.TrimSpace(content) == "" { - fieldErrors["content"] = "This field cannot be blank" - } - if len(fieldErrors) > 0 { - fmt.Fprint(w, fieldErrors) + form.CheckField(validator.NotBlank(form.AuthorName), "authorName", "This field cannot be blank") + form.CheckField(validator.MaxChars(form.AuthorName, 256), "authorName", "This field cannot be more than 256 characters long") + form.CheckField(validator.NotBlank(form.AuthorEmail), "authorEmail", "This field cannot be blank") + form.CheckField(validator.MaxChars(form.AuthorEmail, 256), "authorEmail", "This field cannot be more than 256 characters long") + form.CheckField(validator.NotBlank(form.AuthorSite), "authorSite", "This field cannot be blank") + form.CheckField(validator.MaxChars(form.AuthorSite, 256), "authorSite", "This field cannot be more than 256 characters long") + form.CheckField(validator.NotBlank(form.Content), "content", "This field cannot be blank") + + if !form.Valid() { + data := app.newTemplateData(r) + data.Guestbook = guestbook + data.Form = form + app.render(w, r, http.StatusUnprocessableEntity, "commentcreate.view.tmpl.html", data) return } - commentId, err := app.guestbookComments.Insert(gbId, uuid.UUID{}, authorName, authorEmail, authorSite, content, "", true) + shortId := app.createShortId() + _, err = app.guestbookComments.Insert(shortId, guestbook.ID, 0, form.AuthorName, form.AuthorEmail, form.AuthorSite, form.Content, "", true) if err != nil { app.serverError(w, r, err) return } - _, err = encodeIdB64(commentId) - if err != nil { - app.serverError(w, r, err) - return - } - http.Redirect(w, r, fmt.Sprintf("/guestbooks/%s", rawGbId), http.StatusSeeOther) + app.sessionManager.Put(r.Context(), "flash", "Comment successfully posted!") + http.Redirect(w, r, fmt.Sprintf("/guestbooks/%s", guestbookSlug), http.StatusSeeOther) } diff --git a/cmd/web/helpers.go b/cmd/web/helpers.go index 0d1cbbf..3795b8f 100644 --- a/cmd/web/helpers.go +++ b/cmd/web/helpers.go @@ -1,11 +1,14 @@ package main import ( - "encoding/base64" + "errors" "fmt" + "math" "net/http" + "strconv" + "time" - "github.com/google/uuid" + "github.com/gorilla/schema" ) func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) { @@ -37,24 +40,60 @@ func (app *application) render(w http.ResponseWriter, r *http.Request, status in } } -func encodeIdB64 (id uuid.UUID) (string, error) { - b, err := id.MarshalBinary() - if err != nil { - return "", err +func (app *application) nextSequence () uint16 { + val := app.sequence + if app.sequence == math.MaxUint16 { + app.sequence = 0 + } else { + app.sequence += 1 } - s := base64.RawURLEncoding.EncodeToString(b) - return s, nil + return val } -func decodeIdB64 (id string) (uuid.UUID, error) { - b, err := base64.RawURLEncoding.DecodeString(id) - var u uuid.UUID +func (app *application) createShortId () uint64 { + now := time.Now().UTC() + epoch, err := time.Parse(time.RFC822Z, "01 Jan 20 00:00 -0000") if err != nil { - return u, err + fmt.Println(err) + return 0 } - err = u.UnmarshalBinary(b) - if err != nil { - return u, err - } - return u, nil + d := now.Sub(epoch) + ms := d.Milliseconds() + seq := app.nextSequence() + return (uint64(ms) & 0x0FFFFFFFFFFFFFFF) | (uint64(seq) << 48) +} + +func shortIdToSlug(id uint64) string { + slug := strconv.FormatUint(id, 36) + return slug +} + +func slugToShortId(slug string) uint64 { + id, _ := strconv.ParseUint(slug, 36, 64) + return id +} + +func (app *application) decodePostForm(r *http.Request, dst any) error { + err := r.ParseForm() + if err != nil { + return err + } + + err = app.formDecoder.Decode(dst, r.PostForm) + if err != nil { + var multiErrors *schema.MultiError + if !errors.As(err, &multiErrors) { + panic(err) + } + return err + } + return nil +} + +func (app *application) isAuthenticated(r *http.Request) bool { + isAuthenticated, ok := r.Context().Value(isAuthenticatedContextKey).(bool) + if !ok { + return false + } + return isAuthenticated } diff --git a/cmd/web/main.go b/cmd/web/main.go index 32cf44f..63e7c2a 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -1,6 +1,7 @@ package main import ( + "crypto/tls" "database/sql" "flag" "log/slog" @@ -12,17 +13,19 @@ import ( "git.32bit.cafe/32bitcafe/guestbook/internal/models" "github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/v2" - "github.com/google/uuid" + "github.com/gorilla/schema" _ "github.com/mattn/go-sqlite3" ) type application struct { + sequence uint16 logger *slog.Logger templateCache map[string]*template.Template guestbooks *models.GuestbookModel users *models.UserModel guestbookComments *models.GuestbookCommentModel sessionManager *scs.SessionManager + formDecoder *schema.Decoder } func main() { @@ -49,18 +52,37 @@ func main() { sessionManager.Store = sqlite3store.New(db) sessionManager.Lifetime = 12 * time.Hour + formDecoder := schema.NewDecoder() + formDecoder.IgnoreUnknownKeys(true) + app := &application{ + sequence: 0, templateCache: templateCache, logger: logger, sessionManager: sessionManager, guestbooks: &models.GuestbookModel{DB: db}, users: &models.UserModel{DB: db}, guestbookComments: &models.GuestbookCommentModel{DB: db}, + formDecoder: formDecoder, + } + + tlsConfig := &tls.Config{ + CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}, + } + + srv := &http.Server { + Addr: *addr, + Handler: app.routes(), + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), + TLSConfig: tlsConfig, + IdleTimeout: time.Minute, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, } logger.Info("Starting server", slog.Any("addr", *addr)) - err = http.ListenAndServe(*addr, app.routes()); + err = srv.ListenAndServeTLS("./tls/cert.pem", "./tls/key.pem") logger.Error(err.Error()) os.Exit(1) } @@ -76,7 +98,6 @@ func openDB(dsn string) (*sql.DB, error) { return db, nil } -func getUserId() uuid.UUID { - userId, _ := decodeIdB64("laINnbnkTtyN5SYoCfSbXw") - return userId +func getUserId() int64 { + return 1 } diff --git a/cmd/web/middleware.go b/cmd/web/middleware.go index 5c92c2f..e539bdb 100644 --- a/cmd/web/middleware.go +++ b/cmd/web/middleware.go @@ -1,8 +1,11 @@ package main import ( + "context" "fmt" "net/http" + + "github.com/justinas/nosurf" ) func (app *application) logRequest (next http.Handler) http.Handler { @@ -23,7 +26,7 @@ func commonHeaders (next http.Handler) http.Handler { w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com") w.Header().Set("Referrer-Policy", "origin-when-cross-origin") w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("X-Frame-Options", "deny") + // w.Header().Set("X-Frame-Options", "deny") w.Header().Set("X-XSS-Protection", "0") next.ServeHTTP(w, r) }) @@ -41,3 +44,45 @@ func (app *application) recoverPanic(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +func (app *application) requireAuthentication(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !app.isAuthenticated(r) { + http.Redirect(w, r, "/users/login", http.StatusSeeOther) + return + } + w.Header().Add("Cache-Control", "no-store") + next.ServeHTTP(w, r) + }) +} + +func noSurf(next http.Handler) http.Handler { + csrfHandler := nosurf.New(next) + csrfHandler.SetBaseCookie(http.Cookie{ + HttpOnly: true, + Path: "/", + Secure: true, + }) + + return csrfHandler +} + +func (app *application) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := app.sessionManager.GetInt64(r.Context(), "authenticatedUserId") + if id == 0 { + next.ServeHTTP(w, r) + return + } + exists, err := app.users.Exists(id) + if err != nil { + app.serverError(w, r, err) + return + } + if exists { + ctx := context.WithValue(r.Context(), isAuthenticatedContextKey, true) + r = r.WithContext(ctx) + } + next.ServeHTTP(w, r) + }) +} diff --git a/cmd/web/routes.go b/cmd/web/routes.go index aebc600..aa21e1c 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -1,25 +1,38 @@ package main import ( - "net/http" + "net/http" + + "github.com/justinas/alice" ) func (app *application) routes() http.Handler { mux := http.NewServeMux() fileServer := http.FileServer(http.Dir("./ui/static")) mux.Handle("GET /static/", http.StripPrefix("/static", fileServer)) + + dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate) - mux.Handle("/{$}", app.sessionManager.LoadAndSave(http.HandlerFunc(app.home))) - mux.Handle("GET /users", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getUsersList))) - mux.Handle("GET /users/{id}", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getUser))) - mux.Handle("GET /users/register", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getUserRegister))) - mux.Handle("POST /users/register", app.sessionManager.LoadAndSave(http.HandlerFunc(app.postUserRegister))) - mux.Handle("GET /guestbooks", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getGuestbookList))) - mux.Handle("GET /guestbooks/{id}", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getGuestbook))) - mux.Handle("GET /guestbooks/create", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getGuestbookCreate))) - mux.Handle("POST /guestbooks/create", app.sessionManager.LoadAndSave(http.HandlerFunc(app.postGuestbookCreate))) - mux.Handle("GET /guestbooks/{id}/comments/create", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getGuestbookCommentCreate))) - mux.Handle("POST /guestbooks/{id}/comments/create", app.sessionManager.LoadAndSave(http.HandlerFunc(app.postGuestbookCommentCreate))) - return app.recoverPanic(app.logRequest(commonHeaders(mux))) + mux.Handle("/{$}", dynamic.ThenFunc(app.home)) + mux.Handle("POST /guestbooks/{id}/comments/create", dynamic.ThenFunc(app.postGuestbookCommentCreate)) + mux.Handle("GET /guestbooks/{id}", dynamic.ThenFunc(app.getGuestbook)) + mux.Handle("GET /users/register", dynamic.ThenFunc(app.getUserRegister)) + mux.Handle("POST /users/register", dynamic.ThenFunc(app.postUserRegister)) + mux.Handle("GET /users/login", dynamic.ThenFunc(app.getUserLogin)) + mux.Handle("POST /users/login", dynamic.ThenFunc(app.postUserLogin)) + + protected := dynamic.Append(app.requireAuthentication) + + mux.Handle("GET /users", protected.ThenFunc(app.getUsersList)) + mux.Handle("GET /users/{id}", protected.ThenFunc(app.getUser)) + mux.Handle("POST /users/logout", protected.ThenFunc(app.postUserLogout)) + mux.Handle("GET /guestbooks", protected.ThenFunc(app.getGuestbookList)) + mux.Handle("GET /guestbooks/create", protected.ThenFunc(app.getGuestbookCreate)) + mux.Handle("POST /guestbooks/create", protected.ThenFunc(app.postGuestbookCreate)) + mux.Handle("GET /guestbooks/{id}/comments/create", protected.ThenFunc(app.getGuestbookCommentCreate)) + + standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders) + + return standard.Then(mux) } diff --git a/cmd/web/templates.go b/cmd/web/templates.go index 9458f84..17d3a17 100644 --- a/cmd/web/templates.go +++ b/cmd/web/templates.go @@ -7,6 +7,7 @@ import ( "time" "git.32bit.cafe/32bitcafe/guestbook/internal/models" + "github.com/justinas/nosurf" ) type templateData struct { @@ -18,6 +19,9 @@ type templateData struct { Comment models.GuestbookComment Comments []models.GuestbookComment Flash string + Form any + IsAuthenticated bool + CSRFToken string } func humanDate(t time.Time) string { @@ -26,8 +30,8 @@ func humanDate(t time.Time) string { var functions = template.FuncMap { "humanDate": humanDate, - "encodeId": encodeIdB64, - "decodeId": decodeIdB64, + "shortIdToSlug": shortIdToSlug, + "slugToShortId": slugToShortId, } func newTemplateCache() (map[string]*template.Template, error) { @@ -59,5 +63,7 @@ func (app *application) newTemplateData(r *http.Request) templateData { return templateData { CurrentYear: time.Now().Year(), Flash: app.sessionManager.PopString(r.Context(), "flash"), + IsAuthenticated: app.isAuthenticated(r), + CSRFToken: nosurf.Token(r), } } diff --git a/db/create-tables-sqlite.sql b/db/create-tables-sqlite.sql index ddb41cc..e072cf7 100644 --- a/db/create-tables-sqlite.sql +++ b/db/create-tables-sqlite.sql @@ -1,14 +1,20 @@ CREATE TABLE users ( - Id blob(16) primary key, + Id integer primary key autoincrement, + ShortId integer UNIQUE NOT NULL, Username varchar(32) NOT NULL, - Email varchar(256) NOT NULL, - IsDeleted boolean NOT NULL DEFAULT FALSE -) WITHOUT ROWID; + Email varchar(256) UNIQUE NOT NULL, + IsDeleted boolean NOT NULL DEFAULT FALSE, + IsBanned boolean NOT NULL DEFAULT FALSE, + HashedPassword char(60) NOT NULL, + Created datetime NOT NULL +); CREATE TABLE guestbooks ( - Id blob(16) primary key, + Id integer primary key autoincrement, + ShortId integer UNIQUE NOT NULL, SiteUrl varchar(512) NOT NULL, UserId blob(16) NOT NULL, + Created datetime NOT NULL, IsDeleted boolean NOT NULL DEFAULT FALSE, IsActive boolean NOT NULL DEFAULT TRUE, FOREIGN KEY (UserId) REFERENCES users(Id) @@ -17,7 +23,8 @@ CREATE TABLE guestbooks ( ); CREATE TABLE guestbook_comments ( - Id blob(16) primary key, + Id integer primary key autoincrement, + ShortId integer UNIQUE NOT NULL, GuestbookId blob(16) NOT NULL, ParentId blob(16), AuthorName varchar(256) NOT NULL, @@ -25,6 +32,7 @@ CREATE TABLE guestbook_comments ( AuthorSite varchar(256), CommentText text NOT NULL, PageUrl varchar(256), + Created datetime NOT NULL, IsPublished boolean NOT NULL DEFAULT TRUE, IsDeleted boolean NOT NULL DEFAULT FALSE, FOREIGN KEY (GuestbookId) diff --git a/go.mod b/go.mod index a723110..56e429d 100644 --- a/go.mod +++ b/go.mod @@ -8,3 +8,10 @@ require ( github.com/google/uuid v1.6.0 github.com/mattn/go-sqlite3 v1.14.6 ) + +require ( + github.com/gorilla/schema v1.4.1 // indirect + github.com/justinas/alice v1.2.0 // indirect + github.com/justinas/nosurf v1.1.1 // indirect + golang.org/x/crypto v0.29.0 // indirect +) diff --git a/go.sum b/go.sum index c8a1188..179b0b7 100644 --- a/go.sum +++ b/go.sum @@ -4,5 +4,13 @@ github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZx github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= +github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= +github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= +github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk= +github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= diff --git a/internal/models/errors.go b/internal/models/errors.go index c7845a7..48a3033 100644 --- a/internal/models/errors.go +++ b/internal/models/errors.go @@ -2,4 +2,10 @@ package models import "errors" -var ErrNoRecord = errors.New("models: no matching record found") +var ( + ErrNoRecord = errors.New("models: no matching record found") + + ErrInvalidCredentials = errors.New("models: invalid credentials") + + ErrDuplicateEmail = errors.New("models: duplicate email") +) diff --git a/internal/models/guestbook.go b/internal/models/guestbook.go index 581def3..9e55936 100644 --- a/internal/models/guestbook.go +++ b/internal/models/guestbook.go @@ -2,14 +2,15 @@ package models import ( "database/sql" - - "github.com/google/uuid" + "time" ) type Guestbook struct { - ID uuid.UUID + ID int64 + ShortId uint64 SiteUrl string - UserId uuid.UUID + UserId int64 + Created time.Time IsDeleted bool IsActive bool } @@ -18,23 +19,26 @@ type GuestbookModel struct { DB *sql.DB } -func (m *GuestbookModel) Insert(siteUrl string, userId uuid.UUID) (uuid.UUID, error) { - id := uuid.New() - stmt := `INSERT INTO guestbooks (Id, SiteUrl, UserId, IsDeleted, IsActive) - VALUES(?, ?, ?, FALSE, TRUE)` - _, err := m.DB.Exec(stmt, id, siteUrl, userId) +func (m *GuestbookModel) Insert(shortId uint64, siteUrl string, userId int64) (int64, error) { + stmt := `INSERT INTO guestbooks (ShortId, SiteUrl, UserId, Created, IsDeleted, IsActive) + VALUES(?, ?, ?, ?, FALSE, TRUE)` + result, err := m.DB.Exec(stmt, shortId, siteUrl, userId, time.Now().UTC()) if err != nil { - return uuid.UUID{}, err + return -1, err + } + id, err := result.LastInsertId() + if err != nil { + return -1, err } return id, nil } -func (m *GuestbookModel) Get(id uuid.UUID) (Guestbook, error) { - stmt := `SELECT Id, SiteUrl, UserId, IsDeleted, IsActive FROM guestbooks - WHERE id = ?` - row := m.DB.QueryRow(stmt, id) +func (m *GuestbookModel) Get(shortId uint64) (Guestbook, error) { + stmt := `SELECT Id, ShortId, SiteUrl, UserId, Created, IsDeleted, IsActive FROM guestbooks + WHERE ShortId = ?` + row := m.DB.QueryRow(stmt, shortId) var g Guestbook - err := row.Scan(&g.ID, &g.SiteUrl, &g.UserId, &g.IsDeleted, &g.IsActive) + err := row.Scan(&g.ID, &g.ShortId, &g.SiteUrl, &g.UserId, &g.Created, &g.IsDeleted, &g.IsActive) if err != nil { return Guestbook{}, err } @@ -42,8 +46,8 @@ func (m *GuestbookModel) Get(id uuid.UUID) (Guestbook, error) { return g, nil } -func (m *GuestbookModel) GetAll(userId uuid.UUID) ([]Guestbook, error) { - stmt := `SELECT Id, SiteUrl, UserId, IsDeleted, IsActive FROM guestbooks +func (m *GuestbookModel) GetAll(userId int64) ([]Guestbook, error) { + stmt := `SELECT Id, ShortId, SiteUrl, UserId, Created, IsDeleted, IsActive FROM guestbooks WHERE UserId = ?` rows, err := m.DB.Query(stmt, userId) if err != nil { @@ -52,7 +56,7 @@ func (m *GuestbookModel) GetAll(userId uuid.UUID) ([]Guestbook, error) { var guestbooks []Guestbook for rows.Next() { var g Guestbook - err = rows.Scan(&g.ID, &g.SiteUrl, &g.UserId, &g.IsDeleted, &g.IsActive) + err = rows.Scan(&g.ID, &g.ShortId, &g.SiteUrl, &g.UserId, &g.Created, &g.IsDeleted, &g.IsActive) if err != nil { return nil, err } diff --git a/internal/models/guestbookcomment.go b/internal/models/guestbookcomment.go index d98a0a9..7a5beb0 100644 --- a/internal/models/guestbookcomment.go +++ b/internal/models/guestbookcomment.go @@ -2,19 +2,20 @@ package models import ( "database/sql" - - "github.com/google/uuid" + "time" ) type GuestbookComment struct { - ID uuid.UUID - GuestbookId uuid.UUID - ParentId uuid.UUID + ID int64 + ShortId uint64 + GuestbookId int64 + ParentId int64 AuthorName string AuthorEmail string AuthorSite string CommentText string PageUrl string + Created time.Time IsPublished bool IsDeleted bool } @@ -23,35 +24,38 @@ type GuestbookCommentModel struct { DB *sql.DB } -func (m *GuestbookCommentModel) Insert(guestbookId, parentId uuid.UUID, authorName, - authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (uuid.UUID, error) { - id := uuid.New() - stmt := `INSERT INTO guestbook_comments (Id, GuestbookId, ParentId, AuthorName, - AuthorEmail, AuthorSite, CommentText, PageUrl, IsPublished, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE)` - _, err := m.DB.Exec(stmt, id, guestbookId, parentId, authorName, authorEmail, - authorSite, commentText, pageUrl, isPublished) +func (m *GuestbookCommentModel) Insert(shortId uint64, guestbookId, parentId int64, authorName, + authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (int64, error) { + stmt := `INSERT INTO guestbook_comments (ShortId, GuestbookId, ParentId, AuthorName, + AuthorEmail, AuthorSite, CommentText, PageUrl, Created, IsPublished, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE)` + result, err := m.DB.Exec(stmt, shortId, guestbookId, parentId, authorName, authorEmail, + authorSite, commentText, pageUrl, time.Now().UTC(), isPublished) if err != nil { - return uuid.UUID{}, err + return -1, err + } + id, err := result.LastInsertId() + if err != nil { + return -1, err } return id, nil } -func (m *GuestbookCommentModel) Get(id uuid.UUID) (GuestbookComment, error) { - stmt := `SELECT Id, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite, - CommentText, PageUrl, IsPublished, IsDeleted FROM guestbook_comments WHERE id = ?` - row := m.DB.QueryRow(stmt, id) +func (m *GuestbookCommentModel) Get(shortId uint64) (GuestbookComment, error) { + stmt := `SELECT Id, ShortId, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite, + CommentText, PageUrl, Created, IsPublished, IsDeleted FROM guestbook_comments WHERE ShortId = ?` + row := m.DB.QueryRow(stmt, shortId) var c GuestbookComment - err := row.Scan(&c.ID, &c.GuestbookId, &c.ParentId, &c.AuthorName, &c.AuthorEmail, &c.AuthorSite, &c.CommentText, &c.PageUrl, &c.IsPublished, &c.IsDeleted) + err := row.Scan(&c.ID, &c.ShortId, &c.GuestbookId, &c.ParentId, &c.AuthorName, &c.AuthorEmail, &c.AuthorSite, &c.CommentText, &c.PageUrl, &c.Created, &c.IsPublished, &c.IsDeleted) if err != nil { return GuestbookComment{}, err } return c, nil } -func (m *GuestbookCommentModel) GetAll(guestbookId uuid.UUID) ([]GuestbookComment, error) { - stmt := `SELECT Id, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite, - CommentText, PageUrl, IsPublished, IsDeleted FROM guestbook_comments WHERE GuestbookId = ?` +func (m *GuestbookCommentModel) GetAll(guestbookId int64) ([]GuestbookComment, error) { + stmt := `SELECT Id, ShortId, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite, + CommentText, PageUrl, Created, IsPublished, IsDeleted FROM guestbook_comments WHERE GuestbookId = ? AND IsDeleted = FALSE ORDER BY Created DESC` rows, err := m.DB.Query(stmt, guestbookId) if err != nil { return nil, err @@ -59,7 +63,7 @@ func (m *GuestbookCommentModel) GetAll(guestbookId uuid.UUID) ([]GuestbookCommen var comments []GuestbookComment for rows.Next() { var c GuestbookComment - err = rows.Scan(&c.ID, &c.GuestbookId, &c.ParentId, &c.AuthorName, &c.AuthorEmail, &c.AuthorSite, &c.CommentText, &c.PageUrl, &c.IsPublished, &c.IsDeleted) + err = rows.Scan(&c.ID, &c.ShortId, &c.GuestbookId, &c.ParentId, &c.AuthorName, &c.AuthorEmail, &c.AuthorSite, &c.CommentText, &c.PageUrl, &c.Created, &c.IsPublished, &c.IsDeleted) if err != nil { return nil, err } diff --git a/internal/models/user.go b/internal/models/user.go index 01bd73d..3c96ccd 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -1,39 +1,68 @@ package models import ( - "database/sql" - "errors" + "database/sql" + "errors" + "strings" + "time" - "github.com/google/uuid" + "github.com/mattn/go-sqlite3" + "golang.org/x/crypto/bcrypt" ) type User struct { - ID uuid.UUID + ID int + ShortId uint64 Username string Email string IsDeleted bool + IsBanned bool + HashedPassword []byte + Created time.Time } type UserModel struct { DB *sql.DB } -func (m *UserModel) Insert(username string, email string) (uuid.UUID, error) { - id := uuid.New() - stmt := `INSERT INTO users (Id, Username, Email, IsDeleted) - VALUES (?, ?, ?, FALSE)` - _, err := m.DB.Exec(stmt, id, username, email) +func (m *UserModel) Insert(shortId uint64, username string, email string, password string) error { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { - return uuid.UUID{}, err + return err } - return id, nil + stmt := `INSERT INTO users (ShortId, Username, Email, IsDeleted, IsBanned, HashedPassword, Created) + VALUES (?, ?, ?, FALSE, FALSE, ?, ?)` + _, err = m.DB.Exec(stmt, shortId, username, email, hashedPassword, time.Now().UTC()) + if err != nil { + if sqliteError, ok := err.(sqlite3.Error); ok { + if sqliteError.ExtendedCode == 2067 && strings.Contains(sqliteError.Error(), "Email") { + return ErrDuplicateEmail + } + } + return err + } + return nil } -func (m *UserModel) Get(id uuid.UUID) (User, error) { - stmt := `SELECT Id, Username, Email, IsDeleted FROM users WHERE id = ?` +func (m *UserModel) Get(id uint64) (User, error) { + stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE ShortId = ? AND IsDeleted = FALSE` row := m.DB.QueryRow(stmt, id) var u User - err := row.Scan(&u.ID, &u.Username, &u.Email, &u.IsDeleted) + err := row.Scan(&u.ID, &u.ShortId, &u.Username, &u.Email, &u.Created) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrNoRecord + } + return User{}, err + } + return u, nil +} + +func (m *UserModel) GetById(id int64) (User, error) { + stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE Id = ? AND IsDeleted = FALSE` + row := m.DB.QueryRow(stmt, id) + var u User + err := row.Scan(&u.ID, &u.ShortId, &u.Username, &u.Email, &u.Created) if err != nil { if errors.Is(err, sql.ErrNoRows) { return User{}, ErrNoRecord @@ -44,12 +73,12 @@ func (m *UserModel) Get(id uuid.UUID) (User, error) { } func (m *UserModel) GetAll() ([]User, error) { - stmt := `SELECT Id, Username, Email, IsDeleted FROM users` + stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE IsDeleted = FALSE` rows, err := m.DB.Query(stmt) var users []User for rows.Next() { var u User - err = rows.Scan(&u.ID, &u.Username, &u.Email, &u.IsDeleted) + err = rows.Scan(&u.ID, &u.ShortId, &u.Username, &u.Email, &u.Created) if err != nil { return nil, err } @@ -60,3 +89,36 @@ func (m *UserModel) GetAll() ([]User, error) { } return users, nil } + +func (m *UserModel) Authenticate(email, password string) (int64, error) { + var id int64 + var hashedPassword []byte + + stmt := `SELECT Id, HashedPassword FROM users WHERE Email = ?` + err := m.DB.QueryRow(stmt, email).Scan(&id, &hashedPassword) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, ErrInvalidCredentials + } else { + return 0, err + } + } + + err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password)) + if err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return 0, ErrInvalidCredentials + } else { + return 0, err + } + } + + return id, nil +} + +func (m *UserModel) Exists(id int64) (bool, error) { + var exists bool + stmt := `SELECT EXISTS(SELECT true FROM users WHERE Id = ? AND IsDeleted = False)` + err := m.DB.QueryRow(stmt, id).Scan(&exists) + return exists, err +} diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 2395653..0f57692 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -1,17 +1,21 @@ package validator import ( + "regexp" "slices" "strings" "unicode/utf8" ) +var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + type Validator struct { + NonFieldErrors []string FieldErrors map[string]string } func (v *Validator) Valid() bool { - return len(v.FieldErrors) == 0 + return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0 } func (v *Validator) AddFieldError(key, message string) { @@ -23,6 +27,10 @@ func (v *Validator) AddFieldError(key, message string) { } } +func (v *Validator) AddNonFieldError(message string) { + v.NonFieldErrors = append(v.NonFieldErrors, message) +} + func (v *Validator) CheckField(ok bool, key, message string) { if !ok { v.AddFieldError(key, message) @@ -40,3 +48,11 @@ func MaxChars(value string, n int) bool { func PermittedValue[T comparable](value T, permittedValues ...T) bool { return slices.Contains(permittedValues, value) } + +func MinChars(value string, n int) bool { + return utf8.RuneCountInString(value) >= n +} + +func Matches(value string, rx *regexp.Regexp) bool { + return rx.MatchString(value) +} diff --git a/ui/html/base.tmpl.html b/ui/html/base.tmpl.html index d16d648..ef7a74c 100644 --- a/ui/html/base.tmpl.html +++ b/ui/html/base.tmpl.html @@ -19,7 +19,7 @@ {{ template "main" . }} diff --git a/ui/html/pages/commentcreate.view.tmpl.html b/ui/html/pages/commentcreate.view.tmpl.html index bb9c4c9..db1cd5a 100644 --- a/ui/html/pages/commentcreate.view.tmpl.html +++ b/ui/html/pages/commentcreate.view.tmpl.html @@ -1,14 +1,36 @@ {{ define "title" }}New Comment{{ end }} {{ define "main" }} -
- - - - - - - - - + +
+ + {{ with .Form.FieldErrors.authorName }} + + {{ end }} + +
+
+ + {{ with .Form.FieldErrors.authorEmail }} + + {{ end }} + +
+
+ + {{ with .Form.FieldErrors.authorSite }} + + {{ end }} + +
+
+ + {{ with .Form.FieldErrors.content }} + + {{ end }} + +
+
+ +
{{ end }} diff --git a/ui/html/pages/guestbook.view.tmpl.html b/ui/html/pages/guestbook.view.tmpl.html index a7c99a8..36815d2 100644 --- a/ui/html/pages/guestbook.view.tmpl.html +++ b/ui/html/pages/guestbook.view.tmpl.html @@ -1,12 +1,18 @@ {{ define "title" }} Guestbook View {{ end }} {{ define "main" }}

Guestbook for {{ .Guestbook.SiteUrl }}

-New Comment -

No comments yet!

{{ end }} {{ end }} diff --git a/ui/html/pages/guestbookcreate.view.tmpl.html b/ui/html/pages/guestbookcreate.view.tmpl.html index cda7fd3..a65298b 100644 --- a/ui/html/pages/guestbookcreate.view.tmpl.html +++ b/ui/html/pages/guestbookcreate.view.tmpl.html @@ -1,6 +1,7 @@ {{ define "title" }}Create a Guestbook{{ end }} {{ define "main" }}
+ diff --git a/ui/html/pages/guestbooklist.view.tmpl.html b/ui/html/pages/guestbooklist.view.tmpl.html index 57f89fb..98b97e0 100644 --- a/ui/html/pages/guestbooklist.view.tmpl.html +++ b/ui/html/pages/guestbooklist.view.tmpl.html @@ -1,9 +1,9 @@ {{ define "title" }} Guestbooks {{ end }} {{ define "main" }} -

Guestbooks run by ---

+

Guestbooks run by {{ .User.Username }}