comments.gno
8.75 Kb · 260 lines
1package memba_notes_v1
2
3import (
4 "chain/runtime"
5 "gno.land/p/samcrew/avl"
6 "strconv"
7 "strings"
8 "unicode/utf8"
9)
10
11const MaxCommentBytes = 4000
12const MaxAnchorBytes = 600
13const CommentCooldownBlocks int64 = 10
14const MaxCommentPage = 5
15
16type comment struct {
17 Sequence uint64
18 ID, Parent [16]byte
19 Author address
20 BodyRevision uint64
21 Epoch uint32
22 Anchor, Body []byte
23 Revision uint64
24 CreatedHeight int64
25 Last receipt
26 Deleted, Hidden, Resolved bool
27 Encrypted bool
28}
29
30var comments = avl.NewTree()
31var commentIndex = avl.NewTree()
32var cooldowns = avl.NewTree()
33
34func commentKey(id, cid [16]byte) string { return idText(id) + ":" + idText(cid) }
35func getComment(id, cid [16]byte) *comment {
36 v, ok := comments.Get(commentKey(id, cid))
37 if !ok {
38 panic("notes: comment missing")
39 }
40 return v.(*comment)
41}
42func commentExpected(c *comment, rev uint64, op [16]byte) {
43 if c.Revision != rev || rev == ^uint64(0) {
44 panic("notes: stale comment revision")
45 }
46 if op == ([16]byte{}) || c.Last.Operation == op {
47 panic("notes: invalid comment operation")
48 }
49}
50func commentReceipt(c *comment, who address, op [16]byte) {
51 c.Revision++
52 c.Last = receipt{op, who, runtime.ChainHeight()}
53}
54func mayComment(cur realm, n *note, who address) bool {
55 if n.Mode == ModePublicOpen {
56 return true
57 }
58 if n.Mode == ModePublic {
59 return who == n.Owner || writer(n, who) || n.GovWriters && n.Owner == govAddress && govMember(who) || policyAllows(cur, n, who, OpComment)
60 }
61 if n.Mode == ModeShared {
62 return contains(n.Readers, who)
63 }
64 return false
65}
66
67// Public comments carry UTF8 anchor/body separately. Encrypted comments will
68// carry their combined authenticated payload in Body and an empty Anchor.
69func AddComment(cur realm, id, cid [16]byte, bodyRevision uint64, epoch uint32, parent [16]byte, anchor, body []byte, opID [16]byte) {
70 who := enter(cur, false)
71 defer leave()
72 n := live(id)
73 if paused {
74 panic("notes: comments paused")
75 }
76 if !mayComment(cur, n, who) {
77 panic("notes: comments denied")
78 }
79 if cid == ([16]byte{}) || opID == ([16]byte{}) {
80 panic("notes: empty comment id")
81 }
82 if _, ok := comments.Get(commentKey(id, cid)); ok {
83 panic("notes: comment id used")
84 }
85 if bodyRevision == 0 || bodyRevision > n.BodyRevision || epoch != n.Epoch {
86 panic("notes: bad comment anchor version")
87 }
88 if public(n.Mode) && (len(anchor) > MaxAnchorBytes || !utf8.Valid(anchor) || len(body) == 0 || len(body) > MaxCommentBytes || !utf8.Valid(body) || utf8.RuneCount(body) > 1000) {
89 panic("notes: invalid comment payload")
90 }
91 privateWritable(n)
92 if encrypted(n.Mode) {
93 if len(anchor) != 0 {
94 panic("notes: encrypted anchor must be inside payload")
95 }
96 validateCipher(body, n.Epoch, 3, 1, MaxCipherCommentBytes)
97 }
98 if parent != ([16]byte{}) {
99 p := getComment(id, parent)
100 if p.Deleted {
101 panic("notes: parent deleted")
102 }
103 }
104 ck := idText(id) + ":" + string(who)
105 h := runtime.ChainHeight()
106 if v, ok := cooldowns.Get(ck); ok && h-v.(int64) < CommentCooldownBlocks {
107 panic("notes: comment cooldown")
108 }
109 if n.CommentSequence == ^uint64(0) {
110 panic("notes: comment sequence exhausted")
111 }
112 n.CommentSequence++
113 c := &comment{Sequence: n.CommentSequence, Encrypted: encrypted(n.Mode), ID: cid, Parent: parent, Author: who, BodyRevision: bodyRevision, Epoch: epoch, Anchor: copied(anchor), Body: copied(body), CreatedHeight: h}
114 commentReceipt(c, who, opID)
115 comments.Set(commentKey(id, cid), c)
116 seq := strconv.FormatUint(c.Sequence, 10)
117 commentIndex.Set(idText(id)+":"+strings.Repeat("0", 20-len(seq))+seq+":"+idText(cid), commentKey(id, cid))
118 cooldowns.Set(ck, h)
119 historyCommentChange(n, c, "comment-add")
120}
121func DeleteComment(cur realm, id, cid [16]byte, baseRevision uint64, opID [16]byte) {
122 who := enter(cur, false)
123 defer leave()
124 // Authors can clear their current payload even after the note is deleted or
125 // paused. Public history keeps the original unless a moderator redacts it.
126 n := find(id)
127 c := getComment(id, cid)
128 if c.Author != who {
129 panic("notes: comment author required")
130 }
131 if c.Deleted {
132 panic("notes: comment deleted")
133 }
134 commentExpected(c, baseRevision, opID)
135 c.Deleted = true
136 c.Anchor = nil
137 c.Body = nil
138 commentReceipt(c, who, opID)
139 historyCommentChange(n, c, "comment-delete")
140}
141func ResolveComment(cur realm, id, cid [16]byte, baseRevision uint64, resolved bool, opID [16]byte) {
142 who := enter(cur, false)
143 defer leave()
144 mutateResolveComment(cur, who, id, cid, baseRevision, resolved, opID)
145}
146func mutateResolveComment(cur realm, who address, id, cid [16]byte, baseRevision uint64, resolved bool, opID [16]byte) {
147 n := live(id)
148 requireWrite(cur, n, who)
149 c := getComment(id, cid)
150 commentExpected(c, baseRevision, opID)
151 c.Resolved = resolved
152 commentReceipt(c, who, opID)
153 historyCommentChange(n, c, "comment-resolution")
154}
155func HideComment(cur realm, id, cid [16]byte, baseRevision uint64, hidden bool, opID [16]byte) {
156 who := enter(cur, false)
157 defer leave()
158 mutateHideComment(cur, who, id, cid, baseRevision, hidden, opID)
159}
160func mutateHideComment(cur realm, who address, id, cid [16]byte, baseRevision uint64, hidden bool, opID [16]byte) {
161 n := live(id)
162 if who != n.Owner && !moderator(who) {
163 panic("notes: owner required or moderator")
164 }
165 c := getComment(id, cid)
166 commentExpected(c, baseRevision, opID)
167 if v, ok := historyComments.Get(commentKey(id, cid)); ok && !hidden && v.(*historyComment).Redacted {
168 panic("notes: comment redacted")
169 }
170 c.Hidden = hidden
171 commentReceipt(c, who, opID)
172 historyCommentChange(n, c, "comment-visibility")
173}
174func commentJSON(c *comment) string {
175 return `{"id":` + q(idText(c.ID)) + `,"parent":` + q(idText(c.Parent)) + `,"author":` + q(string(c.Author)) + `,"encrypted":` + strconv.FormatBool(c.Encrypted) + `,"body_revision":` + u(c.BodyRevision) + `,"epoch":` + u(uint64(c.Epoch)) + `,"revision":` + u(c.Revision) + `,"anchor_blob":` + blob(c.Anchor) + `,"body_blob":` + blob(c.Body) + `,"created_height":` + q(strconv.FormatInt(c.CreatedHeight, 10)) + `,"op_id":` + q(idText(c.Last.Operation)) + `,"actor":` + q(string(c.Last.Actor)) + `,"height":` + q(strconv.FormatInt(c.Last.Height, 10)) + `,"deleted":` + strconv.FormatBool(c.Deleted) + `,"hidden":` + strconv.FormatBool(c.Hidden) + `,"resolved":` + strconv.FormatBool(c.Resolved) + `}`
176}
177func CommentJSON(idHex, cidHex string) string {
178 v, ok := comments.Get(commentKey(readID(idHex), readID(cidHex)))
179 if !ok {
180 return "null"
181 }
182 return commentJSON(v.(*comment))
183}
184
185// Comments paginate in append order; editing or moderation never reorders them.
186func CommentsJSON(idHex, cursor string, limit int) string {
187 id := readID(idHex)
188 find(id)
189 if limit < 1 || limit > MaxCommentPage {
190 panic("notes: page limit")
191 }
192 if cursor != "" {
193 if len(cursor) != 53 || cursor[20] != ':' {
194 panic("notes: bad cursor")
195 }
196 readID(cursor[21:])
197 for _, c := range cursor[:20] {
198 if c < '0' || c > '9' {
199 panic("notes: bad cursor")
200 }
201 }
202 }
203 prefix := idText(id) + ":"
204 start := prefix + cursor
205 items := []string{}
206 next := ""
207 commentIndex.Iterate(start, prefix+"\xff", func(k string, v interface{}) bool {
208 if cursor != "" && k == start {
209 return false
210 }
211 cv, ok := comments.Get(v.(string))
212 if !ok {
213 panic("notes: corrupt comment index")
214 }
215 items = append(items, commentJSON(cv.(*comment)))
216 next = strings.TrimPrefix(k, prefix)
217 return len(items) == limit
218 })
219 if len(items) < limit {
220 next = ""
221 }
222 return `{"items":[` + strings.Join(items, ",") + `],"next_cursor":` + q(next) + `}`
223}
224
225// Purge never operates on a live note. Each call erases at most limit records
226// (including cooldowns); a permanent note tombstone prevents ID reuse.
227func Purge(cur realm, id [16]byte, limit int) int {
228 who := enter(cur, false)
229 defer leave()
230 return mutatePurge(cur, who, id, limit)
231}
232func mutatePurge(cur realm, who address, id [16]byte, limit int) int {
233 n := find(id)
234 owner(n, who)
235 if !n.Deleted {
236 panic("notes: delete before purge")
237 }
238 if limit < 1 || limit > MaxPage {
239 panic("notes: purge limit")
240 }
241 counts := [5]int{}
242 counts[0] = purgePrefix(commentIndex, idText(id)+":", limit)
243 counts[1] = purgePrefix(comments, idText(id)+":", limit-counts[0])
244 counts[2] = purgePrefix(cooldowns, idText(id)+":", limit-counts[0]-counts[1])
245 counts[3] = purgePrefix(epochs, idText(id)+":", limit-counts[0]-counts[1]-counts[2])
246 counts[4] = purgeRequests(n, limit-counts[0]-counts[1]-counts[2]-counts[3])
247 historyCleanup(n, who, counts)
248 return counts[0]+counts[1]+counts[2]+counts[3]+counts[4]
249}
250func purgePrefix(tree *avl.Tree, prefix string, limit int) int {
251 if limit == 0 {
252 return 0
253 }
254 doomed := []string{}
255 tree.Iterate(prefix, prefix+"\xff", func(k string, v interface{}) bool { doomed = append(doomed, k); return len(doomed) == limit })
256 for _, k := range doomed {
257 tree.Remove(k)
258 }
259 return len(doomed)
260}