add user login

This commit is contained in:
yequari 2024-11-11 12:55:01 -07:00
parent 741b304032
commit e54875f943
25 changed files with 647 additions and 219 deletions

1
.gitignore vendored
View File

@ -27,3 +27,4 @@ go.work
# air config
.air.toml
/tmp
tls/

6
cmd/web/context.go Normal file
View File

@ -0,0 +1,6 @@
package main
type contextKey string
const isAuthenticatedContextKey = contextKey("isAuthenticated")
const userNameContextKey = contextKey("username")

View File

@ -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.clientError(w, http.StatusBadRequest)
return
}
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)
}
username := r.Form.Get("username")
email := r.Form.Get("email")
rawid, err := app.users.Insert(username, email)
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 {
if errors.Is(err, models.ErrNoRecord) {
http.NotFound(w, r)
} else {
app.serverError(w, r, err)
}
app.render(w, r, http.StatusOK, "commentcreate.view.tmpl.html", templateData{
Guestbook: models.Guestbook{
ID: id,
},
})
return
}
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 {
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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -2,6 +2,8 @@ package main
import (
"net/http"
"github.com/justinas/alice"
)
func (app *application) routes() http.Handler {
@ -9,17 +11,28 @@ func (app *application) routes() http.Handler {
fileServer := http.FileServer(http.Dir("./ui/static"))
mux.Handle("GET /static/", http.StripPrefix("/static", fileServer))
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)))
dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)
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)
}

View File

@ -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),
}
}

View File

@ -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)

7
go.mod
View File

@ -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
)

8
go.sum
View File

@ -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=

View File

@ -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")
)

View File

@ -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
}

View File

@ -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
}

View File

@ -3,37 +3,66 @@ package models
import (
"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
}

View File

@ -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)
}

View File

@ -19,7 +19,7 @@
{{ template "main" . }}
</main>
<footer>
<p>A 32-bit Cafe Project</p>
<p>A 32bit.cafe Project</p>
</footer>
</body>
</html>

View File

@ -1,14 +1,36 @@
{{ define "title" }}New Comment{{ end }}
{{ define "main" }}
<form action="/guestbooks/{{ encodeId .Guestbook.ID }}/comments/create" method="post">
<form action="/guestbooks/{{ shortIdToSlug .Guestbook.ShortId }}/comments/create" method="post">
<div>
<label for="authorname">Name: </label>
{{ with .Form.FieldErrors.authorName }}
<label class="error">{{.}}</label>
{{ end }}
<input type="text" name="authorname" id="authorname" >
</div>
<div>
<label for="authoremail">Email: </label>
{{ with .Form.FieldErrors.authorEmail }}
<label class="error">{{.}}</label>
{{ end }}
<input type="text" name="authoremail" id="authoremail" >
</div>
<div>
<label for="authorsite">Site Url: </label>
<input type="text" name="authortext" id="authortext" >
{{ with .Form.FieldErrors.authorSite }}
<label class="error">{{.}}</label>
{{ end }}
<input type="text" name="authorsite" id="authorsite" >
</div>
<div>
<label for="content">Comment: </label>
{{ with .Form.FieldErrors.content }}
<label class="error">{{.}}</label>
{{ end }}
<textarea name="content" id="content"></textarea>
</div>
<div>
<input type="submit" value="Post">
</div>
</form>
{{ end }}

View File

@ -1,12 +1,18 @@
{{ define "title" }} Guestbook View {{ end }}
{{ define "main" }}
<h1>Guestbook for {{ .Guestbook.SiteUrl }}</h1>
<a href="/guestbooks/{{ encodeId .Guestbook.ID }}/comments/create">New Comment</a>
<ul>
<p>
<a href="/guestbooks/{{ shortIdToSlug .Guestbook.ShortId }}/comments/create">New Comment</a>
</p>
{{ range .Comments }}
<li> {{ .CommentText }} </li>
<div>
<strong> {{ .AuthorName }} </strong>
{{ .Created.Local.Format "01-02-2006 03:04PM" }}
<p>
{{ .CommentText }}
</p>
</div>
{{ else }}
</ul>
<p>No comments yet!</p>
{{ end }}
{{ end }}

