Basic Commenting System Implemented

* List comments on a post
* Add comments, with preview
* Users can edit their own comments (EditToken)
* Admin can edit all comments
* Delete comments
* Comment counts on main blog index pages
This commit is contained in:
Noah 2020-02-13 22:03:01 -08:00
parent fd1494cf75
commit ceb42aa4d0
14 changed files with 668 additions and 1 deletions

1
go.mod
View File

@ -14,4 +14,5 @@ require (
github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470
github.com/urfave/negroni v1.0.0
golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
)

1
go.sum
View File

@ -179,6 +179,7 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@ -36,7 +36,11 @@ func NewSite(pubroot string) *Site {
// Register blog global template functions.
responses.ExtraFuncs = template.FuncMap{
"BlogIndex": controllers.PartialBlogIndex,
"BlogIndex": controllers.PartialBlogIndex,
"RenderComments": controllers.RenderComments,
"RenderCommentsRO": controllers.RenderCommentsRO,
"RenderComment": controllers.RenderComment,
"RenderCommentForm": controllers.RenderCommentForm,
}
return site

229
pkg/controllers/comments.go Normal file
View File

@ -0,0 +1,229 @@
package controllers
import (
"bytes"
"fmt"
"html/template"
"net/http"
"strings"
"git.kirsle.net/apps/gophertype/pkg/authentication"
"git.kirsle.net/apps/gophertype/pkg/glue"
"git.kirsle.net/apps/gophertype/pkg/models"
"git.kirsle.net/apps/gophertype/pkg/responses"
"git.kirsle.net/apps/gophertype/pkg/session"
"github.com/albrow/forms"
uuid "github.com/satori/go.uuid"
)
func init() {
glue.Register(glue.Endpoint{
Path: "/comments",
Methods: []string{"POST"},
Handler: PostComment,
})
glue.Register(glue.Endpoint{
Path: "/comments/quick-delete",
Methods: []string{"GET"},
Handler: CommentQuickDelete,
})
}
// RenderComments returns the partial comments HTML to embed on a page.
func RenderComments(w http.ResponseWriter, r *http.Request, subject string, ids ...string) template.HTML {
thread := strings.Join(ids, "-")
return renderComments(w, r, subject, thread, false)
}
// RenderCommentsRO returns a read-only comment view.
func RenderCommentsRO(w http.ResponseWriter, r *http.Request, ids ...string) template.HTML {
thread := strings.Join(ids, "-")
return renderComments(w, r, "", thread, true)
}
// RenderComment renders the HTML partial for a single comment in the thread.
func RenderComment(w http.ResponseWriter, r *http.Request, com models.Comment, originURL string, editable bool) template.HTML {
var (
html = bytes.NewBuffer([]byte{})
v = responses.NewTemplateVars(html, r)
editToken = getEditToken(w, r)
)
v.V["Comment"] = com
v.V["Editable"] = editable && (com.EditToken == editToken || authentication.LoggedIn(r))
v.V["OriginURL"] = originURL
responses.PartialTemplate(html, r, "_builtin/comments/entry.partial.gohtml", v)
return template.HTML(html.String())
}
// RenderCommentForm renders the comment entry form HTML onto a page.
func RenderCommentForm(r *http.Request, com models.Comment, subject, threadID, originURL string) template.HTML {
var (
html = bytes.NewBuffer([]byte{})
v = responses.NewTemplateVars(html, r)
)
v.V["Comment"] = com
v.V["Subject"] = subject
v.V["ThreadID"] = threadID
v.V["OriginURL"] = originURL
responses.PartialTemplate(html, r, "_builtin/comments/form.partial.gohtml", v)
return template.HTML(html.String())
}
// renderComments is the internal logic for both RenderComments and RenderCommentsRO.
func renderComments(w http.ResponseWriter, r *http.Request, subject string, thread string, readonly bool) template.HTML {
var (
html = bytes.NewBuffer([]byte{})
v = responses.NewTemplateVars(w, r)
ses = session.Get(r)
)
comments, err := models.Comments.GetThread(thread)
if err != nil {
return template.HTML(fmt.Sprintf("[comment error: %s]", err))
}
// Load their cached name and email from any previous comments the user posted.
name, _ := ses.Values["c.name"].(string)
email, _ := ses.Values["c.email"].(string)
editToken, _ := ses.Values["c.token"].(string)
// Logged in? Populate defaults from the user info.
if currentUser, err := authentication.CurrentUser(r); err == nil {
name = currentUser.Name
email = currentUser.Email
}
// v.V["posts"] = posts.Posts
v.V["Readonly"] = readonly
v.V["Subject"] = subject
v.V["ThreadID"] = thread
v.V["Comments"] = comments
v.V["OriginURL"] = r.URL.Path
v.V["NewComment"] = models.Comment{
Name: name,
Email: email,
EditToken: editToken,
}
responses.PartialTemplate(html, r, "_builtin/comments/comments.partial.gohtml", v)
return template.HTML(html.String())
}
// PostComment handles all of the top-level blog index routes:
func PostComment(w http.ResponseWriter, r *http.Request) {
var (
v = responses.NewTemplateVars(w, r)
editToken = getEditToken(w, r)
comment = models.Comments.New()
ses = session.Get(r)
editing bool // true if editing an existing comment
)
// Check if the user is logged in.
var loggedIn bool
currentUser, err := authentication.CurrentUser(r)
loggedIn = err == nil
// Get form parameters.
form, _ := forms.Parse(r)
v.V["Subject"] = form.Get("subject")
v.V["ThreadID"] = form.Get("thread")
v.V["OriginURL"] = form.Get("origin")
// Are they editing an existing post ID?
if id := form.GetInt("id"); id > 0 {
// Load the comment from DB.
com, err := models.Comments.Load(id)
if err != nil {
responses.NotFound(w, r)
return
}
// Verify the user's EditToken matches this comment.
if editToken != com.EditToken && !loggedIn {
responses.Forbidden(w, r, "You do not have permission to edit that comment.")
return
}
editing = true
comment = com
}
comment.EditToken = editToken
for {
// Validate form parameters.
val := form.Validator()
val.Require("body")
if !form.GetBool("editing") {
comment.ParseForm(form)
}
if val.HasErrors() {
v.ValidationError = val.ErrorMap()
break
}
// Cache their name and email in their session, for future requests.
ses.Values["c.email"] = form.Get("email")
ses.Values["c.name"] = form.Get("name")
ses.Save(r, w)
switch form.Get("submit") {
case "delete":
v.V["deleting"] = true
case "confirm-delete":
// Delete the comment.
err := comment.Delete()
if err != nil {
session.Flash(w, r, "Error deleting the comment: %s", err)
} else {
session.Flash(w, r, "Comment has been deleted!")
}
responses.Redirect(w, r, form.Get("origin"))
return
case "preview":
v.V["preview"] = comment.HTML()
case "post":
// If we're logged in, tag our user ID with this post.
if loggedIn && !editing {
comment.UserID = currentUser.ID
}
// Post their comment.
err := comment.Save()
if err != nil {
session.Flash(w, r, "Error posting comment: %s", err)
responses.Redirect(w, r, form.Get("origin"))
return
}
session.Flash(w, r, "Your comment has been added!")
responses.Redirect(w, r, form.Get("origin"))
return
}
break
}
v.V["NewComment"] = comment
responses.RenderTemplate(w, r, "_builtin/comments/preview.gohtml", v)
}
// CommentQuickDelete handles quick-delete links to remove spam comments.
func CommentQuickDelete(w http.ResponseWriter, r *http.Request) {
v := responses.NewTemplateVars(w, r)
responses.RenderTemplate(w, r, "_builtin/blog/view-post.gohtml", v)
}
// getEditToken gets the edit token from the user's session.
func getEditToken(w http.ResponseWriter, r *http.Request) string {
ses := session.Get(r)
if token, ok := ses.Values["c.token"].(string); ok && len(token) > 0 {
return token
}
token := uuid.NewV4().String()
ses.Values["c.token"] = token
ses.Save(r, w)
return token
}

139
pkg/models/comments.go Normal file
View File

@ -0,0 +1,139 @@
package models
import (
"crypto/md5"
"fmt"
"html/template"
"io"
"net/mail"
"git.kirsle.net/apps/gophertype/pkg/console"
"git.kirsle.net/apps/gophertype/pkg/markdown"
"github.com/albrow/forms"
"github.com/jinzhu/gorm"
uuid "github.com/satori/go.uuid"
)
type commentMan struct{}
// Comments is a singleton manager class for Comment model access.
var Comments = commentMan{}
// Comment model.
type Comment struct {
gorm.Model
Thread string `gorm:"index"` // name of comment thread
UserID uint // foreign key to User.ID
Name string
Email string
Avatar string
Body string
EditToken string // So users can edit their own recent comments.
DeleteToken string `gorm:"unique_index"` // Quick-delete token for spam.
User User `gorm:"foreign_key:UserID"`
}
// New creates a new Comment model.
func (m commentMan) New() Comment {
return Comment{
DeleteToken: uuid.NewV4().String(),
}
}
// Load a comment by ID.
func (m commentMan) Load(id int) (Comment, error) {
var com Comment
r := DB.Preload("User").First(&com, id)
return com, r.Error
}
// LoadByDeleteToken loads a comment by its DeleteToken.
func (m commentMan) LoadByDeleteToken(token string) (Comment, error) {
var com Comment
r := DB.Preload("User").Where("delete_token = ?", token).First(&com)
return com, r.Error
}
// GetIndex returns the index page of blog posts.
func (m commentMan) GetThread(thread string) ([]Comment, error) {
var coms []Comment
r := DB.Debug().Preload("User").
Where("thread = ?", thread).
Order("created_at asc").
Find(&coms)
return coms, 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))
}
// Save a comment.
func (c *Comment) Save() error {
// Ensure the delete token is unique!
{
if exist, err := Comments.LoadByDeleteToken(c.DeleteToken); err != nil && exist.ID != c.ID {
console.Debug("Comment.Save: delete token is not unique, trying to resolve")
var resolved bool
for i := 2; i <= 100; i++ {
token := uuid.NewV4().String()
_, err = Comments.LoadByDeleteToken(token)
if err == nil {
continue
}
c.DeleteToken = token
resolved = true
break
}
if !resolved {
return fmt.Errorf("failed to generate a unique delete token after 100 attempts")
}
}
}
console.Info("Save comment: %+v", c)
// Save the post.
if DB.NewRecord(c) {
console.Warn("NEw Record!")
return DB.Create(&c).Error
}
return DB.Save(&c).Error
}
// Delete a comment.
func (c Comment) Delete() error {
return DB.Delete(&c).Error
}
// ParseForm populates a Post from an HTTP form.
func (c *Comment) ParseForm(form *forms.Data) {
c.Thread = form.Get("thread")
c.Name = form.Get("name")
c.Email = form.Get("email")
c.Body = form.Get("body")
c.LoadAvatar()
}
// LoadAvatar calculates the user's avatar for the comment.
func (c *Comment) LoadAvatar() {
// MD5 hash the email address for Gravatar.
if _, err := mail.ParseAddress(c.Email); err == nil {
h := md5.New()
io.WriteString(h, c.Email)
hash := fmt.Sprintf("%x", h.Sum(nil))
c.Avatar = fmt.Sprintf(
"//www.gravatar.com/avatar/%s?s=96",
hash,
)
} else {
// Default gravatar.
c.Avatar = "https://www.gravatar.com/avatar/00000000000000000000000000000000"
}
}

