Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}