diff --git a/cmd/web/handlers_guestbook.go b/cmd/web/handlers_guestbook.go index 92b285a..5234eb4 100644 --- a/cmd/web/handlers_guestbook.go +++ b/cmd/web/handlers_guestbook.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "errors" "fmt" "net/http" @@ -136,6 +137,30 @@ func (app *application) getGuestbookComments(w http.ResponseWriter, r *http.Requ views.GuestbookDashboardCommentsView("Comments", data, website, website.Guestbook, comments).Render(r.Context(), w) } +func (app *application) getGuestbookCommentsSerialized(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("id") + website, err := app.websites.Get(slugToShortId(slug)) + if err != nil { + if errors.Is(err, models.ErrNoRecord) { + http.NotFound(w, r) + } else { + app.serverError(w, r, err) + } + return + } + if !website.Guestbook.Settings.IsVisible || !website.Guestbook.Settings.AllowRemoteHostAccess { + app.clientError(w, http.StatusForbidden) + } + comments, err := app.guestbookComments.GetAllSerialized(website.Guestbook.ID) + if err != nil { + app.serverError(w, r, err) + return + } + + b, err := json.Marshal(comments) + w.Write(b) +} + func (app *application) getGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) { // TODO: This will be the embeddable form slug := r.PathValue("id") @@ -148,13 +173,21 @@ func (app *application) getGuestbookCommentCreate(w http.ResponseWriter, r *http } return } + s := website.Guestbook.Settings + if !s.IsVisible || !s.AllowRemoteHostAccess || !website.Guestbook.CanComment() { + app.clientError(w, http.StatusForbidden) + } data := app.newCommonData(r) form := forms.CommentCreateForm{} - views.CreateGuestbookComment("New Comment", data, website, website.Guestbook, form).Render(r.Context(), w) + views.EmbeddableGuestbookCommentForm(data, website, form).Render(r.Context(), w) } func (app *application) postGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("id") + headless, err := strconv.ParseBool(r.URL.Query().Get("headless")) + if err != nil { + headless = false + } website, err := app.websites.Get(slugToShortId(slug)) if err != nil { if errors.Is(err, models.ErrNoRecord) { @@ -184,14 +217,17 @@ func (app *application) postGuestbookCommentCreate(w http.ResponseWriter, r *htt form.CheckField(validator.NotBlank(form.Content), "content", "This field cannot be blank") if !form.Valid() { + data := app.newCommonData(r) + w.WriteHeader(http.StatusUnprocessableEntity) + if headless { + views.EmbeddableGuestbookCommentForm(data, website, form).Render(r.Context(), w) + } // TODO: use htmx to avoid getting comments again comments, err := app.guestbookComments.GetAll(website.Guestbook.ID) if err != nil { app.serverError(w, r, err) return } - data := app.newCommonData(r) - w.WriteHeader(http.StatusUnprocessableEntity) views.GuestbookView("Guestbook", data, website, website.Guestbook, comments, form).Render(r.Context(), w) return } @@ -203,9 +239,70 @@ func (app *application) postGuestbookCommentCreate(w http.ResponseWriter, r *htt return } app.sessionManager.Put(r.Context(), "flash", "Comment successfully posted!") + if headless { + http.Redirect(w, r, fmt.Sprintf("/websites/%s/guestbook/comments/create", slug), http.StatusSeeOther) + } http.Redirect(w, r, fmt.Sprintf("/websites/%s/guestbook", slug), http.StatusSeeOther) } +func (app *application) postGuestbookCommentCreateRemote(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("id") + website, err := app.websites.Get(slugToShortId(slug)) + if err != nil { + if errors.Is(err, models.ErrNoRecord) { + http.NotFound(w, r) + } else { + app.serverError(w, r, err) + } + return + } + + if !matchOrigin(r.Header.Get("Origin"), website.Url) { + app.clientError(w, http.StatusForbidden) + return + } + if !website.Guestbook.CanComment() { + app.clientError(w, http.StatusForbidden) + return + } + + var form forms.CommentCreateForm + err = app.decodePostForm(r, &form) + if err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + + 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.MaxChars(form.AuthorEmail, 256), "authorEmail", "This field cannot be more than 256 characters long") + 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 redirect path is filled out, redirect to that path on the website host + // otherwise redirect to the guestbook by default + redirectUrl := fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(website.ShortId)) + if form.Redirect != "" { + u, err := website.Url.Parse(form.Redirect) + if err == nil { + redirectUrl = u.String() + } + } + + if !form.Valid() { + views.GuestbookCommentCreateRemoteErrorView(redirectUrl, "Invalid Input").Render(r.Context(), w) + return + } + + shortId := app.createShortId() + _, err = app.guestbookComments.Insert(shortId, website.Guestbook.ID, 0, form.AuthorName, form.AuthorEmail, form.AuthorSite, form.Content, "", true) + if err != nil { + app.serverError(w, r, err) + return + } + views.GuestbookCommentCreateRemoteSuccessView(redirectUrl).Render(r.Context(), w) +} + func (app *application) getCommentQueue(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("id") website, err := app.websites.Get(slugToShortId(slug)) diff --git a/cmd/web/handlers_guestbook_test.go b/cmd/web/handlers_guestbook_test.go index c96693b..182f081 100644 --- a/cmd/web/handlers_guestbook_test.go +++ b/cmd/web/handlers_guestbook_test.go @@ -144,3 +144,88 @@ func TestPostGuestbookCommentCreate(t *testing.T) { }) } } + +func TestPostGuestbookCommentCreateRemote(t *testing.T) { + app := newTestApplication(t) + ts := newTestServer(t, app.routes()) + defer ts.Close() + + _, _, body := ts.get(t, fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(1))) + validCSRFToken := extractCSRFToken(t, body) + + const ( + validAuthorName = "John Test" + validAuthorEmail = "test@example.com" + validAuthorSite = "example.com" + validContent = "This is a comment" + ) + + tests := []struct { + name string + authorName string + authorEmail string + authorSite string + content string + csrfToken string + wantCode int + }{ + { + name: "Valid input", + authorName: validAuthorName, + authorEmail: validAuthorEmail, + authorSite: validAuthorSite, + content: validContent, + csrfToken: validCSRFToken, + wantCode: http.StatusSeeOther, + }, + { + name: "Blank name", + authorName: "", + authorEmail: validAuthorEmail, + authorSite: validAuthorSite, + content: validContent, + csrfToken: validCSRFToken, + wantCode: http.StatusUnprocessableEntity, + }, + { + name: "Blank email", + authorName: validAuthorName, + authorEmail: "", + authorSite: validAuthorSite, + content: validContent, + csrfToken: validCSRFToken, + wantCode: http.StatusSeeOther, + }, + { + name: "Blank site", + authorName: validAuthorName, + authorEmail: validAuthorEmail, + authorSite: "", + content: validContent, + csrfToken: validCSRFToken, + wantCode: http.StatusSeeOther, + }, + { + name: "Blank content", + authorName: validAuthorName, + authorEmail: validAuthorEmail, + authorSite: validAuthorSite, + content: "", + csrfToken: validCSRFToken, + wantCode: http.StatusUnprocessableEntity, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + form := url.Values{} + form.Add("authorname", tt.authorName) + form.Add("authoremail", tt.authorEmail) + form.Add("authorsite", tt.authorSite) + form.Add("content", tt.content) + form.Add("csrf_token", tt.csrfToken) + code, _, body := ts.postForm(t, fmt.Sprintf("/websites/%s/guestbook/comments/create/remote", shortIdToSlug(1)), form) + assert.Equal(t, code, tt.wantCode) + assert.Equal(t, body, body) + }) + } +} diff --git a/cmd/web/handlers_website.go b/cmd/web/handlers_website.go index cb94c72..a2b8e9e 100644 --- a/cmd/web/handlers_website.go +++ b/cmd/web/handlers_website.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "git.32bit.cafe/32bitcafe/guestbook/internal/forms" "git.32bit.cafe/32bitcafe/guestbook/internal/models" @@ -32,14 +33,20 @@ func (app *application) postWebsiteCreate(w http.ResponseWriter, r *http.Request form.CheckField(validator.MaxChars(form.Name, 256), "sitename", "This field cannot exceed 256 characters") form.CheckField(validator.NotBlank(form.SiteUrl), "siteurl", "This field cannot be blank") form.CheckField(validator.MaxChars(form.SiteUrl, 512), "siteurl", "This field cannot exceed 512 characters") + form.CheckField(validator.Matches(form.SiteUrl, validator.WebRX), "siteurl", "This field must be a valid URL (including http:// or https://)") + u, err := url.Parse(form.SiteUrl) + if err != nil { + form.CheckField(false, "siteurl", "This field must be a valid URL") + } if !form.Valid() { data := app.newCommonData(r) w.WriteHeader(http.StatusUnprocessableEntity) views.WebsiteCreate("Add a Website", data, form).Render(r.Context(), w) + return } websiteShortID := app.createShortId() - _, err = app.websites.Insert(websiteShortID, userId, form.Name, form.SiteUrl, form.AuthorName) + _, err = app.websites.Insert(websiteShortID, userId, form.Name, u.String(), form.AuthorName) if err != nil { app.serverError(w, r, err) return @@ -75,10 +82,6 @@ func (app *application) getWebsiteList(w http.ResponseWriter, r *http.Request) { app.serverError(w, r, err) return } - if r.Header.Get("HX-Request") == "true" { - views.HxWebsiteList(websites) - return - } data := app.newCommonData(r) views.WebsiteList("My Websites", data, websites).Render(r.Context(), w) } diff --git a/cmd/web/helpers.go b/cmd/web/helpers.go index 7d927d1..f86b71b 100644 --- a/cmd/web/helpers.go +++ b/cmd/web/helpers.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "net/http" + "net/url" "runtime/debug" "strconv" "time" @@ -109,6 +110,7 @@ func (app *application) newCommonData(r *http.Request) views.CommonData { CSRFToken: nosurf.Token(r), CurrentUser: app.getCurrentUser(r), IsHtmx: r.Header.Get("Hx-Request") == "true", + RootUrl: app.rootUrl, } } @@ -127,3 +129,14 @@ func (app *application) durationToTime(duration string) (time.Time, error) { result = time.Now().UTC().Add(offset) return result, nil } + +func matchOrigin(origin string, u *url.URL) bool { + o, err := url.Parse(origin) + if err != nil { + return false + } + if o.Host != u.Host { + return false + } + return true +} diff --git a/cmd/web/main.go b/cmd/web/main.go index 7f22d07..5fe6dac 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -28,12 +28,14 @@ type application struct { formDecoder *schema.Decoder debug bool timezones []string + rootUrl string } func main() { addr := flag.String("addr", ":3000", "HTTP network address") dsn := flag.String("dsn", "guestbook.db", "data source name") debug := flag.Bool("debug", false, "enable debug mode") + root := flag.String("root", "localhost:3000", "root URL of application") flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) @@ -62,6 +64,7 @@ func main() { formDecoder: formDecoder, debug: *debug, timezones: getAvailableTimezones(), + rootUrl: *root, } err = app.users.InitializeSettingsMap() diff --git a/cmd/web/middleware.go b/cmd/web/middleware.go index cb43934..c9d83ec 100644 --- a/cmd/web/middleware.go +++ b/cmd/web/middleware.go @@ -8,87 +8,94 @@ import ( "github.com/justinas/nosurf" ) -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 (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 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)) - } - }() + 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) - }) + 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) - }) + 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, - }) + csrfHandler := nosurf.New(next) + csrfHandler.SetBaseCookie(http.Cookie{ + HttpOnly: true, + Path: "/", + Secure: true, + }) - return csrfHandler + 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 - } - user, err := app.users.GetById(id) - if err != nil { - app.serverError(w, r, err) - return - } - if exists { - ctx := context.WithValue(r.Context(), isAuthenticatedContextKey, true) - ctx = context.WithValue(ctx, userNameContextKey, user) - r = r.WithContext(ctx) - } - next.ServeHTTP(w, r) - }) + 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 + } + user, err := app.users.GetById(id) + if err != nil { + app.serverError(w, r, err) + return + } + if exists { + ctx := context.WithValue(r.Context(), isAuthenticatedContextKey, true) + ctx = context.WithValue(ctx, userNameContextKey, user) + r = r.WithContext(ctx) + } + next.ServeHTTP(w, r) + }) +} + +func (app *application) enableCors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + next.ServeHTTP(w, r) + }) } diff --git a/cmd/web/routes.go b/cmd/web/routes.go index 0b72dda..f9e768e 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -15,10 +15,14 @@ func (app *application) routes() http.Handler { dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate) standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders) + withCors := standard.Append(app.enableCors) mux.Handle("/{$}", dynamic.ThenFunc(app.home)) - mux.Handle("POST /websites/{id}/guestbook/comments/create", dynamic.ThenFunc(app.postGuestbookCommentCreate)) mux.Handle("GET /websites/{id}/guestbook", dynamic.ThenFunc(app.getGuestbook)) + mux.Handle("GET /websites/{id}/guestbook/comments", withCors.ThenFunc(app.getGuestbookCommentsSerialized)) + mux.Handle("POST /websites/{id}/guestbook/comments/create/remote", standard.ThenFunc(app.postGuestbookCommentCreateRemote)) + mux.Handle("GET /websites/{id}/guestbook/comments/create", dynamic.ThenFunc(app.getGuestbookCommentCreate)) + mux.Handle("POST /websites/{id}/guestbook/comments/create", dynamic.ThenFunc(app.postGuestbookCommentCreate)) 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)) @@ -48,7 +52,6 @@ func (app *application) routes() http.Handler { mux.Handle("GET /websites/{id}/dashboard/guestbook/comments/trash", protected.ThenFunc(app.getCommentTrash)) mux.Handle("GET /websites/{id}/dashboard/guestbook/themes", protected.ThenFunc(app.getComingSoon)) mux.Handle("GET /websites/{id}/dashboard/guestbook/customize", protected.ThenFunc(app.getComingSoon)) - mux.Handle("GET /websites/{id}/guestbook/comments/create", protected.ThenFunc(app.getGuestbookCommentCreate)) return standard.Then(mux) } diff --git a/internal/forms/forms.go b/internal/forms/forms.go index b5edd31..30abaa3 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -20,6 +20,7 @@ type CommentCreateForm struct { AuthorEmail string `schema:"authoremail"` AuthorSite string `schema:"authorsite"` Content string `schema:"content"` + Redirect string `schema:"redirect"` validator.Validator `schema:"-"` } diff --git a/internal/models/guestbookcomment.go b/internal/models/guestbookcomment.go index 4a7203b..d44de26 100644 --- a/internal/models/guestbookcomment.go +++ b/internal/models/guestbookcomment.go @@ -20,6 +20,12 @@ type GuestbookComment struct { IsPublished bool } +type GuestbookCommentSerialized struct { + AuthorName string + CommentText string + Created string +} + type GuestbookCommentModel struct { DB *sql.DB } @@ -28,6 +34,7 @@ type GuestbookCommentModelInterface interface { Insert(shortId uint64, guestbookId, parentId int64, authorName, authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (int64, error) Get(shortId uint64) (GuestbookComment, error) GetAll(guestbookId int64) ([]GuestbookComment, error) + GetAllSerialized(guestbookId int64) ([]GuestbookCommentSerialized, error) GetDeleted(guestbookId int64) ([]GuestbookComment, error) GetUnpublished(guestbookId int64) ([]GuestbookComment, error) UpdateComment(comment *GuestbookComment) error @@ -93,6 +100,30 @@ func (m *GuestbookCommentModel) GetAll(guestbookId int64) ([]GuestbookComment, e return comments, nil } +func (m *GuestbookCommentModel) GetAllSerialized(guestbookId int64) ([]GuestbookCommentSerialized, error) { + stmt := `SELECT AuthorName, CommentText, Created + FROM guestbook_comments + WHERE GuestbookId = ? AND IsPublished = TRUE AND DELETED IS NULL + ORDER BY Created DESC` + rows, err := m.DB.Query(stmt, guestbookId) + if err != nil { + return nil, err + } + var comments []GuestbookCommentSerialized + for rows.Next() { + var c GuestbookCommentSerialized + err = rows.Scan(&c.AuthorName, &c.CommentText, &c.Created) + if err != nil { + return nil, err + } + comments = append(comments, c) + } + if err = rows.Err(); err != nil { + return nil, err + } + return comments, nil +} + func (m *GuestbookCommentModel) GetDeleted(guestbookId int64) ([]GuestbookComment, error) { stmt := `SELECT Id, ShortId, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite, CommentText, PageUrl, Created, IsPublished, Deleted diff --git a/internal/models/mocks/guestbookcomment.go b/internal/models/mocks/guestbookcomment.go index 6a0c972..f8520de 100644 --- a/internal/models/mocks/guestbookcomment.go +++ b/internal/models/mocks/guestbookcomment.go @@ -18,6 +18,12 @@ var mockGuestbookComment = models.GuestbookComment{ IsPublished: true, } +var mockSerializedGuestbookComment = models.GuestbookCommentSerialized{ + AuthorName: "John Test", + CommentText: "Hello, world", + Created: time.Now().Format(time.RFC3339), +} + type GuestbookCommentModel struct{} func (m *GuestbookCommentModel) Insert(shortId uint64, guestbookId, parentId int64, authorName, @@ -45,6 +51,17 @@ func (m *GuestbookCommentModel) GetAll(guestbookId int64) ([]models.GuestbookCom } } +func (m *GuestbookCommentModel) GetAllSerialized(guestbookId int64) ([]models.GuestbookCommentSerialized, error) { + switch guestbookId { + case 1: + return []models.GuestbookCommentSerialized{mockSerializedGuestbookComment}, nil + case 2: + return []models.GuestbookCommentSerialized{}, nil + default: + return []models.GuestbookCommentSerialized{}, models.ErrNoRecord + } +} + func (m *GuestbookCommentModel) GetDeleted(guestbookId int64) ([]models.GuestbookComment, error) { switch guestbookId { default: diff --git a/internal/models/mocks/website.go b/internal/models/mocks/website.go index 83cc69e..1e14872 100644 --- a/internal/models/mocks/website.go +++ b/internal/models/mocks/website.go @@ -1,6 +1,7 @@ package mocks import ( + "net/url" "time" "git.32bit.cafe/32bitcafe/guestbook/internal/models" @@ -22,10 +23,14 @@ var mockGuestbook = models.Guestbook{ } var mockWebsite = models.Website{ - ID: 1, - ShortId: 1, - Name: "Example", - SiteUrl: "example.com", + ID: 1, + ShortId: 1, + Name: "Example", + // SiteUrl: "example.com", + Url: &url.URL{ + Scheme: "http", + Host: "example.com", + }, AuthorName: "John Test", UserId: 1, Created: time.Now(), diff --git a/internal/models/website.go b/internal/models/website.go index 53eb35d..811bedf 100644 --- a/internal/models/website.go +++ b/internal/models/website.go @@ -3,15 +3,17 @@ package models import ( "database/sql" "errors" + "net/url" "strconv" "time" ) type Website struct { - ID int64 - ShortId uint64 - Name string - SiteUrl string + ID int64 + ShortId uint64 + Name string + // SiteUrl string + Url *url.URL AuthorName string UserId int64 Created time.Time @@ -179,7 +181,8 @@ func (m *WebsiteModel) Get(shortId uint64) (Website, error) { } row := tx.QueryRow(stmt, shortId) var w Website - err = row.Scan(&w.ID, &w.ShortId, &w.Name, &w.SiteUrl, &w.AuthorName, &w.UserId, &w.Created) + var u string + err = row.Scan(&w.ID, &w.ShortId, &w.Name, &u, &w.AuthorName, &w.UserId, &w.Created) if err != nil { if errors.Is(err, sql.ErrNoRows) { err = ErrNoRecord @@ -189,6 +192,10 @@ func (m *WebsiteModel) Get(shortId uint64) (Website, error) { } return Website{}, err } + w.Url, err = url.Parse(u) + if err != nil { + return Website{}, err + } stmt = `SELECT Id, ShortId, UserId, WebsiteId, Created, IsActive FROM guestbooks WHERE WebsiteId = ? AND Deleted IS NULL` @@ -244,11 +251,16 @@ func (m *WebsiteModel) GetAllUser(userId int64) ([]Website, error) { var websites []Website for rows.Next() { var w Website - err := rows.Scan(&w.ID, &w.ShortId, &w.Name, &w.SiteUrl, &w.AuthorName, &w.UserId, &w.Created, + var u string + err := rows.Scan(&w.ID, &w.ShortId, &w.Name, &u, &w.AuthorName, &w.UserId, &w.Created, &w.Guestbook.ID, &w.Guestbook.ShortId, &w.Guestbook.Created, &w.Guestbook.IsActive) if err != nil { return nil, err } + w.Url, err = url.Parse(u) + if err != nil { + return nil, err + } websites = append(websites, w) } if err = rows.Err(); err != nil { @@ -268,11 +280,16 @@ func (m *WebsiteModel) GetAll() ([]Website, error) { var websites []Website for rows.Next() { var w Website - err := rows.Scan(&w.ID, &w.ShortId, &w.Name, &w.SiteUrl, &w.AuthorName, &w.UserId, &w.Created, + var u string + err := rows.Scan(&w.ID, &w.ShortId, &w.Name, &u, &w.AuthorName, &w.UserId, &w.Created, &w.Guestbook.ID, &w.Guestbook.ShortId, &w.Guestbook.Created, &w.Guestbook.IsActive) if err != nil { return nil, err } + w.Url, err = url.Parse(u) + if err != nil { + return nil, err + } websites = append(websites, w) } if err = rows.Err(); err != nil { diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 0f57692..62037b2 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -8,51 +8,52 @@ import ( ) 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])?)*$") +var WebRX = regexp.MustCompile("^https?:\\/\\/") type Validator struct { - NonFieldErrors []string - FieldErrors map[string]string + NonFieldErrors []string + FieldErrors map[string]string } func (v *Validator) Valid() bool { - return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0 + return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 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 - } + if v.FieldErrors == nil { + v.FieldErrors = make(map[string]string) + } + if _, exists := v.FieldErrors[key]; !exists { + v.FieldErrors[key] = message + } } func (v *Validator) AddNonFieldError(message string) { - v.NonFieldErrors = append(v.NonFieldErrors, message) + v.NonFieldErrors = append(v.NonFieldErrors, message) } func (v *Validator) CheckField(ok bool, key, message string) { - if !ok { - v.AddFieldError(key, message) - } + if !ok { + v.AddFieldError(key, message) + } } func NotBlank(value string) bool { - return strings.TrimSpace(value) != "" + return strings.TrimSpace(value) != "" } func MaxChars(value string, n int) bool { - return utf8.RuneCountInString(value) <= n + return utf8.RuneCountInString(value) <= n } 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 + return utf8.RuneCountInString(value) >= n } func Matches(value string, rx *regexp.Regexp) bool { - return rx.MatchString(value) + return rx.MatchString(value) } diff --git a/migrations/000005_normalize_site_urls.down.sql b/migrations/000005_normalize_site_urls.down.sql new file mode 100644 index 0000000..1d42f9e --- /dev/null +++ b/migrations/000005_normalize_site_urls.down.sql @@ -0,0 +1 @@ +UPDATE websites SET SiteUrl = substr(SiteUrl, 8) WHERE substr(SiteUrl, 1, 4) = 'http'; diff --git a/migrations/000005_normalize_site_urls.up.sql b/migrations/000005_normalize_site_urls.up.sql new file mode 100644 index 0000000..a9ea2b3 --- /dev/null +++ b/migrations/000005_normalize_site_urls.up.sql @@ -0,0 +1 @@ +UPDATE websites SET SiteUrl = 'http://' || SiteUrl WHERE substr(SiteUrl, 1, 4) <> 'http'; diff --git a/ui/static/css/style.css b/ui/static/css/style.css index 0b1a576..1f291a6 100644 --- a/ui/static/css/style.css +++ b/ui/static/css/style.css @@ -58,10 +58,17 @@ div#dashboard { div#dashboard nav { flex: 1 1 25%; margin-top: 2rem; + min-width: 0; } div#dashboard > div { flex: 10 1 40%; + min-width: 0; +} + +div > pre { + max-width: 100%; + overflow: auto; } main nav ul { diff --git a/ui/static/js/guestbook.js b/ui/static/js/guestbook.js new file mode 100644 index 0000000..8a6dcac --- /dev/null +++ b/ui/static/js/guestbook.js @@ -0,0 +1,177 @@ +class GuestbookForm extends HTMLElement { + static get observedAttributes() { + return ['guestbook']; + } + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this._guestbook = this.getAttribute('guestbook') || ''; + this._postUrl = `${this._guestbook}/comments/create/remote` + this.render(); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === 'guestbook') { + this._guestbook = newValue; + this.render(); + } + } + + render() { + this.shadowRoot.innerHTML = ` + +
+ `; + } +} + +class CommentList extends HTMLElement { + static get observedAttributes() { + return ['guestbook']; + } + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.comments = []; + this.loading = false; + this.error = null; + } + + connectedCallback() { + if (this.hasAttribute('guestbook')) { + this.fetchComments(); + } + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === 'guestbook' && oldValue !== newValue) { + this.fetchComments(); + } + } + + async fetchComments() { + const guestbook = this.getAttribute('guestbook'); + if (!guestbook) return; + this.loading = true; + this.error = null; + this.render(); + + const commentsUrl = `${guestbook}/comments` + + try { + const response = await fetch(commentsUrl); + if (!response.ok) throw new Error(`HTTP error: ${response.status}`); + const data = await response.json(); + this.comments = Array.isArray(data) ? data : []; + this.loading = false; + this.render(); + } catch (err) { + this.error = err.message; + this.loading = false; + this.render(); + } + } + + formatDate(isoString) { + if (!isoString) return ''; + const date = new Date(isoString); + return date.toLocaleString(); + } + + render() { + this.shadowRoot.innerHTML = ` + +No comments yet!
@@ -61,7 +61,7 @@ templ GuestbookDashboardCommentView(data CommonData, w models.Website, c models. templ commentForm(form forms.CommentCreateForm) {