diff --git a/pkg/controllers/comments.go b/pkg/controllers/comments.go index 49a1746..0364650 100644 --- a/pkg/controllers/comments.go +++ b/pkg/controllers/comments.go @@ -5,6 +5,7 @@ import ( "fmt" "html/template" "net/http" + "strconv" "strings" "git.kirsle.net/apps/gophertype/pkg/authentication" @@ -20,9 +21,14 @@ import ( func init() { glue.Register(glue.Endpoint{ Path: "/comments", - Methods: []string{"POST"}, + Methods: []string{"GET", "POST"}, Handler: PostComment, }) + glue.Register(glue.Endpoint{ + Path: "/comments/subscription", + Methods: []string{"GET", "POST"}, + Handler: ManageSubscription, + }) glue.Register(glue.Endpoint{ Path: "/comments/quick-delete", Methods: []string{"GET"}, @@ -109,8 +115,37 @@ func renderComments(w http.ResponseWriter, r *http.Request, subject string, thre return template.HTML(html.String()) } -// PostComment handles all of the top-level blog index routes: +// ReadComments handles the GET /comments for viewing recently added comments +// side wide. +func ReadComments(w http.ResponseWriter, r *http.Request) { + var ( + v = responses.NewTemplateVars(w, r) + query = r.URL.Query() + ) + + // Query parameters. + page, _ := strconv.Atoi(query.Get("page")) + perPage, _ := strconv.Atoi(query.Get("per_page")) + + // Get the comments. + comments, err := models.Comments.GetRecent(page, perPage) + if err != nil { + responses.Error(w, r, http.StatusInternalServerError, err.Error()) + return + } + + v.V["PagedComments"] = comments + responses.RenderTemplate(w, r, "_builtin/comments/recent.gohtml", v) +} + +// PostComment handles all of the top-level blog index routes +// for Post, Preview, Delete comments. func PostComment(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + ReadComments(w, r) + return + } + var ( v = responses.NewTemplateVars(w, r) editToken = getEditToken(w, r) @@ -190,6 +225,9 @@ func PostComment(w http.ResponseWriter, r *http.Request) { comment.UserID = currentUser.ID } + // Store the OriginURL for this comment. + comment.OriginURL = form.Get("origin") + // Post their comment. err := comment.Save() if err != nil { @@ -213,6 +251,43 @@ func PostComment(w http.ResponseWriter, r *http.Request) { responses.RenderTemplate(w, r, "_builtin/comments/preview.gohtml", v) } +// ManageSubscription helps users unsubscribe from comment threads. +func ManageSubscription(w http.ResponseWriter, r *http.Request) { + var ( + v = responses.NewTemplateVars(w, r) + query = r.URL.Query() + qThread = query.Get("t") + qEmail = strings.ToLower(strings.TrimSpace(query.Get("e"))) + qAll = query.Get("all") + ) + + // Are we unsubscribing from all? + if qEmail != "" && qAll != "" { + err := models.Comments.UnsubscribeFromAll(qEmail) + if err != nil { + session.Flash(w, r, "Error unsubscribing you: %s", err) + } else { + session.Flash(w, r, "Success: '%s' has been unsubscribed from ALL comment threads.", qEmail) + } + responses.Redirect(w, r, "/comments/subscription") + return + } + + // Is there a thread and email in the query string? + if qThread != "" && qEmail != "" { + err := models.Comments.UnsubscribeThread(qThread, qEmail) + if err != nil { + session.Flash(w, r, "Error unsubscribing you: %s", err) + } else { + session.Flash(w, r, "Success: '%s' has been unsubscribed from this comment thread.", qEmail) + } + responses.Redirect(w, r, "/comments/subscription") + return + } + + responses.RenderTemplate(w, r, "_builtin/comments/subscription.gohtml", v) +} + // CommentQuickDelete handles quick-delete links to remove spam comments. func CommentQuickDelete(w http.ResponseWriter, r *http.Request) { // Query parameters. diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go index 07cf9e3..f20ef86 100644 --- a/pkg/mail/mail.go +++ b/pkg/mail/mail.go @@ -137,21 +137,25 @@ func NotifyComment(subject string, originURL string, c models.Comment) { } // // Email the subscribers. - // email.Admin = false - // m := comments.LoadMailingList() - // for _, to := range m.List(c.ThreadID) { - // if to == c.Email { - // continue // don't email yourself - // } - // email.To = to - // email.UnsubscribeURL = fmt.Sprintf("%s/comments/subscription?t=%s&e=%s", - // strings.Trim(s.Site.URL, "/"), - // url.QueryEscape(c.ThreadID), - // url.QueryEscape(to), - // ) - // console.Info("Mail subscriber '%s' about comment notification on '%s'", email.To, c.ThreadID) - // SendEmail(email) - // } + email.Admin = false + if subscribers, err := models.Comments.GetSubscribers(c.Thread); err == nil { + email.Subject = "A new comment has been added to: " + subject + + for _, to := range subscribers { + // Don't email to the writer of the comment. + if to == c.Email { + continue + } + email.To = to + email.UnsubscribeURL = fmt.Sprintf("%s/comments/subscription?t=%s&e=%s", + strings.Trim(s.BaseURL, "/"), + url.QueryEscape(c.Thread), + url.QueryEscape(to), + ) + console.Info("Mail subscriber '%s' about comment notification on '%s'", email.To, c.Thread) + SendEmail(email) + } + } } // ParseAddress parses an email address. diff --git a/pkg/models/comments.go b/pkg/models/comments.go index e3d06b7..33d5c6f 100644 --- a/pkg/models/comments.go +++ b/pkg/models/comments.go @@ -2,10 +2,15 @@ package models import ( "crypto/md5" + "errors" "fmt" "html/template" "io" + "math" "net/mail" + "regexp" + "strconv" + "strings" "git.kirsle.net/apps/gophertype/pkg/console" "git.kirsle.net/apps/gophertype/pkg/markdown" @@ -14,6 +19,9 @@ import ( uuid "github.com/satori/go.uuid" ) +// Regexp to match a comment thread ID for a blog post +var rePostThread = regexp.MustCompile(`^post-(\d+?)$`) + type commentMan struct{} // Comments is a singleton manager class for Comment model access. @@ -25,16 +33,31 @@ type Comment struct { Thread string `gorm:"index"` // name of comment thread UserID uint // foreign key to User.ID + PostID uint // if a comment on a blog post + OriginURL string // original URL of comment page Name string Email string + Subscribe bool // user subscribes to future comments on same thread Avatar string Body string EditToken string // So users can edit their own recent comments. DeleteToken string `gorm:"unique_index"` // Quick-delete token for spam. + Post Post User User `gorm:"foreign_key:UserID"` } +// PagedComments holds a paginated view of multiple comments. +type PagedComments struct { + Comments []Comment + Page int + PerPage int + Pages int + Total int + NextPage int + PreviousPage int +} + // New creates a new Comment model. func (m commentMan) New() Comment { return Comment{ @@ -45,7 +68,7 @@ func (m commentMan) New() Comment { // Load a comment by ID. func (m commentMan) Load(id int) (Comment, error) { var com Comment - r := DB.Preload("User").First(&com, id) + r := DB.Preload("User").Preload("Post").First(&com, id) return com, r.Error } @@ -66,6 +89,85 @@ func (m commentMan) GetThread(thread string) ([]Comment, error) { return coms, r.Error } +// GetRecent pages through the comments by recency. +func (m commentMan) GetRecent(page int, perPage int) (PagedComments, error) { + var pc = PagedComments{ + Page: page, + PerPage: perPage, + } + + if pc.Page < 1 { + pc.Page = 1 + } + if pc.PerPage <= 0 { + pc.PerPage = 20 + } + + query := DB.Debug().Preload("User").Preload("Post"). + Order("created_at desc") + + // Count the total number of rows. + query.Model(&Comment{}).Count(&pc.Total) + + // Query the paged slice of results. + r := query. + Offset((pc.Page - 1) * pc.PerPage). + Limit(pc.PerPage). + Find(&pc.Comments) + + // Populate paging details. + pc.Pages = int(math.Ceil(float64(pc.Total) / float64(pc.PerPage))) + if pc.Page < pc.Pages { + pc.NextPage = pc.Page + 1 + } + if pc.Page > 1 { + pc.PreviousPage = pc.Page - 1 + } + + return pc, r.Error +} + +// GetSubscribers returns the subscriber email addresses that are watching a comment thread. +func (m commentMan) GetSubscribers(thread string) ([]string, error) { + var result []string + + var comments []Comment + r := DB.Where("thread = ? AND subscribe = ?", thread, true).Find(&comments) + + // Filter them down to valid emails only. + for _, com := range comments { + // TODO: validate its an email + if len(com.Email) > 0 { + result = append(result, com.Email) + } + } + + return result, r.Error +} + +// UnsubscribeThread unsubscribes a user's email from a comment thread. +func (m commentMan) UnsubscribeThread(thread string, email string) error { + // Verify the thread is valid. + var count int + DB.Debug().Model(&Comment{}).Where("thread=?", thread).Count(&count) + if count == 0 { + return errors.New("invalid comment thread") + } + + r := DB.Debug().Table("comments").Where("thread=? AND subscribe=?", thread, true).Updates(map[string]interface{}{ + "subscribe": false, + }) + return r.Error +} + +// UnsubscribeFromAll remove's an email subscription for ALL comment threads. +func (m commentMan) UnsubscribeFromAll(email string) error { + r := DB.Debug().Table("comments").Where("email=?", email).Updates(map[string]interface{}{ + "subscribe": false, + }) + return r.Error +} + // HTML returns the comment's body as rendered HTML code. func (c Comment) HTML() template.HTML { return template.HTML(markdown.RenderMarkdown(c.Body)) @@ -96,6 +198,21 @@ func (c *Comment) Save() error { } } + // Parse the thread name if it looks like a post ID. + if m := rePostThread.FindStringSubmatch(c.Thread); len(m) > 0 { + if postID, err := strconv.Atoi(m[1]); err == nil { + c.PostID = uint(postID) + } + } + + // If there's a PostID, validate that the post exists. + if c.PostID > 0 { + if _, err := Posts.Load(int(c.PostID)); err != nil { + console.Error("Comment had a PostID=%d but the post wasn't found!", c.PostID) + c.PostID = 0 + } + } + console.Info("Save comment: %+v", c) // Save the post. @@ -116,8 +233,9 @@ func (c Comment) Delete() error { func (c *Comment) ParseForm(form *forms.Data) { c.Thread = form.Get("thread") c.Name = form.Get("name") - c.Email = form.Get("email") + c.Email = strings.ToLower(strings.TrimSpace(form.Get("email"))) c.Body = form.Get("body") + c.Subscribe = form.Get("subscribe") == "true" c.LoadAvatar() } diff --git a/pkg/models/posts.go b/pkg/models/posts.go index b5771cc..18c6910 100644 --- a/pkg/models/posts.go +++ b/pkg/models/posts.go @@ -98,8 +98,8 @@ func (m postMan) GetIndexPosts(privacy string, page, perPage int) (PagedPosts, e // Query the paginated slice of results. r := query. - Offset((page - 1) * perPage). - Limit(perPage). + Offset((pp.Page - 1) * pp.PerPage). + Limit(pp.PerPage). Find(&pp.Posts) pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage))) diff --git a/pvt-www/.layout.gohtml b/pvt-www/.layout.gohtml index 6ee4723..f93a052 100644 --- a/pvt-www/.layout.gohtml +++ b/pvt-www/.layout.gohtml @@ -102,6 +102,17 @@ +
+
+

Pages

+ + +
+
+ {{ if .LoggedIn }}
diff --git a/pvt-www/_builtin/comments/form.partial.gohtml b/pvt-www/_builtin/comments/form.partial.gohtml index fb4124d..8fb6f05 100644 --- a/pvt-www/_builtin/comments/form.partial.gohtml +++ b/pvt-www/_builtin/comments/form.partial.gohtml @@ -30,6 +30,13 @@ Optional; used for your Gravatar. + + +

+ +

diff --git a/pvt-www/_builtin/comments/recent.gohtml b/pvt-www/_builtin/comments/recent.gohtml new file mode 100644 index 0000000..01b418a --- /dev/null +++ b/pvt-www/_builtin/comments/recent.gohtml @@ -0,0 +1,37 @@ +{{ define "title" }}Recent Comments{{ end }} +{{ define "content" }} + +

Recent Comments

+ +{{ with .V.PagedComments }} +

+ Page {{ .Page }} of {{ .Pages }} ({{ .Total }} total) + {{ if or (gt .PreviousPage 0) (gt .NextPage 0) }} + [ + {{ if gt .NextPage 0 }} + Older + {{ if gt .PreviousPage 0 }} | {{ end }} + {{ end }} + {{ if gt .PreviousPage 0 }} + Newer + {{ end }} + ] + {{ end }} +

+ + {{ range .Comments }} + {{ if gt .PostID 0 }} +

+ In post {{ or .Post.Title "Untitled" }}: +

+ {{ else if .OriginURL }} +

+ On page {{ .OriginURL }}: +

+ {{ end }} + + {{ RenderComment $.ResponseWriter $.Request . "/comments" false }} + {{ end }} +{{ end }} + +{{ end }} diff --git a/pvt-www/_builtin/comments/subscription.gohtml b/pvt-www/_builtin/comments/subscription.gohtml new file mode 100644 index 0000000..c502910 --- /dev/null +++ b/pvt-www/_builtin/comments/subscription.gohtml @@ -0,0 +1,24 @@ +{{ define "title" }}Comment Subscriptions{{ end }} +{{ define "content" }} + +

Comment Subscriptions

+ +

+ This blog allows users to subscribe to comment threads, when they leave their + e-mail address and opt-in to do so when adding a comment to a page. +

+ +

+ To unsubscribe from a single comment thread, click on the "Unsubscribe" link + in that email. Or, to remove yourself from all comment threads, + enter your email address below. +

+ +
+ +

Unsubscribe From All

+ + + +
+{{ end }} diff --git a/pvt-www/guestbook.gohtml b/pvt-www/guestbook.gohtml new file mode 100644 index 0000000..6c9d186 --- /dev/null +++ b/pvt-www/guestbook.gohtml @@ -0,0 +1,8 @@ +{{ define "title" }}Guestbook{{ end }} +{{ define "content" }} + +

My Guestbook

+ +{{ RenderComments .ResponseWriter .Request "My Guestbook" "guestbook" }} + +{{ end }}