View File

@ -11,4 +11,5 @@ func UseDB(db *gorm.DB) {
DB.AutoMigrate(&User{})
DB.AutoMigrate(&Post{})
DB.AutoMigrate(&TaggedPost{})
DB.AutoMigrate(&Comment{})
}

View File

@ -6,6 +6,7 @@ import (
"html/template"
"math"
"regexp"
"strconv"
"strings"
"time"
@ -35,6 +36,9 @@ type Post struct {
EnableComments bool
Tags []TaggedPost
Author User `gorm:"foreign_key:UserID"`
// Private fields not in DB.
CommentCount int `gorm:"-"`
}
// PagedPosts holds a paginated response of multiple posts.
@ -106,6 +110,10 @@ func (m postMan) GetIndexPosts(privacy string, page, perPage int) (PagedPosts, e
pp.PreviousPage = pp.Page - 1
}
if err := pp.CountComments(); err != nil {
console.Error("PagedPosts.CountComments: %s", err)
}
return pp, r.Error
}
@ -157,9 +165,70 @@ func (m postMan) GetPostsByTag(tag, privacy string, page, perPage int) (PagedPos
pp.PreviousPage = pp.Page - 1
}
if err := pp.CountComments(); err != nil {
console.Error("PagedPosts.CountComments: %s", err)
}
return pp, r.Error
}
// CountComments gets comment counts for one or more posts.
// Returns a map[uint]int mapping post ID to comment count.
func (m postMan) CountComments(posts ...Post) (map[uint]int, error) {
var result = map[uint]int{}
// Create the comment thread IDs.
var threadIDs = make([]string, len(posts))
for i, post := range posts {
threadIDs[i] = fmt.Sprintf("post-%d", post.ID)
}
// Query comment counts for each thread.
if len(threadIDs) > 0 {
rows, err := DB.Table("comments").
Select("thread, count(*) as count").
Group("thread").
Rows()
if err != nil {
return result, err
}
for rows.Next() {
var thread string
var count int
if err := rows.Scan(&thread, &count); err != nil {
console.Error("CountComments: rows.Scan: %s", err)
}
postID, err := strconv.Atoi(strings.TrimPrefix(thread, "post-"))
if err != nil {
console.Warn("CountComments: strconv.Atoi(%s): %s", thread, err)
}
result[uint(postID)] = count
}
}
return result, nil
}
// CountComments on the posts in a PagedPosts list.
func (pp *PagedPosts) CountComments() error {
counts, err := Posts.CountComments(pp.Posts...)
if err != nil {
return err
}
console.Info("counts: %+v", counts)
for i, post := range pp.Posts {
if count, ok := counts[post.ID]; ok {
pp.Posts[i].CommentCount = count
}
}
return nil
}
// PreviewHTML returns the post's body as rendered HTML code, but only above
// the <snip> tag for index views.
func (p Post) PreviewHTML() template.HTML {

View File

@ -38,6 +38,7 @@ func NewTemplateVars(w io.Writer, r *http.Request) TemplateValues {
}
if rw, ok := w.(http.ResponseWriter); ok {
v.ResponseWriter = rw
v.Flashes = session.GetFlashes(rw, r)
}
@ -57,6 +58,7 @@ type TemplateValues struct {
Request *http.Request
RequestTime time.Time
RequestDuration time.Duration
ResponseWriter http.ResponseWriter
FormValues url.Values
Path string // request path
TemplatePath string // file path of html template, like "_builtin/error.gohtml"

View File

@ -41,6 +41,14 @@
{{ end }}
</em></small>
</div>
<div class="mt-2">
<small class="text-muted"><em>
<a href="/{{ $Post.Fragment }}#comments">{{ $Post.CommentCount }} comment{{ if ne $Post.CommentCount 1 }}s{{ end }}</a>
|
<a href="/{{ $Post.Fragment }}">Permalink</a>
</em></small>
</div>
</div>
</div>

View File

@ -43,6 +43,13 @@
</div>
</div>
{{ $idStr := printf "%d" $Post.ID }}
{{ if $Post.EnableComments }}
{{ RenderComments .ResponseWriter .Request $Post.Title "post" $idStr }}
{{ else }}
{{ RenderCommentsRO .ResponseWriter .Request "post" $idStr }}
{{ end }}
{{ if .CurrentUser.IsAdmin }}
<div class="alert alert-secondary">
<strong>Admin:</strong>

View File

@ -0,0 +1,27 @@
<hr>
<h1 id="comments">Comments</h1>
<p>
There
{{ if eq (len .V.Comments) 1 }}
is one comment
{{ else if eq (len .V.Comments) 0 }}
are no comments
{{ else }}
are {{ len .V.Comments }} comments
{{ end }}
on this page.
</p>
{{ range .V.Comments }}
{{ RenderComment $.ResponseWriter $.Request . $.V.OriginURL true }}
{{ end }}
<h3 id="add-comment">Add a Comment</h3>
{{ if .V.Readonly }}
<em>Comments are disabled on this page.</em>
{{ else }}
{{ RenderCommentForm .Request .V.NewComment .V.Subject .V.ThreadID .V.OriginURL }}
{{ end }}

View File

@ -0,0 +1,55 @@
{{ $C := .V.Comment }}
<div class="card mb-4">
<div class="card-body">
<div class="row">
<div class="markdown col-12 col-lg-2 mb-1">
<img src="{{ $C.Avatar }}"
width="96"
height="96"
alt="Avatar image">
</div>
<div class="markdown col-12 col-lg-10">
<div class="comment-meta">
{{ if and $C.UserID $C.User }}
<strong>{{ or $C.Name "Anonymous" }}</strong>
{{ if $C.User.IsAdmin }}<span class="text-danger">(admin)</span>
{{ else }}<span class="text-info">(logged in)</span>{{ end }}
{{ else }}
<strong>{{ or $C.Name "Anonymous" }}</strong>
{{ end }}
posted on {{ $C.CreatedAt.Format "January 2, 2006 @ 15:04 MST" }}
{{ if $C.UpdatedAt.After $C.CreatedAt }}
<span title="{{ $C.UpdatedAt.Format "Jan 2 2006 @ 15:04:05 MST" }}">
(updated {{ $C.UpdatedAt.Format "1/2/06 15:04 MST"}})
</span>
{{ end }}
</div>
{{ $C.HTML }}
{{ if and $C.ID .V.Editable }}
<form action="/comments" method="POST">
{{ CSRF }}
<input type="hidden" name="id" value="{{ $C.ID }}">
<input type="hidden" name="thread" value="{{ $C.Thread }}">
<input type="hidden" name="subject" value="(editing)">
<input type="hidden" name="origin" value="{{ .V.OriginURL }}">
<input type="hidden" name="body" value="{{ $C.Body }}">
<input type="hidden" name="editing" value="true">
<button type="submit"
name="submit"
value="preview"
class="btn btn-sm btn-primary">edit</button>
<button type="submit"
name="submit"
value="delete"
class="btn btn-sm btn-danger">delete</button>
</form>
{{ end }}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,80 @@
{{ $NC := .V.Comment }}
<form name="comments" action="/comments" method="POST">
{{ CSRF }}
<input type="hidden" name="id" value="{{ $NC.ID }}">
<input type="hidden" name="subject" value="{{ .V.Subject }}">
<input type="hidden" name="thread" value="{{ .V.ThreadID }}">
<input type="hidden" name="origin" value="{{ .V.OriginURL }}">
<div class="form-group row">
<label for="name" class="col-12 col-lg-2 col-form-label">Your name:</label>
<div class="col-12 col-lg-10">
<input type="text"
id="name"
name="name"
class="form-control"
value="{{ $NC.Name }}"
placeholder="Anonymous">
</div>
</div>
<div class="form-group row">
<label for="email" class="col-12 col-lg-2 col-form-label">Your email:</label>
<div class="col-12 col-lg-10">
<input type="email"
id="email"
name="email"
class="form-control"
aria-describedby="emailHelp"
value="{{ $NC.Email }}"
placeholder="(optional)">
<small id="emailHelp" class="form-text text-muted">
Optional; used for your <a href="https://en.gravatar.com/" target="_blank">Gravatar</a>.
</small>
</div>
</div>
<div class="form-group row">
<div class="col-12">
<label for="body">Message:</label>
<textarea name="body"
id="body"
class="form-control"
cols="80"
rows="10"
required="required"
aria-describedby="bodyHelp">{{ $NC.Body }}</textarea>
<small id="bodyHelp" class="form-text text-muted">
You may format your comment using
<a href="https://github.github.com/gfm/" target="_blank">GitHub Flavored Markdown</a>
syntax.
</small>
</div>
</div>
<div class="form-group row" style="display: none">
<div class="col">
<div class="card">
<div class="card-header">Sanity Check</div>
<div class="card-body">
If you can see this, do not touch the following fields.
<input type="text" name="url" value="http://" class="form-control" placeholder="Website">
<textarea name="comment" cols="80" rows="10" class="form-control" placeholder="Comment"></textarea>
</div>
</div>
</div>
</div>
<div class="form-group row">
<div class="col">
<button type="submit"
name="submit"
value="preview"
class="btn btn-primary">Preview</button>
<button type="submit"
name="submit"
value="post"
class="btn btn-danger">Post</button>
</div>
</div>
</form>

View File

@ -0,0 +1,44 @@
{{ define "title" }}Preview Comment{{ end }}
{{ define "content" }}
{{ if .V.deleting }}
<h1>Delete Comment?</h1>
{{ else }}
<h1>Preview Comment</h1>
{{ end }}
{{ RenderComment .ResponseWriter .Request .V.NewComment .V.OriginURL false }}
{{ if .V.deleting }}
<form action="/comments" method="POST">
{{ CSRF }}
<input type="hidden" name="id" value="{{ .V.NewComment.ID }}">
<input type="hidden" name="origin" value="{{ .V.OriginURL }}">
<input type="hidden" name="body" value="{{ .V.NewComment.Body }}">
<p>
Are you sure you want to delete this comment?
</p>
<button type="submit"
class="btn btn-primary mr-2"
name="submit"
value="confirm-delete">
Yes, Delete
</button>
<a href="{{ .V.OriginURL }}"
class="btn btn-secondary">
Cancel
</a>
</form>
{{ else }}
{{ RenderCommentForm .Request .V.NewComment .V.Subject .V.ThreadID .V.OriginURL }}
{{ end }}
{{ if .V.OriginURL}}
<div class="mt-4">
(<a href="{{ .V.OriginURL }}">back to post</a>)
</div>
{{ end }}
{{ end }}