Search Apps Documentation Source Content File Folder Download Copy Actions Download 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}