package store import ( "fmt" "html/template" "regexp" "strings" "time" "github.com/microcosm-cc/bluemonday" ) // Comment represents a single comment with optional reference to its parent type Comment struct { ID string `json:"id" bson:"_id"` ParentID string `json:"pid"` Text string `json:"text"` Orig string `json:"orig,omitempty"` // important: never render this as HTML! It's not sanitized. User User `json:"user"` Locator Locator `json:"locator"` Score int `json:"score"` Votes map[string]bool `json:"votes,omitempty"` VotedIPs map[string]VotedIPInfo `json:"voted_ips,omitempty"` // voted ips (hashes) with TS Vote int `json:"vote"` // vote for the current user, -1/1/0. Controversy float64 `json:"controversy,omitempty"` Timestamp time.Time `json:"time" bson:"time"` Edit *Edit `json:"edit,omitempty" bson:"edit,omitempty"` // pointer to have empty default in json response Pin bool `json:"pin,omitempty" bson:"pin,omitempty"` Deleted bool `json:"delete,omitempty" bson:"delete"` Imported bool `json:"imported,omitempty" bson:"imported"` PostTitle string `json:"title,omitempty" bson:"title"` } // Locator keeps site and url of the post type Locator struct { SiteID string `json:"site,omitempty" bson:"site"` URL string `json:"url"` } // Edit indication type Edit struct { Timestamp time.Time `json:"time" bson:"time"` Summary string `json:"summary"` } // PostInfo holds summary for given post url type PostInfo struct { URL string `json:"url,omitempty"` // can be attached to site-wide comments but won't be set then Count int `json:"count"` CountLeft int `json:"count_left"` // used only with returning search results limited by number, otherwise zero LastComment string `json:"last_comment,omitempty"` // used only with returning search results limited by number ReadOnly bool `json:"read_only,omitempty" bson:"read_only,omitempty"` // can be attached to site-wide comments but won't be set then FirstTS time.Time `json:"first_time" bson:"first_time,omitempty"` LastTS time.Time `json:"last_time" bson:"last_time,omitempty"` } // BlockedUser holds id and ts for blocked user type BlockedUser struct { ID string `json:"id"` Name string `json:"name"` Until time.Time `json:"time"` } // VotedIPInfo keeps timestamp and voting value (direction). Used as VotedIPs value type VotedIPInfo struct { Timestamp time.Time Value bool } // DeleteMode defines how much comment info will be erased type DeleteMode int // DeleteMode enum const ( SoftDelete DeleteMode = 0 HardDelete DeleteMode = 1 ) // Maximum length for URL text shortening. const shortURLLen = 48 const snippetLen = 200 // PrepareUntrusted pre-processes a comment received from untrusted source by clearing all // autogen fields and reset everything users not supposed to provide func (c *Comment) PrepareUntrusted() { c.ID = "" // don't allow user to define ID, force auto-gen c.Timestamp = time.Time{} // reset time, force auto-gen c.Votes = make(map[string]bool) c.VotedIPs = make(map[string]VotedIPInfo) c.Score = 0 c.Controversy = 0 c.Edit = nil c.Pin = false c.Deleted = false c.Imported = false } // SetDeleted clears comment info, reset to deleted state. hard flag will clear all user info as well func (c *Comment) SetDeleted(mode DeleteMode) { c.Text = "" c.Orig = "" c.Score = 0 c.Controversy = 0 c.Votes = map[string]bool{} c.VotedIPs = make(map[string]VotedIPInfo) c.Edit = nil c.Deleted = true c.Pin = false if mode == HardDelete { c.User.Name = "deleted" c.User.ID = "deleted" c.User.Picture = "" c.User.IP = "" } } // Sanitize clean dangerous html/js from the comment. // Comment.Orig which is used to store the original comment text is not sanitized // as we expect to never render it as HTML and render Comment.Text instead func (c *Comment) Sanitize() { p := bluemonday.UGCPolicy() p.AllowAttrs("class").Matching(regexp.MustCompile("^chroma$")).OnElements("pre") // special case for embedding the quotes from Twitter p.AllowAttrs("class").Matching(regexp.MustCompile("^twitter-tweet$")).OnElements("blockquote") // this is list of tag classes which could be produced by chroma code renderer // source: https://github.com/alecthomas/chroma/blob/c263f6f/types.go#L209-L306 const codeSpanClassRegex = "^(bg|chroma|line|ln|lnt|hl|lntable|lntd|lnlinks|cl|w|err|x|k|kc" + "|kd|kn|kp|kr|kt|n|na|nb|bp|nc|no|nd|ni|ne|nf|fm|py|nl|nn|nx|nt|nv|vc|vg" + "|vi|vm|l|ld|s|sa|sb|sc|dl|sd|s2|se|sh|si|sx|sr|s1|ss|m|mb|mf|mh|mi|il" + "|mo|o|ow|p|c|ch|cm|cp|cpf|c1|cs|g|gd|ge|gr|gh|gi|go|gp|gs|gu|gt|gl)$" p.AllowAttrs("class").Matching(regexp.MustCompile(codeSpanClassRegex)).OnElements("span") p.AllowAttrs("loading").Matching(regexp.MustCompile("^(lazy|eager)$")).OnElements("img") c.Text = p.Sanitize(c.Text) c.User.ID = template.HTMLEscapeString(c.User.ID) c.User.Name = c.SanitizeText(c.User.Name) c.User.Picture = c.SanitizeAsURL(c.User.Picture) c.Locator.URL = c.SanitizeAsURL(c.Locator.URL) c.PostTitle = c.SanitizeText(c.PostTitle) } // Snippet from comment's text func (c *Comment) Snippet(limit int) string { if limit <= 0 { limit = snippetLen } cleanText := strings.ReplaceAll(c.Text, "\n", " ") size := len([]rune(cleanText)) if size < limit { return cleanText } snippet := []rune(cleanText)[:limit] // go back in snippet and found the first space for i := len(snippet) - 1; i >= 0; i-- { if snippet[i] == ' ' { snippet = snippet[:i] break } } // don't add a space if comment is just a one single word which has been truncated. if len(snippet) == limit { return string(snippet) + "..." } return string(snippet) + " ..." } var reHref = regexp.MustCompile(`]*?\s+)?href="([^"]*)"`) // SanitizeAsURL drops dangerous code from a url. // It wraps input with href to trigger bluemonday sanitizer and cleans href after sanitizing done func (c *Comment) SanitizeAsURL(inp string) string { h := fmt.Sprintf(``, inp) clean := bluemonday.UGCPolicy().Sanitize(h) if match := reHref.FindStringSubmatch(clean); len(match) > 1 { return match[1] } return "" // this shouldn't happen as we build the href } func (c *Comment) escapeHTMLWithSome(inp string) string { res := template.HTMLEscapeString(inp) res = strings.ReplaceAll(res, "&", "&") res = strings.ReplaceAll(res, """, "\"") res = strings.ReplaceAll(res, "'", "'") return res } // SanitizeText used to sanitize any input string, and removes any HTML tags func (c *Comment) SanitizeText(inp string) string { clean := bluemonday.StrictPolicy().Sanitize(inp) return strings.TrimSpace(c.escapeHTMLWithSome(clean)) }