2020-02-14 06:03:01 +00:00
|
|
|
package models
|
|
|
|
|
|
|
|
import (
|
|
|
|
"crypto/md5"
|
2020-02-16 03:43:08 +00:00
|
|
|
"errors"
|
2020-02-14 06:03:01 +00:00
|
|
|
"fmt"
|
|
|
|
"html/template"
|
|
|
|
"io"
|
2020-02-16 03:43:08 +00:00
|
|
|
"math"
|
2020-02-14 06:03:01 +00:00
|
|
|
"net/mail"
|
2020-02-16 03:43:08 +00:00
|
|
|
"regexp"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
2020-02-14 06:03:01 +00:00
|
|
|
|
|
|
|
"git.kirsle.net/apps/gophertype/pkg/console"
|
|
|
|
"git.kirsle.net/apps/gophertype/pkg/markdown"
|
|
|
|
"github.com/albrow/forms"
|
|
|
|
uuid "github.com/satori/go.uuid"
|
|
|
|
)
|
|
|
|
|
2020-02-16 03:43:08 +00:00
|
|
|
// Regexp to match a comment thread ID for a blog post
|
|
|
|
var rePostThread = regexp.MustCompile(`^post-(\d+?)$`)
|
|
|
|
|
2020-02-14 06:03:01 +00:00
|
|
|
type commentMan struct{}
|
|
|
|
|
|
|
|
// Comments is a singleton manager class for Comment model access.
|
|
|
|
var Comments = commentMan{}
|
|
|
|
|
|
|
|
// Comment model.
|
|
|
|
type Comment struct {
|
2020-02-18 02:10:35 +00:00
|
|
|
BaseModel
|
2020-02-14 06:03:01 +00:00
|
|
|
|
|
|
|
Thread string `gorm:"index"` // name of comment thread
|
2020-02-18 02:10:35 +00:00
|
|
|
UserID int // foreign key to User.ID
|
|
|
|
PostID int // if a comment on a blog post
|
2020-02-16 03:43:08 +00:00
|
|
|
OriginURL string // original URL of comment page
|
2020-02-14 06:03:01 +00:00
|
|
|
Name string
|
|
|
|
Email string
|
2020-02-16 03:43:08 +00:00
|
|
|
Subscribe bool // user subscribes to future comments on same thread
|
2020-02-14 06:03:01 +00:00
|
|
|
Avatar string
|
|
|
|
Body string
|
|
|
|
EditToken string // So users can edit their own recent comments.
|
|
|
|
DeleteToken string `gorm:"unique_index"` // Quick-delete token for spam.
|
|
|
|
|
2020-02-16 03:43:08 +00:00
|
|
|
Post Post
|
2020-02-14 06:03:01 +00:00
|
|
|
User User `gorm:"foreign_key:UserID"`
|
|
|
|
}
|
|
|
|
|
2020-02-16 03:43:08 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2020-02-14 06:03:01 +00:00
|
|
|
// 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
|
2020-02-16 03:43:08 +00:00
|
|
|
r := DB.Preload("User").Preload("Post").First(&com, id)
|
2020-02-14 06:03:01 +00:00
|
|
|
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
|
2020-02-26 20:49:39 +00:00
|
|
|
r := DB.Preload("User").
|
2020-02-14 06:03:01 +00:00
|
|
|
Where("thread = ?", thread).
|
|
|
|
Order("created_at asc").
|
|
|
|
Find(&coms)
|
|
|
|
return coms, r.Error
|
|
|
|
}
|
|
|
|
|
2020-02-16 03:43:08 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2020-02-26 20:49:39 +00:00
|
|
|
query := DB.Preload("User").Preload("Post").
|
2020-02-16 03:43:08 +00:00
|
|
|
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
|
2020-02-26 20:49:39 +00:00
|
|
|
DB.Model(&Comment{}).Where("thread=?", thread).Count(&count)
|
2020-02-16 03:43:08 +00:00
|
|
|
if count == 0 {
|
|
|
|
return errors.New("invalid comment thread")
|
|
|
|
}
|
|
|
|
|
2020-02-26 20:49:39 +00:00
|
|
|
r := DB.Table("comments").Where("thread=? AND subscribe=?", thread, true).Updates(map[string]interface{}{
|
2020-02-16 03:43:08 +00:00
|
|
|
"subscribe": false,
|
|
|
|
})
|
|
|
|
return r.Error
|
|
|
|
}
|
|
|
|
|
|
|
|
// UnsubscribeFromAll remove's an email subscription for ALL comment threads.
|
|
|
|
func (m commentMan) UnsubscribeFromAll(email string) error {
|
2020-02-26 20:49:39 +00:00
|
|
|
r := DB.Table("comments").Where("email=?", email).Updates(map[string]interface{}{
|
2020-02-16 03:43:08 +00:00
|
|
|
"subscribe": false,
|
|
|
|
})
|
|
|
|
return r.Error
|
|
|
|
}
|
|
|
|
|
2020-02-14 06:03:01 +00:00
|
|
|
// 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")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-02-16 03:43:08 +00:00
|
|
|
// 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 {
|
2020-02-18 02:10:35 +00:00
|
|
|
c.PostID = postID
|
2020-02-16 03:43:08 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-02-14 06:03:01 +00:00
|
|
|
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")
|
2020-02-16 03:43:08 +00:00
|
|
|
c.Email = strings.ToLower(strings.TrimSpace(form.Get("email")))
|
2020-02-14 06:03:01 +00:00
|
|
|
c.Body = form.Get("body")
|
2020-02-16 03:43:08 +00:00
|
|
|
c.Subscribe = form.Get("subscribe") == "true"
|
2020-02-14 06:03:01 +00:00
|
|
|
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"
|
|
|
|
}
|
|
|
|
}
|