Comment Subscriptions and Recent Comments Page

master
Noah 2020-02-15 19:43:08 -08:00
parent 87fbdea68b
commit 91e3bdaa53
9 changed files with 305 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -102,6 +102,17 @@
</div>
</div>
<div class="card mb-4">
<div class="card-body">
<h4 class="card-title">Pages</h4>
<ul class="list-unstyled">
<li class="list-item"><a href="/comments">Recent Comments</a></li>
<li class="list-item"><a href="/guestbook">Guestbook</a></li>
</ul>
</div>
</div>
{{ if .LoggedIn }}
<div class="card mb-4">
<div class="card-body">

View File

@ -30,6 +30,13 @@
<small id="emailHelp" class="form-text text-muted">
Optional; used for your <a href="https://en.gravatar.com/" target="_blank">Gravatar</a>.
</small>
<p class="mt-1 text-muted">
<label style="font-weight: normal">
<input type="checkbox" name="subscribe" value="true"{{ if $NC.Subscribe }} checked{{ end }}> Notify me of future comments on this page
</label>
</p>
</div>
</div>

View File

@ -0,0 +1,37 @@
{{ define "title" }}Recent Comments{{ end }}
{{ define "content" }}
<h1>Recent Comments</h1>
{{ with .V.PagedComments }}
<p>
Page {{ .Page }} of {{ .Pages }} ({{ .Total }} total)
{{ if or (gt .PreviousPage 0) (gt .NextPage 0) }}
[
{{ if gt .NextPage 0 }}
<a href="/comments?page={{ .NextPage }}&per_page={{ .PerPage }}">Older</a>
{{ if gt .PreviousPage 0 }} | {{ end }}
{{ end }}
{{ if gt .PreviousPage 0 }}
<a href="/comments?page={{ .PreviousPage }}&per_page={{ .PerPage }}">Newer</a>
{{ end }}
]
{{ end }}
</p>
{{ range .Comments }}
{{ if gt .PostID 0 }}
<p>
<strong>In post <a href="{{ .Post.Fragment }}">{{ or .Post.Title "Untitled" }}</a>:</strong>
</p>
{{ else if .OriginURL }}
<p>
<strong>On page <a href="{{ .OriginURL }}">{{ .OriginURL }}</a>:</strong>
</p>
{{ end }}
{{ RenderComment $.ResponseWriter $.Request . "/comments" false }}
{{ end }}
{{ end }}
{{ end }}

View File

@ -0,0 +1,24 @@
{{ define "title" }}Comment Subscriptions{{ end }}
{{ define "content" }}
<h1>Comment Subscriptions</h1>
<p>
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.
</p>
<p>
To unsubscribe from a single comment thread, click on the "Unsubscribe" link
in that email. Or, to remove yourself from <strong>all comment threads</strong>,
enter your email address below.
</p>
<form action="/comments/subscription" method="GET">
<input type="hidden" name="all" value="true">
<h2>Unsubscribe From All</h2>
<input type="email" class="form-control" name="e" placeholder="Email address">
<button type="submit" class="btn btn-primary mt-2">Unsubscribe From All</button>
</form>
{{ end }}

8
pvt-www/guestbook.gohtml Normal file
View File

@ -0,0 +1,8 @@
{{ define "title" }}Guestbook{{ end }}
{{ define "content" }}
<h1>My Guestbook</h1>
{{ RenderComments .ResponseWriter .Request "My Guestbook" "guestbook" }}
{{ end }}