View File

@ -1,6 +1,7 @@
{{ define "title" }}Create a Guestbook{{ end }}
{{ define "main" }}
<form action="/guestbooks/create" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="siteurl">Site URL: </label>
<input type="text" name="siteurl" id="siteurl" required />
<input type="submit" />

View File

@ -1,9 +1,9 @@
{{ define "title" }} Guestbooks {{ end }}
{{ define "main" }}
<h1>Guestbooks run by ---</h1>
<h1>Guestbooks run by {{ .User.Username }}</h1>
<ul>
{{ range .Guestbooks }}
<li><a href="/guestbooks/{{ encodeId .ID }}">{{ with .SiteUrl }}{{ . }}{{ else }}Untitled{{ end }}</a></li>
<li><a href="/guestbooks/{{ shortIdToSlug .ShortId }}">{{ with .SiteUrl }}{{ . }}{{ else }}Untitled{{ end }}</a></li>
{{ else }}
<p>No Guestbooks yet</p>
{{ end }}

View File

@ -0,0 +1,26 @@
{{define "title"}}Login{{end}}
{{define "main"}}
<form action="/users/login" method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{ range .Form.NonFieldErrors }}
<div class="error">{{.}}</div>
{{ end }}
<div>
<label>Email: </label>
{{ with .Form.FieldErrors.email }}
<label class="error">{{.}}</label>
{{ end }}
<input type="email" name="email" value="{{.Form.Email}}">
</div>
<div>
<label>Password: </label>
{{ with .Form.FieldErrors.password }}
<label class="error">{{.}}</label>
{{ end }}
<input type="password" name="password">
</div>
<div>
<input type="submit" value="login">
</div>
</form>
{{end}}

View File

@ -1,10 +1,30 @@
{{ define "title" }}User Registration{{ end }}
{{ define "main" }}
<form action="/users/register" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div>
<label for="username">Username: </label>
<input type="text" name="username" id="username" required />
{{ with .Form.FieldErrors.name }}
<label class="error">{{.}}</label>
{{ end }}
<input type="text" name="username" id="username" value="{{ .Form.Name }}" required />
</div>
<div>
<label for="email">Email: </label>
<input type="text" name="email" id="email" required />
<input type="submit" />
{{ with .Form.FieldErrors.email }}
<label class="error">{{.}}</label>
{{ end }}
<input type="text" name="email" id="email" value="{{ .Form.Email }}" required />
</div>
<div>
<label for="password">Password: </label>
{{ with .Form.FieldErrors.password }}
<label class="error">{{.}}</label>
{{ end }}
<input type="password" name="password" id="password" />
</div>
<div>
<input type="submit" value="Register"/>
</div>
</form>
{{ end }}

View File

@ -3,7 +3,7 @@
<h1>Users</h1>
{{ range .Users }}
<p>
<a href="/users/{{ encodeId .ID }}">{{ .Username }}</a>
<a href="/users/{{ shortIdToSlug .ShortId }}">{{ .Username }}</a>
</p>
{{ end }}
{{ end }}

View File

@ -1,7 +1,22 @@
{{ define "nav" }}
<nav>
<div>
<a href="/">Home</a>
<a href="/users/register">Register</a>
<a href="/users">Users</a>
</div>
<div>
{{ if .IsAuthenticated }}
{{ with .User }}
<a href="/users/{{ .User.ShortId }}">{{ .User.Username }}</a>
{{ end }}
<form action="/users/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button>Logout</button>
</form>
{{ else }}
<a href="/users/register">Register</a>
<a href="/users/login">Login</a>
{{ end }}
</div>
</nav>
{{ end }}