Files
remark42/backend/app/store/service/tree.go
T
Umputun aca0cff399 fix: IPv6 address truncation and image proxy SSRF vulnerabilities
Replace strings.Split(RemoteAddr, ":") with net.SplitHostPort for correct
IPv6 address extraction in vote deduplication and comment IP tracking.

Harden image proxy: add SSRF-safe transport blocking private/reserved IPs
at connection time with DNS rebinding protection, sanitize error messages
to prevent information leakage, add response size limit via io.LimitReader.

Fix shadowed error variables in BlockedUsers, SetTitle, and Delete methods.
Exclude gosec taint analysis false positives at linter config level.
2026-02-28 04:13:07 -06:00

240 lines
7.4 KiB
Go

package service
import (
"sort"
"strings"
"time"
"github.com/umputun/remark42/backend/app/store"
)
// Tree is formatter making tree from the list of comments
type Tree struct {
Nodes []*Node `json:"comments"`
countLeft int
lastLimitedComment string
}
// Node is a comment with optional replies
type Node struct {
Comment store.Comment `json:"comment"`
Replies []*Node `json:"replies,omitempty"`
tsModified time.Time
tsCreated time.Time
}
// recurData wraps all fields used in recursive processing as intermediate results
type recurData struct {
tsModified time.Time
tsCreated time.Time
visible bool
}
// MakeTree gets unsorted list of comments and produces Tree
func MakeTree(comments []store.Comment, sortType string, limit int, offsetID string) *Tree {
if len(comments) == 0 {
return &Tree{}
}
res := Tree{}
topComments := res.filter(comments, func(c store.Comment) bool { return c.ParentID == "" })
res.Nodes = []*Node{}
for _, rootComment := range topComments {
node := Node{Comment: rootComment}
rd := recurData{}
commentsTree := res.proc(comments, &node, &rd, rootComment.ID)
// skip deleted with no sub-comments and all sub-comments deleted
if rootComment.Deleted && (len(commentsTree.Replies) == 0 || !rd.visible) {
continue
}
res.Nodes = append(res.Nodes, commentsTree)
}
res.sortNodes(sortType)
res.limit(limit, offsetID)
return &res
}
// CountLeft returns number of comments left after limit, 0 if no limit was set
func (t *Tree) CountLeft() int {
return t.countLeft
}
// LastComment returns ID of the last comment in the tree after limit, empty string if no limit was set
func (t *Tree) LastComment() string {
return t.lastLimitedComment
}
// proc makes tree for one top-level comment recursively
func (t *Tree) proc(comments []store.Comment, node *Node, rd *recurData, parentID string) (result *Node) {
if rd.tsModified.IsZero() || rd.tsCreated.IsZero() {
rd.tsModified, rd.tsCreated = node.Comment.Timestamp, node.Comment.Timestamp
}
repComments := t.filter(comments, func(comment store.Comment) bool { return comment.ParentID == parentID })
for _, rc := range repComments {
if !rc.Timestamp.IsZero() && rc.Timestamp.After(rd.tsModified) && !rc.Deleted {
rd.tsModified = rc.Timestamp
}
if !rc.Timestamp.IsZero() && rc.Timestamp.Before(rd.tsCreated) && !rc.Deleted {
rd.tsCreated = rc.Timestamp
}
if !rc.Deleted {
rd.visible = true // indicates top-level should be visible
}
rnode := &Node{Comment: rc, Replies: []*Node{}}
node.Replies = append(node.Replies, rnode)
t.proc(comments, rnode, rd, rc.ID)
if !rd.visible || (len(rnode.Replies) == 0 && rc.Deleted) { // clean all-deleted subtree
node.Replies = node.Replies[:len(node.Replies)-1]
}
}
// replies always sorted by time
sort.Slice(node.Replies, func(i, j int) bool {
return node.Replies[i].Comment.Timestamp.Before(node.Replies[j].Comment.Timestamp)
})
node.tsModified, node.tsCreated = rd.tsModified, rd.tsCreated
return node
}
// filter returns comments for parentID
func (t *Tree) filter(comments []store.Comment, fn func(comment store.Comment) bool) []store.Comment {
f := []store.Comment{}
for _, c := range comments {
if fn(c) {
f = append(f, c)
}
}
return f
}
// sort list of nodes, i.e. top-level comments
// time sort uses tsModified from latest reply
func (t *Tree) sortNodes(sortType string) {
sort.Slice(t.Nodes, func(i, j int) bool {
switch sortType {
case "+time", "-time", "time":
if strings.HasPrefix(sortType, "-") {
return t.Nodes[i].Comment.Timestamp.After(t.Nodes[j].Comment.Timestamp)
}
return t.Nodes[i].Comment.Timestamp.Before(t.Nodes[j].Comment.Timestamp)
case "+active", "-active", "active":
if strings.HasPrefix(sortType, "-") {
return t.Nodes[i].tsModified.After(t.Nodes[j].tsModified)
}
return t.Nodes[i].tsModified.Before(t.Nodes[j].tsModified)
case "+score", "-score", "score":
if strings.HasPrefix(sortType, "-") {
if t.Nodes[i].Comment.Score == t.Nodes[j].Comment.Score {
return t.Nodes[i].Comment.Timestamp.Before(t.Nodes[j].Comment.Timestamp)
}
return t.Nodes[i].Comment.Score > t.Nodes[j].Comment.Score
}
if t.Nodes[i].Comment.Score == t.Nodes[j].Comment.Score {
return t.Nodes[i].Comment.Timestamp.Before(t.Nodes[j].Comment.Timestamp)
}
return t.Nodes[i].Comment.Score < t.Nodes[j].Comment.Score
case "+controversy", "-controversy", "controversy":
if strings.HasPrefix(sortType, "-") {
if t.Nodes[i].Comment.Controversy == t.Nodes[j].Comment.Controversy {
return t.Nodes[i].Comment.Timestamp.Before(t.Nodes[j].Comment.Timestamp)
}
return t.Nodes[i].Comment.Controversy > t.Nodes[j].Comment.Controversy
}
if t.Nodes[i].Comment.Controversy == t.Nodes[j].Comment.Controversy {
return t.Nodes[i].Comment.Timestamp.Before(t.Nodes[j].Comment.Timestamp)
}
return t.Nodes[i].Comment.Controversy < t.Nodes[j].Comment.Controversy
default:
return t.Nodes[i].Comment.Timestamp.Before(t.Nodes[j].Comment.Timestamp)
}
})
}
// limit limits number of comments in tree and sets countLeft and lastLimitedComment,
// starting with comment next after offsetID.
//
// If offsetID is empty or invalid, it starts from the beginning. If limit is 0, it doesn't limit anything.
//
// Limit is applied to top-level comments only, so top-level comments only returned with all replies,
// and lastLimitedComment is set to the last top-level comment and not last reply in it.
//
// In case limit is less than the number of replies to first comment after given offset, that first comment is
// returned completely with all replies.
func (t *Tree) limit(limit int, offsetID string) {
if offsetID == "" && limit <= 0 {
return
}
start := 0
if offsetID != "" {
for i, n := range t.Nodes {
if n.Comment.ID == offsetID {
start = i + 1
break
}
}
}
if start == len(t.Nodes) { // if the start index is beyond the available nodes, clear the nodes
t.Nodes = []*Node{}
return
}
t.Nodes = t.Nodes[start:]
// if there is only offset and no limit, there are no comments left and no point in returning
// the last comment ID as there are no comments beyond it.
if limit <= 0 {
return
}
// traverse and limit the number of top-level nodes, including their replies
limitedNodes := []*Node{}
commentsCount := 0
for _, node := range t.Nodes {
repliesCount := countReplies(node) + 1 // count this node and its replies
// if the limit is already reached or exceeded, calculate countLeft and move to the next node
if commentsCount >= limit {
t.countLeft += repliesCount
continue
}
// check if we just exceeded the limit and there are already some nodes in the list,
// as otherwise we would have to return the first node with all its replies even if it exceeds the limit.
if commentsCount+repliesCount >= limit && len(limitedNodes) > 0 {
t.countLeft += repliesCount
commentsCount = limit // adjust commentsCount to stop checking limit for the next nodes
continue
}
// add the node and its replies to the list
limitedNodes = append(limitedNodes, node)
commentsCount += repliesCount
}
t.lastLimitedComment = limitedNodes[len(limitedNodes)-1].Comment.ID
t.Nodes = limitedNodes
}
// countReplies counts the total number of replies recursively for a given node.
func countReplies(node *Node) int {
count := 0
for _, reply := range node.Replies {
count++ // count the reply itself
count += countReplies(reply) // recursively count its replies
}
return count
}