gophertype/pkg/models/comments.go

258 lines
6.4 KiB
Go

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"
"github.com/albrow/forms"
"github.com/jinzhu/gorm"
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.
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
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{
DeleteToken: uuid.NewV4().String(),
}
}
// Load a comment by ID.
func (m commentMan) Load(id int) (Comment, error) {
var com Comment
r := DB.Preload("User").Preload("Post").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
}
// 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))
}
// 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")
}
}
}
// 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.
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 = strings.ToLower(strings.TrimSpace(form.Get("email")))
c.Body = form.Get("body")
c.Subscribe = form.Get("subscribe") == "true"
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"
}
}