Browse Source
* 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 pagesmaster
14 changed files with 668 additions and 1 deletions
@ -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 |
|||
} |
@ -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" |
|||
} |
|||
} |
@ -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 }} |
@ -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> |
@ -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> |
@ -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 }} |
Loading…
Reference in new issue