diff --git a/.gitignore b/.gitignore index e6c17e1..deca6d6 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ go.work # sqlite3 databases *.db + +# air config +.air.toml +/tmp diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index 04be353..59d139d 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -1,30 +1,222 @@ package main import ( - "net/http" + "errors" + "fmt" + "net/http" + "strings" + "unicode/utf8" + + "git.32bit.cafe/32bitcafe/guestbook/internal/models" + "github.com/google/uuid" ) func (app *application) home(w http.ResponseWriter, r *http.Request) { app.render(w, r, http.StatusOK, "home.tmpl.html", templateData{}) } -func 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{}) } -func postUserRegister(w http.ResponseWriter, r *http.Request) { +func (app *application) postUserRegister(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + app.serverError(w, r, err) + } + username := r.Form.Get("username") + email := r.Form.Get("email") + rawid, err := app.users.Insert(username, email) + if err != nil { + app.serverError(w, r, err) + return + } + id, err := encodeIdB64(rawid) + if err != nil { + app.serverError(w, r, err) + return + } + http.Redirect(w, r, fmt.Sprintf("/users/%s", id), http.StatusSeeOther) } -func getUsersList(w http.ResponseWriter, r *http.Request) { +func (app *application) getUsersList(w http.ResponseWriter, r *http.Request) { + users, err := app.users.GetAll() + if err != nil { + app.serverError(w, r, err) + return + } + app.render(w, r, http.StatusOK, "userlist.view.tmpl.html", templateData{ + Users: users, + }) } -func getUser(w http.ResponseWriter, r *http.Request) { +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) + if err != nil { + if errors.Is(err, models.ErrNoRecord) { + http.NotFound(w, r) + } else { + app.serverError(w, r, err) + } + return + } + app.render(w, r, http.StatusOK, "user.view.tmpl.html", templateData{ + User: user, + }) } -func postGuestbooksCreate(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{}) } -func getGuestbooksList(w http.ResponseWriter, r *http.Request) { +func (app *application) postGuestbookCreate(w http.ResponseWriter, r* http.Request) { + err := r.ParseForm() + if err != nil { + app.serverError(w, r, err) + 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) + 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) } -func getGuestbook(w http.ResponseWriter, r *http.Request) { +func (app *application) getGuestbookList(w http.ResponseWriter, r *http.Request) { + userId := getUserId() + 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) + if err != nil { + app.serverError(w, r, err) + return + } + guestbook, err := app.guestbooks.Get(id) + if err != nil { + if errors.Is(err, models.ErrNoRecord) { + http.NotFound(w, r) + } else { + app.serverError(w, r, err) + } + return + } + comments, err := app.guestbookComments.GetAll(id) + if err != nil { + app.serverError(w, r, err) + return + } + data := app.newTemplateData(r) + data.Guestbook = guestbook + data.Comments = comments + app.render(w, r, http.StatusOK, "guestbook.view.tmpl.html", data) +} + +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) + if err != nil { + if errors.Is(err, models.ErrNoRecord) { + http.NotFound(w, r) + } else { + app.serverError(w, r, err) + } + return + } + comments, err := app.guestbookComments.GetAll(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, + }) +} + +func (app *application) getGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) { + rawId := r.PathValue("id") + id, err := decodeIdB64(rawId) + if err != nil { + http.NotFound(w, r) + } + app.render(w, r, http.StatusOK, "commentcreate.view.tmpl.html", templateData{ + Guestbook: models.Guestbook{ + ID: id, + }, + }) +} + +func (app *application) postGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) { + rawGbId := r.PathValue("id") + gbId, err := decodeIdB64(rawGbId) + if err != nil { + app.serverError(w, r, err) + return + } + err = r.ParseForm() + 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) + return + } + + commentId, err := app.guestbookComments.Insert(gbId, uuid.UUID{}, authorName, authorEmail, authorSite, 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) } diff --git a/cmd/web/helpers.go b/cmd/web/helpers.go index 16d5bda..0d1cbbf 100644 --- a/cmd/web/helpers.go +++ b/cmd/web/helpers.go @@ -1,8 +1,11 @@ package main import ( - "net/http" - "fmt" + "encoding/base64" + "fmt" + "net/http" + + "github.com/google/uuid" ) func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) { @@ -33,3 +36,25 @@ func (app *application) render(w http.ResponseWriter, r *http.Request, status in app.serverError(w, r, err) } } + +func encodeIdB64 (id uuid.UUID) (string, error) { + b, err := id.MarshalBinary() + if err != nil { + return "", err + } + s := base64.RawURLEncoding.EncodeToString(b) + return s, nil +} + +func decodeIdB64 (id string) (uuid.UUID, error) { + b, err := base64.RawURLEncoding.DecodeString(id) + var u uuid.UUID + if err != nil { + return u, err + } + err = u.UnmarshalBinary(b) + if err != nil { + return u, err + } + return u, nil +} diff --git a/cmd/web/main.go b/cmd/web/main.go index 8af985d..32cf44f 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -7,9 +7,13 @@ import ( "net/http" "os" "text/template" + "time" "git.32bit.cafe/32bitcafe/guestbook/internal/models" - _ "modernc.org/sqlite" + "github.com/alexedwards/scs/sqlite3store" + "github.com/alexedwards/scs/v2" + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" ) type application struct { @@ -18,6 +22,7 @@ type application struct { guestbooks *models.GuestbookModel users *models.UserModel guestbookComments *models.GuestbookCommentModel + sessionManager *scs.SessionManager } func main() { @@ -25,7 +30,7 @@ func main() { dsn := flag.String("dsn", "guestbook.db", "data source name") flag.Parse() - logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) db, err := openDB(*dsn) if err != nil { @@ -40,15 +45,20 @@ func main() { os.Exit(1) } + sessionManager := scs.New() + sessionManager.Store = sqlite3store.New(db) + sessionManager.Lifetime = 12 * time.Hour + app := &application{ templateCache: templateCache, logger: logger, + sessionManager: sessionManager, guestbooks: &models.GuestbookModel{DB: db}, users: &models.UserModel{DB: db}, guestbookComments: &models.GuestbookCommentModel{DB: db}, } - logger.Info("Starting server on %s", slog.Any("addr", ":4000")) + logger.Info("Starting server", slog.Any("addr", *addr)) err = http.ListenAndServe(*addr, app.routes()); logger.Error(err.Error()) @@ -56,7 +66,7 @@ func main() { } func openDB(dsn string) (*sql.DB, error) { - db, err := sql.Open("sqlite", dsn) + db, err := sql.Open("sqlite3", dsn) if err != nil { return nil, err } @@ -65,3 +75,8 @@ func openDB(dsn string) (*sql.DB, error) { } return db, nil } + +func getUserId() uuid.UUID { + userId, _ := decodeIdB64("laINnbnkTtyN5SYoCfSbXw") + return userId +} diff --git a/cmd/web/middleware.go b/cmd/web/middleware.go new file mode 100644 index 0000000..5c92c2f --- /dev/null +++ b/cmd/web/middleware.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "net/http" +) + +func (app *application) logRequest (next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + ip = r.RemoteAddr + proto = r.Proto + method = r.Method + uri = r.URL.RequestURI() + ) + app.logger.Info("received request", "ip", ip, "proto", proto, "method", method, "uri", uri) + next.ServeHTTP(w, r) + }) +} + +func commonHeaders (next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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-XSS-Protection", "0") + next.ServeHTTP(w, r) + }) +} + +func (app *application) recoverPanic(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + w.Header().Set("Connection", "close") + app.serverError(w, r, fmt.Errorf("%s", err)) + } + }() + + next.ServeHTTP(w, r) + }) +} diff --git a/cmd/web/routes.go b/cmd/web/routes.go index c426ba3..aebc600 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -4,9 +4,22 @@ import ( "net/http" ) -func (app *application) routes() *http.ServeMux { +func (app *application) routes() http.Handler { mux := http.NewServeMux() - mux.HandleFunc("/", app.home); - return mux + 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))) } diff --git a/cmd/web/templates.go b/cmd/web/templates.go index 9e03d1a..9458f84 100644 --- a/cmd/web/templates.go +++ b/cmd/web/templates.go @@ -1,14 +1,23 @@ package main import ( - "net/http" - "path/filepath" - "text/template" - "time" + "net/http" + "path/filepath" + "text/template" + "time" + + "git.32bit.cafe/32bitcafe/guestbook/internal/models" ) type templateData struct { CurrentYear int + User models.User + Users []models.User + Guestbook models.Guestbook + Guestbooks []models.Guestbook + Comment models.GuestbookComment + Comments []models.GuestbookComment + Flash string } func humanDate(t time.Time) string { @@ -17,6 +26,8 @@ func humanDate(t time.Time) string { var functions = template.FuncMap { "humanDate": humanDate, + "encodeId": encodeIdB64, + "decodeId": decodeIdB64, } func newTemplateCache() (map[string]*template.Template, error) { @@ -44,8 +55,9 @@ func newTemplateCache() (map[string]*template.Template, error) { return cache, nil } -func (app *application) newTemplateData(r *http.Request) *templateData { - return &templateData { +func (app *application) newTemplateData(r *http.Request) templateData { + return templateData { CurrentYear: time.Now().Year(), + Flash: app.sessionManager.PopString(r.Context(), "flash"), } } diff --git a/db/create-session-table-sqlite.sql b/db/create-session-table-sqlite.sql new file mode 100644 index 0000000..5dba4f5 --- /dev/null +++ b/db/create-session-table-sqlite.sql @@ -0,0 +1,5 @@ +CREATE TABLE sessions ( + token CHAR(43) primary key, + data BLOB NOT NULL, + expiry TEXT NOT NULL +); diff --git a/go.mod b/go.mod index 61161d3..a723110 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,8 @@ module git.32bit.cafe/32bitcafe/guestbook go 1.23.1 require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.22.0 // indirect - modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect - modernc.org/libc v1.55.3 // indirect - modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.8.0 // indirect - modernc.org/sqlite v1.33.1 // indirect - modernc.org/strutil v1.2.0 // indirect - modernc.org/token v1.1.0 // indirect + github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 + github.com/alexedwards/scs/v2 v2.8.0 + github.com/google/uuid v1.6.0 + github.com/mattn/go-sqlite3 v1.14.6 ) diff --git a/go.sum b/go.sum index db23520..c8a1188 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,8 @@ -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 h1:+DCxWg/ojncqS+TGAuRUoV7OfG/S4doh0pcpAwEcow0= +github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss= +github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw= +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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= -modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= -modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= -modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= -modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= -modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= -modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +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= diff --git a/internal/models/errors.go b/internal/models/errors.go new file mode 100644 index 0000000..c7845a7 --- /dev/null +++ b/internal/models/errors.go @@ -0,0 +1,5 @@ +package models + +import "errors" + +var ErrNoRecord = errors.New("models: no matching record found") diff --git a/internal/models/guestbook.go b/internal/models/guestbook.go index 09875ce..581def3 100644 --- a/internal/models/guestbook.go +++ b/internal/models/guestbook.go @@ -21,7 +21,7 @@ type GuestbookModel struct { 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)` + VALUES(?, ?, ?, FALSE, TRUE)` _, err := m.DB.Exec(stmt, id, siteUrl, userId) if err != nil { return uuid.UUID{}, err @@ -43,5 +43,23 @@ func (m *GuestbookModel) Get(id uuid.UUID) (Guestbook, error) { } func (m *GuestbookModel) GetAll(userId uuid.UUID) ([]Guestbook, error) { - return []Guestbook{}, nil + stmt := `SELECT Id, SiteUrl, UserId, IsDeleted, IsActive FROM guestbooks + WHERE UserId = ?` + rows, err := m.DB.Query(stmt, userId) + if err != nil { + return nil, err + } + var guestbooks []Guestbook + for rows.Next() { + var g Guestbook + err = rows.Scan(&g.ID, &g.SiteUrl, &g.UserId, &g.IsDeleted, &g.IsActive) + if err != nil { + return nil, err + } + guestbooks = append(guestbooks, g) + } + if err = rows.Err(); err != nil { + return nil, err + } + return guestbooks, nil } diff --git a/internal/models/guestbookcomment.go b/internal/models/guestbookcomment.go index 74685f8..d98a0a9 100644 --- a/internal/models/guestbookcomment.go +++ b/internal/models/guestbookcomment.go @@ -49,5 +49,24 @@ func (m *GuestbookCommentModel) Get(id uuid.UUID) (GuestbookComment, error) { return c, nil } -func (m *GuestbookCommentModel) GetAll(guestbookId *uuid.UUID) { +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 = ?` + rows, err := m.DB.Query(stmt, guestbookId) + if err != nil { + return nil, err + } + 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) + if err != nil { + return nil, err + } + comments = append(comments, c) + } + if err = rows.Err(); err != nil { + return nil, err + } + return comments, nil } diff --git a/internal/models/user.go b/internal/models/user.go index c7e65cb..01bd73d 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -1,9 +1,10 @@ package models import ( - "database/sql" + "database/sql" + "errors" - "github.com/google/uuid" + "github.com/google/uuid" ) type User struct { @@ -34,7 +35,28 @@ func (m *UserModel) Get(id uuid.UUID) (User, error) { var u User err := row.Scan(&u.ID, &u.Username, &u.Email, &u.IsDeleted) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrNoRecord + } return User{}, err } return u, nil } + +func (m *UserModel) GetAll() ([]User, error) { + stmt := `SELECT Id, Username, Email, IsDeleted FROM users` + 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) + if err != nil { + return nil, err + } + users = append(users, u) + } + if err = rows.Err(); err != nil { + return nil, err + } + return users, nil +} diff --git a/internal/validator/validator.go b/internal/validator/validator.go new file mode 100644 index 0000000..2395653 --- /dev/null +++ b/internal/validator/validator.go @@ -0,0 +1,42 @@ +package validator + +import ( + "slices" + "strings" + "unicode/utf8" +) + +type Validator struct { + FieldErrors map[string]string +} + +func (v *Validator) Valid() bool { + return len(v.FieldErrors) == 0 +} + +func (v *Validator) AddFieldError(key, message string) { + if v.FieldErrors == nil { + v.FieldErrors = make(map[string]string) + } + if _, exists := v.FieldErrors[key]; !exists { + v.FieldErrors[key] = message + } +} + +func (v *Validator) CheckField(ok bool, key, message string) { + if !ok { + v.AddFieldError(key, message) + } +} + +func NotBlank(value string) bool { + return strings.TrimSpace(value) != "" +} + +func MaxChars(value string, n int) bool { + return utf8.RuneCountInString(value) <= n +} + +func PermittedValue[T comparable](value T, permittedValues ...T) bool { + return slices.Contains(permittedValues, value) +} diff --git a/ui/html/base.tmpl.html b/ui/html/base.tmpl.html index 674d9e6..d16d648 100644 --- a/ui/html/base.tmpl.html +++ b/ui/html/base.tmpl.html @@ -5,7 +5,7 @@ {{ template "title" }} - Guestbook - +
@@ -13,6 +13,9 @@
{{ template "nav" . }}
+ {{ with .Flash }} +
{{ . }}
+ {{ end }} {{ template "main" . }}