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" 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 { BaseModel Thread string `gorm:"index"` // name of comment thread UserID int // foreign key to User.ID PostID int // 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.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.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.Model(&Comment{}).Where("thread=?", thread).Count(&count) if count == 0 { return errors.New("invalid comment thread") } r := DB.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.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 = 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" } }