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 config
.air.toml .air.toml
/tmp /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" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"unicode/utf8"
"git.32bit.cafe/32bitcafe/guestbook/internal/models" "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) { 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) { 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) { func (app *application) postUserRegister(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm() var form userRegistrationForm
err := app.decodePostForm(r, &form)
if err != nil { if err != nil {
app.serverError(w, r, err) app.clientError(w, http.StatusBadRequest)
return
} }
username := r.Form.Get("username")
email := r.Form.Get("email") form.CheckField(validator.NotBlank(form.Name), "name", "This field cannot be blank")
rawid, err := app.users.Insert(username, email) 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 { if err != nil {
app.serverError(w, r, err) app.serverError(w, r, err)
return 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 { if err != nil {
app.serverError(w, r, err) app.serverError(w, r, err)
return 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) { 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) app.serverError(w, r, err)
return return
} }
app.render(w, r, http.StatusOK, "userlist.view.tmpl.html", templateData{ data := app.newTemplateData(r)
Users: users, data.Users = users
}) app.render(w, r, http.StatusOK, "userlist.view.tmpl.html", data)
} }
func (app *application) getUser(w http.ResponseWriter, r *http.Request) { func (app *application) getUser(w http.ResponseWriter, r *http.Request) {
rawid := r.PathValue("id") slug := r.PathValue("id")
id, err := decodeIdB64(rawid) user, err := app.users.Get(slugToShortId(slug))
if err != nil {
app.serverError(w, r, err)
return
}
user, err := app.users.Get(id)
if err != nil { if err != nil {
if errors.Is(err, models.ErrNoRecord) { if errors.Is(err, models.ErrNoRecord) {
http.NotFound(w, r) http.NotFound(w, r)
@ -66,13 +154,14 @@ func (app *application) getUser(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
app.render(w, r, http.StatusOK, "user.view.tmpl.html", templateData{ data := app.newTemplateData(r)
User: user, data.User = user
}) app.render(w, r, http.StatusOK, "user.view.tmpl.html", data)
} }
func (app *application) getGuestbookCreate(w http.ResponseWriter, r* http.Request) { 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) { 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 return
} }
siteUrl := r.Form.Get("siteurl") siteUrl := r.Form.Get("siteurl")
app.logger.Debug("creating guestbook for site", "siteurl", siteUrl) shortId := app.createShortId()
userId := getUserId() _, err = app.guestbooks.Insert(shortId, siteUrl, 0)
rawid, err := app.guestbooks.Insert(siteUrl, userId)
if err != nil {
app.serverError(w, r, err)
return
}
id, err := encodeIdB64(rawid)
if err != nil { if err != nil {
app.serverError(w, r, err) app.serverError(w, r, err)
return return
} }
app.sessionManager.Put(r.Context(), "flash", "Guestbook successfully created!") 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) { 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) guestbooks, err := app.guestbooks.GetAll(userId)
if err != nil { if err != nil {
app.serverError(w, r, err) app.serverError(w, r, err)
return return
} }
app.render(w, r, http.StatusOK, "guestbooklist.view.tmpl.html", templateData{ user, err := app.users.GetById(userId)
Guestbooks: guestbooks,
})
}
func (app *application) getGuestbook(w http.ResponseWriter, r *http.Request) {
rawId := r.PathValue("id")
id, err := decodeIdB64(rawId)
if err != nil { if err != nil {
app.serverError(w, r, err) app.serverError(w, r, err)
return 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 err != nil {
if errors.Is(err, models.ErrNoRecord) { if errors.Is(err, models.ErrNoRecord) {
http.NotFound(w, r) http.NotFound(w, r)
@ -126,7 +210,7 @@ func (app *application) getGuestbook(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
comments, err := app.guestbookComments.GetAll(id) comments, err := app.guestbookComments.GetAll(guestbook.ID)
if err != nil { if err != nil {
app.serverError(w, r, err) app.serverError(w, r, err)
return 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) { func (app *application) getGuestbookComments(w http.ResponseWriter, r *http.Request) {
rawId := r.PathValue("id") slug := r.PathValue("id")
id, err := decodeIdB64(rawId) guestbook, err := app.guestbooks.Get(slugToShortId(slug))
if err != nil {
app.serverError(w, r, err)
return
}
guestbook, err := app.guestbooks.Get(id)
if err != nil { if err != nil {
if errors.Is(err, models.ErrNoRecord) { if errors.Is(err, models.ErrNoRecord) {
http.NotFound(w, r) http.NotFound(w, r)
@ -153,70 +232,83 @@ func (app *application) getGuestbookComments(w http.ResponseWriter, r *http.Requ
} }
return return
} }
comments, err := app.guestbookComments.GetAll(id) comments, err := app.guestbookComments.GetAll(guestbook.ID)
if err != nil { if err != nil {
app.serverError(w, r, err) app.serverError(w, r, err)
return return
} }
app.render(w, r, http.StatusOK, "commentlist.view.tmpl.html", templateData{ data := app.newTemplateData(r)
Guestbook: guestbook, data.Guestbook = guestbook
Comments: comments, 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) { func (app *application) getGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) {
rawId := r.PathValue("id") slug := r.PathValue("id")
id, err := decodeIdB64(rawId) guestbook, err := app.guestbooks.Get(slugToShortId(slug))
if err != nil { 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{ data := app.newTemplateData(r)
Guestbook: models.Guestbook{ data.Guestbook = guestbook
ID: id, data.Form = commentCreateForm{}
}, app.render(w, r, http.StatusOK, "commentcreate.view.tmpl.html", data)
})
} }
func (app *application) postGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) { func (app *application) postGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) {
rawGbId := r.PathValue("id") guestbookSlug := r.PathValue("id")
gbId, err := decodeIdB64(rawGbId) guestbook, err := app.guestbooks.Get(slugToShortId(guestbookSlug))
if err != nil { 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 return
} }
err = r.ParseForm()
var form commentCreateForm
err = app.decodePostForm(r, &form)
if err != nil { if err != nil {
app.clientError(w, http.StatusBadRequest) app.clientError(w, http.StatusBadRequest)
return 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) form.CheckField(validator.NotBlank(form.AuthorName), "authorName", "This field cannot be blank")
if strings.TrimSpace(authorName) == "" { form.CheckField(validator.MaxChars(form.AuthorName, 256), "authorName", "This field cannot be more than 256 characters long")
fieldErrors["title"] = "This field cannot be blank" form.CheckField(validator.NotBlank(form.AuthorEmail), "authorEmail", "This field cannot be blank")
} else if utf8.RuneCountInString(authorName) > 256 { form.CheckField(validator.MaxChars(form.AuthorEmail, 256), "authorEmail", "This field cannot be more than 256 characters long")
fieldErrors["title"] = "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")
if strings.TrimSpace(content) == "" { form.CheckField(validator.NotBlank(form.Content), "content", "This field cannot be blank")
fieldErrors["content"] = "This field cannot be blank"
} if !form.Valid() {
if len(fieldErrors) > 0 { data := app.newTemplateData(r)
fmt.Fprint(w, fieldErrors) data.Guestbook = guestbook
data.Form = form
app.render(w, r, http.StatusUnprocessableEntity, "commentcreate.view.tmpl.html", data)
return 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 { if err != nil {
app.serverError(w, r, err) app.serverError(w, r, err)
return return
} }
_, err = encodeIdB64(commentId) app.sessionManager.Put(r.Context(), "flash", "Comment successfully posted!")
if err != nil { http.Redirect(w, r, fmt.Sprintf("/guestbooks/%s", guestbookSlug), http.StatusSeeOther)
app.serverError(w, r, err)
return
}
http.Redirect(w, r, fmt.Sprintf("/guestbooks/%s", rawGbId), http.StatusSeeOther)
} }

View File

@ -1,11 +1,14 @@
package main package main
import ( import (
"encoding/base64" "errors"
"fmt" "fmt"
"math"
"net/http" "net/http"
"strconv"
"time"
"github.com/google/uuid" "github.com/gorilla/schema"
) )
func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) { 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) { func (app *application) nextSequence () uint16 {
b, err := id.MarshalBinary() val := app.sequence
if err != nil { if app.sequence == math.MaxUint16 {
return "", err app.sequence = 0
} else {
app.sequence += 1
} }
s := base64.RawURLEncoding.EncodeToString(b) return val
return s, nil
} }
func decodeIdB64 (id string) (uuid.UUID, error) { func (app *application) createShortId () uint64 {
b, err := base64.RawURLEncoding.DecodeString(id) now := time.Now().UTC()
var u uuid.UUID epoch, err := time.Parse(time.RFC822Z, "01 Jan 20 00:00 -0000")
if err != nil { if err != nil {
return u, err fmt.Println(err)
return 0
} }
err = u.UnmarshalBinary(b) d := now.Sub(epoch)
if err != nil { ms := d.Milliseconds()
return u, err seq := app.nextSequence()
} return (uint64(ms) & 0x0FFFFFFFFFFFFFFF) | (uint64(seq) << 48)
return u, nil }
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 package main
import ( import (
"crypto/tls"
"database/sql" "database/sql"
"flag" "flag"
"log/slog" "log/slog"
@ -12,17 +13,19 @@ import (
"git.32bit.cafe/32bitcafe/guestbook/internal/models" "git.32bit.cafe/32bitcafe/guestbook/internal/models"
"github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/google/uuid" "github.com/gorilla/schema"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
type application struct { type application struct {
sequence uint16
logger *slog.Logger logger *slog.Logger
templateCache map[string]*template.Template templateCache map[string]*template.Template
guestbooks *models.GuestbookModel guestbooks *models.GuestbookModel
users *models.UserModel users *models.UserModel
guestbookComments *models.GuestbookCommentModel guestbookComments *models.GuestbookCommentModel
sessionManager *scs.SessionManager sessionManager *scs.SessionManager
formDecoder *schema.Decoder
} }
func main() { func main() {
@ -49,18 +52,37 @@ func main() {
sessionManager.Store = sqlite3store.New(db) sessionManager.Store = sqlite3store.New(db)
sessionManager.Lifetime = 12 * time.Hour sessionManager.Lifetime = 12 * time.Hour
formDecoder := schema.NewDecoder()
formDecoder.IgnoreUnknownKeys(true)
app := &application{ app := &application{
sequence: 0,
templateCache: templateCache, templateCache: templateCache,
logger: logger, logger: logger,
sessionManager: sessionManager, sessionManager: sessionManager,
guestbooks: &models.GuestbookModel{DB: db}, guestbooks: &models.GuestbookModel{DB: db},
users: &models.UserModel{DB: db}, users: &models.UserModel{DB: db},
guestbookComments: &models.GuestbookCommentModel{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)) 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()) logger.Error(err.Error())
os.Exit(1) os.Exit(1)
} }
@ -76,7 +98,6 @@ func openDB(dsn string) (*sql.DB, error) {
return db, nil return db, nil
} }
func getUserId() uuid.UUID { func getUserId() int64 {
userId, _ := decodeIdB64("laINnbnkTtyN5SYoCfSbXw") return 1
return userId
} }

View File

@ -1,8 +1,11 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"github.com/justinas/nosurf"
) )
func (app *application) logRequest (next http.Handler) http.Handler { 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("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("Referrer-Policy", "origin-when-cross-origin")
w.Header().Set("X-Content-Type-Options", "nosniff") 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") w.Header().Set("X-XSS-Protection", "0")
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
@ -41,3 +44,45 @@ func (app *application) recoverPanic(next http.Handler) http.Handler {
next.ServeHTTP(w, r) 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

@ -1,7 +1,9 @@
package main package main
import ( import (
"net/http" "net/http"
"github.com/justinas/alice"
) )
func (app *application) routes() http.Handler { func (app *application) routes() http.Handler {
@ -9,17 +11,28 @@ func (app *application) routes() http.Handler {
fileServer := http.FileServer(http.Dir("./ui/static")) fileServer := http.FileServer(http.Dir("./ui/static"))
mux.Handle("GET /static/", http.StripPrefix("/static", fileServer)) mux.Handle("GET /static/", http.StripPrefix("/static", fileServer))
mux.Handle("/{$}", app.sessionManager.LoadAndSave(http.HandlerFunc(app.home))) dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)
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("/{$}", dynamic.ThenFunc(app.home))
mux.Handle("GET /users/register", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getUserRegister))) mux.Handle("POST /guestbooks/{id}/comments/create", dynamic.ThenFunc(app.postGuestbookCommentCreate))
mux.Handle("POST /users/register", app.sessionManager.LoadAndSave(http.HandlerFunc(app.postUserRegister))) mux.Handle("GET /guestbooks/{id}", dynamic.ThenFunc(app.getGuestbook))
mux.Handle("GET /guestbooks", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getGuestbookList))) mux.Handle("GET /users/register", dynamic.ThenFunc(app.getUserRegister))
mux.Handle("GET /guestbooks/{id}", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getGuestbook))) mux.Handle("POST /users/register", dynamic.ThenFunc(app.postUserRegister))
mux.Handle("GET /guestbooks/create", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getGuestbookCreate))) mux.Handle("GET /users/login", dynamic.ThenFunc(app.getUserLogin))
mux.Handle("POST /guestbooks/create", app.sessionManager.LoadAndSave(http.HandlerFunc(app.postGuestbookCreate))) mux.Handle("POST /users/login", dynamic.ThenFunc(app.postUserLogin))
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))) protected := dynamic.Append(app.requireAuthentication)
return app.recoverPanic(app.logRequest(commonHeaders(mux)))
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" "time"
"git.32bit.cafe/32bitcafe/guestbook/internal/models" "git.32bit.cafe/32bitcafe/guestbook/internal/models"
"github.com/justinas/nosurf"
) )
type templateData struct { type templateData struct {
@ -18,6 +19,9 @@ type templateData struct {
Comment models.GuestbookComment Comment models.GuestbookComment
Comments []models.GuestbookComment Comments []models.GuestbookComment
Flash string Flash string
Form any
IsAuthenticated bool
CSRFToken string
} }
func humanDate(t time.Time) string { func humanDate(t time.Time) string {
@ -26,8 +30,8 @@ func humanDate(t time.Time) string {
var functions = template.FuncMap { var functions = template.FuncMap {
"humanDate": humanDate, "humanDate": humanDate,
"encodeId": encodeIdB64, "shortIdToSlug": shortIdToSlug,
"decodeId": decodeIdB64, "slugToShortId": slugToShortId,
} }
func newTemplateCache() (map[string]*template.Template, error) { func newTemplateCache() (map[string]*template.Template, error) {
@ -59,5 +63,7 @@ func (app *application) newTemplateData(r *http.Request) templateData {
return templateData { return templateData {
CurrentYear: time.Now().Year(), CurrentYear: time.Now().Year(),
Flash: app.sessionManager.PopString(r.Context(), "flash"), Flash: app.sessionManager.PopString(r.Context(), "flash"),
IsAuthenticated: app.isAuthenticated(r),
CSRFToken: nosurf.Token(r),
} }
} }

View File

@ -1,14 +1,20 @@
CREATE TABLE users ( CREATE TABLE users (
Id blob(16) primary key, Id integer primary key autoincrement,
ShortId integer UNIQUE NOT NULL,
Username varchar(32) NOT NULL, Username varchar(32) NOT NULL,
Email varchar(256) NOT NULL, Email varchar(256) UNIQUE NOT NULL,
IsDeleted boolean NOT NULL DEFAULT FALSE IsDeleted boolean NOT NULL DEFAULT FALSE,
) WITHOUT ROWID; IsBanned boolean NOT NULL DEFAULT FALSE,
HashedPassword char(60) NOT NULL,
Created datetime NOT NULL
);
CREATE TABLE guestbooks ( CREATE TABLE guestbooks (
Id blob(16) primary key, Id integer primary key autoincrement,
ShortId integer UNIQUE NOT NULL,
SiteUrl varchar(512) NOT NULL, SiteUrl varchar(512) NOT NULL,
UserId blob(16) NOT NULL, UserId blob(16) NOT NULL,
Created datetime NOT NULL,
IsDeleted boolean NOT NULL DEFAULT FALSE, IsDeleted boolean NOT NULL DEFAULT FALSE,
IsActive boolean NOT NULL DEFAULT TRUE, IsActive boolean NOT NULL DEFAULT TRUE,
FOREIGN KEY (UserId) REFERENCES users(Id) FOREIGN KEY (UserId) REFERENCES users(Id)
@ -17,7 +23,8 @@ CREATE TABLE guestbooks (
); );
CREATE TABLE guestbook_comments ( CREATE TABLE guestbook_comments (
Id blob(16) primary key, Id integer primary key autoincrement,
ShortId integer UNIQUE NOT NULL,
GuestbookId blob(16) NOT NULL, GuestbookId blob(16) NOT NULL,
ParentId blob(16), ParentId blob(16),
AuthorName varchar(256) NOT NULL, AuthorName varchar(256) NOT NULL,
@ -25,6 +32,7 @@ CREATE TABLE guestbook_comments (
AuthorSite varchar(256), AuthorSite varchar(256),
CommentText text NOT NULL, CommentText text NOT NULL,
PageUrl varchar(256), PageUrl varchar(256),
Created datetime NOT NULL,
IsPublished boolean NOT NULL DEFAULT TRUE, IsPublished boolean NOT NULL DEFAULT TRUE,
IsDeleted boolean NOT NULL DEFAULT FALSE, IsDeleted boolean NOT NULL DEFAULT FALSE,
FOREIGN KEY (GuestbookId) FOREIGN KEY (GuestbookId)

7
go.mod
View File

@ -8,3 +8,10 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.6 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 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" 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 ( import (
"database/sql" "database/sql"
"time"
"github.com/google/uuid"
) )
type Guestbook struct { type Guestbook struct {
ID uuid.UUID ID int64
ShortId uint64
SiteUrl string SiteUrl string
UserId uuid.UUID UserId int64
Created time.Time
IsDeleted bool IsDeleted bool
IsActive bool IsActive bool
} }
@ -18,23 +19,26 @@ type GuestbookModel struct {
DB *sql.DB DB *sql.DB
} }
func (m *GuestbookModel) Insert(siteUrl string, userId uuid.UUID) (uuid.UUID, error) { func (m *GuestbookModel) Insert(shortId uint64, siteUrl string, userId int64) (int64, error) {
id := uuid.New() stmt := `INSERT INTO guestbooks (ShortId, SiteUrl, UserId, Created, IsDeleted, IsActive)
stmt := `INSERT INTO guestbooks (Id, SiteUrl, UserId, IsDeleted, IsActive) VALUES(?, ?, ?, ?, FALSE, TRUE)`
VALUES(?, ?, ?, FALSE, TRUE)` result, err := m.DB.Exec(stmt, shortId, siteUrl, userId, time.Now().UTC())
_, err := m.DB.Exec(stmt, id, siteUrl, userId)
if err != nil { if err != nil {
return uuid.UUID{}, err return -1, err
}
id, err := result.LastInsertId()
if err != nil {
return -1, err
} }
return id, nil return id, nil
} }
func (m *GuestbookModel) Get(id uuid.UUID) (Guestbook, error) { func (m *GuestbookModel) Get(shortId uint64) (Guestbook, error) {
stmt := `SELECT Id, SiteUrl, UserId, IsDeleted, IsActive FROM guestbooks stmt := `SELECT Id, ShortId, SiteUrl, UserId, Created, IsDeleted, IsActive FROM guestbooks
WHERE id = ?` WHERE ShortId = ?`
row := m.DB.QueryRow(stmt, id) row := m.DB.QueryRow(stmt, shortId)
var g Guestbook 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 { if err != nil {
return Guestbook{}, err return Guestbook{}, err
} }
@ -42,8 +46,8 @@ func (m *GuestbookModel) Get(id uuid.UUID) (Guestbook, error) {
return g, nil return g, nil
} }
func (m *GuestbookModel) GetAll(userId uuid.UUID) ([]Guestbook, error) { func (m *GuestbookModel) GetAll(userId int64) ([]Guestbook, error) {
stmt := `SELECT Id, SiteUrl, UserId, IsDeleted, IsActive FROM guestbooks stmt := `SELECT Id, ShortId, SiteUrl, UserId, Created, IsDeleted, IsActive FROM guestbooks
WHERE UserId = ?` WHERE UserId = ?`
rows, err := m.DB.Query(stmt, userId) rows, err := m.DB.Query(stmt, userId)
if err != nil { if err != nil {
@ -52,7 +56,7 @@ func (m *GuestbookModel) GetAll(userId uuid.UUID) ([]Guestbook, error) {
var guestbooks []Guestbook var guestbooks []Guestbook
for rows.Next() { for rows.Next() {
var g Guestbook 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -2,19 +2,20 @@ package models
import ( import (
"database/sql" "database/sql"
"time"
"github.com/google/uuid"
) )
type GuestbookComment struct { type GuestbookComment struct {
ID uuid.UUID ID int64
GuestbookId uuid.UUID ShortId uint64
ParentId uuid.UUID GuestbookId int64
ParentId int64
AuthorName string AuthorName string
AuthorEmail string AuthorEmail string
AuthorSite string AuthorSite string
CommentText string CommentText string
PageUrl string PageUrl string
Created time.Time
IsPublished bool IsPublished bool
IsDeleted bool IsDeleted bool
} }
@ -23,35 +24,38 @@ type GuestbookCommentModel struct {
DB *sql.DB DB *sql.DB
} }
func (m *GuestbookCommentModel) Insert(guestbookId, parentId uuid.UUID, authorName, func (m *GuestbookCommentModel) Insert(shortId uint64, guestbookId, parentId int64, authorName,
authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (uuid.UUID, error) { authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (int64, error) {
id := uuid.New() stmt := `INSERT INTO guestbook_comments (ShortId, GuestbookId, ParentId, AuthorName,
stmt := `INSERT INTO guestbook_comments (Id, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite, CommentText, PageUrl, Created, IsPublished, IsDeleted)
AuthorEmail, AuthorSite, CommentText, PageUrl, IsPublished, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE)`
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE)` result, err := m.DB.Exec(stmt, shortId, guestbookId, parentId, authorName, authorEmail,
_, err := m.DB.Exec(stmt, id, guestbookId, parentId, authorName, authorEmail, authorSite, commentText, pageUrl, time.Now().UTC(), isPublished)
authorSite, commentText, pageUrl, isPublished)
if err != nil { if err != nil {
return uuid.UUID{}, err return -1, err
}
id, err := result.LastInsertId()
if err != nil {
return -1, err
} }
return id, nil return id, nil
} }
func (m *GuestbookCommentModel) Get(id uuid.UUID) (GuestbookComment, error) { func (m *GuestbookCommentModel) Get(shortId uint64) (GuestbookComment, error) {
stmt := `SELECT Id, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite, stmt := `SELECT Id, ShortId, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite,
CommentText, PageUrl, IsPublished, IsDeleted FROM guestbook_comments WHERE id = ?` CommentText, PageUrl, Created, IsPublished, IsDeleted FROM guestbook_comments WHERE ShortId = ?`
row := m.DB.QueryRow(stmt, id) row := m.DB.QueryRow(stmt, shortId)
var c GuestbookComment 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 { if err != nil {
return GuestbookComment{}, err return GuestbookComment{}, err
} }
return c, nil return c, nil
} }
func (m *GuestbookCommentModel) GetAll(guestbookId uuid.UUID) ([]GuestbookComment, error) { func (m *GuestbookCommentModel) GetAll(guestbookId int64) ([]GuestbookComment, error) {
stmt := `SELECT Id, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite, stmt := `SELECT Id, ShortId, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite,
CommentText, PageUrl, IsPublished, IsDeleted FROM guestbook_comments WHERE GuestbookId = ?` CommentText, PageUrl, Created, IsPublished, IsDeleted FROM guestbook_comments WHERE GuestbookId = ? AND IsDeleted = FALSE ORDER BY Created DESC`
rows, err := m.DB.Query(stmt, guestbookId) rows, err := m.DB.Query(stmt, guestbookId)
if err != nil { if err != nil {
return nil, err return nil, err
@ -59,7 +63,7 @@ func (m *GuestbookCommentModel) GetAll(guestbookId uuid.UUID) ([]GuestbookCommen
var comments []GuestbookComment var comments []GuestbookComment
for rows.Next() { for rows.Next() {
var c GuestbookComment 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -1,39 +1,68 @@
package models package models
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"strings"
"time"
"github.com/google/uuid" "github.com/mattn/go-sqlite3"
"golang.org/x/crypto/bcrypt"
) )
type User struct { type User struct {
ID uuid.UUID ID int
ShortId uint64
Username string Username string
Email string Email string
IsDeleted bool IsDeleted bool
IsBanned bool
HashedPassword []byte
Created time.Time
} }
type UserModel struct { type UserModel struct {
DB *sql.DB DB *sql.DB
} }
func (m *UserModel) Insert(username string, email string) (uuid.UUID, error) { func (m *UserModel) Insert(shortId uint64, username string, email string, password string) error {
id := uuid.New() hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
stmt := `INSERT INTO users (Id, Username, Email, IsDeleted)
VALUES (?, ?, ?, FALSE)`
_, err := m.DB.Exec(stmt, id, username, email)
if err != nil { 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) { func (m *UserModel) Get(id uint64) (User, error) {
stmt := `SELECT Id, Username, Email, IsDeleted FROM users WHERE id = ?` stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE ShortId = ? AND IsDeleted = FALSE`
row := m.DB.QueryRow(stmt, id) row := m.DB.QueryRow(stmt, id)
var u User 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 err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNoRecord return User{}, ErrNoRecord
@ -44,12 +73,12 @@ func (m *UserModel) Get(id uuid.UUID) (User, error) {
} }
func (m *UserModel) GetAll() ([]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) rows, err := m.DB.Query(stmt)
var users []User var users []User
for rows.Next() { for rows.Next() {
var u User 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 { if err != nil {
return nil, err return nil, err
} }
@ -60,3 +89,36 @@ func (m *UserModel) GetAll() ([]User, error) {
} }
return users, nil 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 package validator
import ( import (
"regexp"
"slices" "slices"
"strings" "strings"
"unicode/utf8" "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 { type Validator struct {
NonFieldErrors []string
FieldErrors map[string]string FieldErrors map[string]string
} }
func (v *Validator) Valid() bool { 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) { 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) { func (v *Validator) CheckField(ok bool, key, message string) {
if !ok { if !ok {
v.AddFieldError(key, message) v.AddFieldError(key, message)
@ -40,3 +48,11 @@ func MaxChars(value string, n int) bool {
func PermittedValue[T comparable](value T, permittedValues ...T) bool { func PermittedValue[T comparable](value T, permittedValues ...T) bool {
return slices.Contains(permittedValues, value) 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" . }} {{ template "main" . }}
</main> </main>
<footer> <footer>
<p>A 32-bit Cafe Project</p> <p>A 32bit.cafe Project</p>
</footer> </footer>
</body> </body>
</html> </html>

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
{{ define "title" }} Guestbooks {{ end }} {{ define "title" }} Guestbooks {{ end }}
{{ define "main" }} {{ define "main" }}
<h1>Guestbooks run by ---</h1> <h1>Guestbooks run by {{ .User.Username }}</h1>
<ul> <ul>
{{ range .Guestbooks }} {{ 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 }} {{ else }}
<p>No Guestbooks yet</p> <p>No Guestbooks yet</p>
{{ end }} {{ 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 "title" }}User Registration{{ end }}
{{ define "main" }} {{ define "main" }}
<form action="/users/register" method="post"> <form action="/users/register" method="post">
<label for="username">Username: </label> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="text" name="username" id="username" required /> <div>
<label for="email">Email: </label> <label for="username">Username: </label>
<input type="text" name="email" id="email" required /> {{ with .Form.FieldErrors.name }}
<input type="submit" /> <label class="error">{{.}}</label>
{{ end }}
<input type="text" name="username" id="username" value="{{ .Form.Name }}" required />
</div>
<div>
<label for="email">Email: </label>
{{ 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> </form>
{{ end }} {{ end }}

View File

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

View File

@ -1,7 +1,22 @@
{{ define "nav" }} {{ define "nav" }}
<nav> <nav>
<div>
<a href="/">Home</a> <a href="/">Home</a>
<a href="/users/register">Register</a>
<a href="/users">Users</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> </nav>
{{ end }} {{ end }}