Comment Subscriptions and Recent Comments Page
This commit is contained in:
parent
87fbdea68b
commit
91e3bdaa53
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
37
pvt-www/_builtin/comments/recent.gohtml
Normal file
37
pvt-www/_builtin/comments/recent.gohtml
Normal 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 }}
|
24
pvt-www/_builtin/comments/subscription.gohtml
Normal file
24
pvt-www/_builtin/comments/subscription.gohtml
Normal 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
8
pvt-www/guestbook.gohtml
Normal file
|
@ -0,0 +1,8 @@
|
|||
{{ define "title" }}Guestbook{{ end }}
|
||||
{{ define "content" }}
|
||||
|
||||
<h1>My Guestbook</h1>
|
||||
|
||||
{{ RenderComments .ResponseWriter .Request "My Guestbook" "guestbook" }}
|
||||
|
||||
{{ end }}
|
Loading…
Reference in New Issue
Block a user