memba_feed_v1.gno
36.60 Kb · 1165 lines
1package memba_feed_v1
2
3// memba_feed_v1 — Global social feed realm for Memba (W7.2 P0).
4//
5// OPEN-WRITE: any wallet may post. This inverts every assumption of the
6// DAO-scoped memba_dao_channels_v2 (members-only, 20 channels / 500 threads),
7// so the realm is NEW — what is ported VERBATIM from channels_v2 is its
8// hardening discipline:
9//
10// B1 render-DoS bounds — reads NEVER iterate the monotonic nextPostID.
11// Live (visible) posts are tracked in dedicated AVL indexes and every
12// read path is paginated to a fixed window. channels_v2 used []uint64
13// slices (safe under its 500-per-channel cap); an open feed is unbounded,
14// so the live indexes here are composite-key AVL trees instead —
15// O(log n) insert/remove, O(page) scan, no single node that grows forever.
16// B2 state-shrink — author deletes soft-delete + enqueue a tombstone;
17// SweepTombstones hard-removes nodes (bounded, permissionless GC).
18// B3 pause policy — every user-facing write is blocked while paused;
19// owner/moderation stays operational (no funds in this realm).
20// Flag pipeline — one flag per address per post, threshold auto-hide.
21// Open write invites brigading, so the threshold is higher than
22// channels_v2's 3 and flagging is further gated by account age and a
23// per-day flag budget.
24//
25// Anti-spam (open write has no membership gate to lean on):
26// - per-address block cooldown between posts, stricter for accounts first
27// seen fewer than YoungAccountBlocks ago;
28// - body length cap; media CID count cap (CIDs stored on-chain only —
29// pinning/serving is the backend's PinMedia pipeline, P2).
30//
31// Reads: exported *JSON funcs via RPC vm/qeval — cursor-paginated, O(limit)
32// at any depth (the app reads from the indexer; these are the fallback).
33// Render() is the human gnoweb view: bounded pages with a hard depth cap
34// (MaxRenderPage) — deep history belongs to the cursor API, not qrender.
35//
36// Moderation P0: realm owner only (ModRemovePost / UnhidePost / BanAuthor is
37// W8.2's moderation board via a daokit moderator role — p/samcrew/modboard).
38// Every moderation action emits for public audit.
39//
40// This realm holds NO funds: no banker, no OriginSend, no fee lane. A future
41// tipping lane is a separate SAFETY_GATED flag + fee-spine lane "feed".
42
43import (
44 "strconv"
45 "strings"
46
47 "gno.land/p/samcrew/avl"
48 "gno.land/p/nt/ufmt/v0"
49
50 "chain"
51 "chain/runtime"
52 "chain/runtime/unsafe"
53)
54
55// ── Constants ────────────────────────────────────────────────
56
57const (
58 MaxBodyLen = 1000 // post body (roadmap 4.1)
59 MaxMediaCIDs = 4 // stored per post; pin/serve pipeline is P2
60 MaxCIDLen = 128 // defensive cap on a single CID string
61
62 FeedPageSize = 20 // posts per rendered page / default JSON window
63 MaxPageLimit = 100 // hard cap on any JSON read window (DoS guard)
64 MaxRenderPage = 50 // deepest gnoweb page; beyond → use the cursor API
65
66 // B1 reply bound (mirrors channels_v2's MaxRepliesPerThread). Caps the
67 // LIVE reply set per post so (a) the byParent index under any one parent is
68 // bounded, and (b) SweepTombstones can range-delete a swept parent's reply
69 // links in bounded work. Deleting a reply frees a slot.
70 MaxRepliesPerPost = 500
71
72 // Flag pipeline. channels_v2 auto-hides at 3 member flags; the feed is
73 // open-write, so the threshold starts higher and flagging is rate-limited.
74 // These are damping knobs, not a brigade-proof gate: 5 aged sybils can
75 // still hide a post (the age gate is one post + a one-time wait, farmable
76 // in parallel). They raise the cost and slow coordination; genuine brigade
77 // resistance is the W8.2 moderation board (reversible mod actions + audit).
78 // All are governance-tunable post-deploy via a future realm version; they
79 // are constants here so the adversarial tests pin their behavior.
80 FlagThreshold = 5 // unique flags before auto-hide
81 MinAccountAgeForFlag = 1200 // blocks since first post (~1-2h)
82 FlagsPerDayBudget = 10 // per address per BlocksPerDay window
83 BlocksPerDay = 17280 // ~5s blocks; approximation is fine
84
85 // Posting cooldowns (blocks). Young accounts (first seen < YoungAccountBlocks
86 // ago) wait longer between posts — cheap sybil throttle.
87 MinPostIntervalBlocks = 2
88 YoungMinPostIntervalBlocks = 12
89 YoungAccountBlocks = 17280 // first ~day
90)
91
92// ── Types ────────────────────────────────────────────────────
93
94type Post struct {
95 ID uint64
96 Author address
97 Body string
98 MediaCIDs []string // IPFS CIDs only; serving goes through the backend proxy
99 ReplyTo uint64 // 0 = top-level
100 RepostOf uint64 // 0 = original (entrypoint lands in P1; field is schema-stable)
101 BlockH int64
102 EditedAt int64 // block height of last edit (0 = never)
103 FlagCount int
104 Hidden bool // flag auto-hide or moderation hide
105 Deleted bool // author tombstone (awaiting SweepTombstones hard-GC)
106}
107
108type flagBudget struct {
109 DayStartH int64
110 Used int
111}
112
113// ── State ────────────────────────────────────────────────────
114
115var (
116 posts *avl.Tree // padID(id) -> *Post (live + hidden + not-yet-swept tombstones)
117
118 // Live indexes (B1): ONLY visible (not hidden, not deleted) posts.
119 // Every read path iterates one of these — never `posts`, never nextPostID.
120 liveFeed *avl.Tree // padID(id) -> true
121 byAuthor *avl.Tree // author + ":" + padID(id) -> true
122 byParent *avl.Tree // padID(parent) + ":" + padID(child) -> true
123 liveCount uint64 // number of keys in liveFeed
124 replyCount *avl.Tree // padID(parent) -> uint64 (live replies)
125
126 nextPostID uint64 // monotonic; NEVER iterated by reads
127
128 // Anti-spam / flag state.
129 lastPostH *avl.Tree // addr -> int64 (last CreatePost height)
130 firstSeenH *avl.Tree // addr -> int64 (first write interaction height)
131 flags *avl.Tree // padID(id) -> *avl.Tree (flagger addr -> true)
132 flagSpend *avl.Tree // addr -> *flagBudget
133
134 // Reactions (one-per-emoji toggle). padID(id) -> *avl.Tree keyed by
135 // reactionSubKey(emoji, addr) -> true. Enforces per-(post,emoji,addr)
136 // uniqueness on-chain; per-emoji COUNTS are aggregated off-chain by the
137 // indexer from ReactionAdded/ReactionRemoved (no unbounded on-chain scan —
138 // B1 render bounds hold; a hot post's reaction set is never iterated here).
139 reactions *avl.Tree
140
141 // Soft-deleted post IDs awaiting hard-GC (B2). An AVL tree (not a slice):
142 // a slice re-serializes wholesale on every enqueue, so post+delete spam
143 // would inflate the gas of every write touching it (a slow write-path DoS).
144 // The tree gives O(log n) enqueue and lets the sweep drain a bounded
145 // ascending window without rewriting the backlog.
146 tombstones *avl.Tree // padID(id) -> true
147
148 // Moderator set (W8.2): owner-granted addresses that can ModRemovePost /
149 // UnhidePost WITHOUT owner-multisig-per-post — fast, reversible moderation.
150 // Granting/revoking stays owner-only. addr -> true.
151 moderators *avl.Tree
152
153 paused bool
154 // Deployer address (samcrew-core-test1 multisig on testnet), captured at
155 // package load — same pattern as channels_v2.
156 owner = unsafe.OriginCaller()
157)
158
159func init() {
160 posts = avl.NewTree()
161 liveFeed = avl.NewTree()
162 byAuthor = avl.NewTree()
163 byParent = avl.NewTree()
164 replyCount = avl.NewTree()
165 lastPostH = avl.NewTree()
166 firstSeenH = avl.NewTree()
167 flags = avl.NewTree()
168 flagSpend = avl.NewTree()
169 reactions = avl.NewTree()
170 moderators = avl.NewTree()
171 tombstones = avl.NewTree()
172 nextPostID = 1
173}
174
175// ── Helpers ──────────────────────────────────────────────────
176
177// padID zero-pads to 12 digits so AVL string order == numeric order.
178// 12 digits ≥ 31,000 years of one post per block — effectively unbounded.
179func padID(id uint64) string {
180 s := strconv.FormatUint(id, 10)
181 for len(s) < 12 {
182 s = "0" + s
183 }
184 return s
185}
186
187func getPost(id uint64) (*Post, bool) {
188 v, ok := posts.Get(padID(id))
189 if !ok {
190 return nil, false
191 }
192 return v.(*Post), true
193}
194
195func mustGetPost(id uint64) *Post {
196 p, ok := getPost(id)
197 if !ok {
198 panic("post not found: " + strconv.FormatUint(id, 10))
199 }
200 return p
201}
202
203func authorKey(addr address, id uint64) string { return addr.String() + ":" + padID(id) }
204func parentKey(parent, child uint64) string { return padID(parent) + ":" + padID(child) }
205
206func getReplyCount(parent uint64) uint64 {
207 if v, ok := replyCount.Get(padID(parent)); ok {
208 return v.(uint64)
209 }
210 return 0
211}
212
213// addToLiveIndexes registers a visible post in every live index (B1).
214//
215// A reply is only linked into its parent's byParent/replyCount indexes when the
216// parent is still LIVE. On CreatePost the parent was just verified live, so this
217// is always true there. The guard matters on the UnhidePost re-add path: if the
218// parent was deleted+swept while the reply sat hidden, re-linking would recreate
219// an orphan byParent/replyCount entry under a parent id no longer in `posts`
220// (never sweepable again) — the exact leak SweepTombstones' byParent-prefix
221// cleanup was added to prevent. An unhidden reply whose parent is gone simply
222// becomes standalone live content, which is how a reply to a removed parent is
223// already treated everywhere else.
224func addToLiveIndexes(p *Post) {
225 liveFeed.Set(padID(p.ID), true)
226 byAuthor.Set(authorKey(p.Author, p.ID), true)
227 liveCount++
228 if p.ReplyTo != 0 && liveFeed.Has(padID(p.ReplyTo)) {
229 byParent.Set(parentKey(p.ReplyTo, p.ID), true)
230 replyCount.Set(padID(p.ReplyTo), getReplyCount(p.ReplyTo)+1)
231 }
232}
233
234// removeFromLiveIndexes drops a post from every live index (hide/delete).
235func removeFromLiveIndexes(p *Post) {
236 if _, ok := liveFeed.Get(padID(p.ID)); !ok {
237 return // already invisible (e.g. delete after auto-hide)
238 }
239 liveFeed.Remove(padID(p.ID))
240 byAuthor.Remove(authorKey(p.Author, p.ID))
241 if liveCount > 0 {
242 liveCount--
243 }
244 if p.ReplyTo != 0 {
245 byParent.Remove(parentKey(p.ReplyTo, p.ID))
246 if rc := getReplyCount(p.ReplyTo); rc > 0 {
247 replyCount.Set(padID(p.ReplyTo), rc-1)
248 }
249 }
250}
251
252func assertNotPaused() {
253 if paused {
254 panic("realm is paused — emergency maintenance")
255 }
256}
257
258func assertCallerIsOwner() {
259 caller := unsafe.PreviousRealm().Address()
260 if caller != owner {
261 panic("unauthorized: caller " + caller.String() + " is not the owner")
262 }
263}
264
265// touchFirstSeen records the first write interaction height for an address.
266func touchFirstSeen(addr address, h int64) {
267 if _, ok := firstSeenH.Get(addr.String()); !ok {
268 firstSeenH.Set(addr.String(), h)
269 }
270}
271
272func accountAge(addr address, now int64) int64 {
273 if v, ok := firstSeenH.Get(addr.String()); ok {
274 return now - v.(int64)
275 }
276 return 0
277}
278
279// ── Emergency pause (B3 — one policy, enforced everywhere) ───
280// While paused every user-facing content write is blocked. Owner moderation
281// and pause management stay live: pause halts user activity during an
282// incident, but the owner must stay able to act. No funds here, so there is
283// no value-exit exemption to carve out.
284
285func PauseRealm(cur realm) {
286 assertCallerIsOwner()
287 paused = true
288 chain.Emit("RealmPaused", "by", owner.String())
289}
290
291func UnpauseRealm(cur realm) {
292 assertCallerIsOwner()
293 paused = false
294 chain.Emit("RealmUnpaused", "by", owner.String())
295}
296
297func IsPaused() bool { return paused }
298
299// TransferOwnership moves realm ownership (deployer multisig rotation).
300func TransferOwnership(cur realm, newOwner address) {
301 assertCallerIsOwner()
302 if newOwner == "" {
303 panic("address cannot be empty")
304 }
305 if newOwner == owner {
306 panic("new owner is the same as current owner")
307 }
308 prev := owner
309 owner = newOwner
310 chain.Emit("OwnershipTransferred",
311 "previousOwner", prev.String(),
312 "newOwner", newOwner.String(),
313 )
314}
315
316// GetOwner returns the current realm owner address.
317func GetOwner() address { return owner }
318
319// ── Writes ───────────────────────────────────────────────────
320
321// CreatePost publishes a post (replyTo == 0) or a reply (replyTo == parent id).
322// Open write: any wallet, subject to the block cooldown and body caps.
323// Returns the new post id.
324func CreatePost(cur realm, body string, replyTo uint64) uint64 {
325 assertNotPaused()
326 caller := unsafe.PreviousRealm().Address()
327 now := runtime.ChainHeight()
328
329 // Cooldown BEFORE touching state: young accounts wait longer.
330 touchFirstSeen(caller, now)
331 interval := int64(MinPostIntervalBlocks)
332 if accountAge(caller, now) < YoungAccountBlocks {
333 interval = YoungMinPostIntervalBlocks
334 }
335 if v, ok := lastPostH.Get(caller.String()); ok {
336 if now-v.(int64) < interval {
337 panic(ufmt.Sprintf("posting too fast: wait %d blocks between posts", interval))
338 }
339 }
340
341 if len(body) == 0 || len(body) > MaxBodyLen {
342 panic(ufmt.Sprintf("body must be 1-%d characters", MaxBodyLen))
343 }
344
345 var parent *Post
346 if replyTo != 0 {
347 parent = mustGetPost(replyTo)
348 if parent.Deleted {
349 panic("cannot reply to a deleted post")
350 }
351 if parent.Hidden {
352 panic("cannot reply to a hidden post")
353 }
354 // B1: bound the LIVE reply set so the byParent index under one parent
355 // stays bounded (and a swept parent's link cleanup stays bounded).
356 if getReplyCount(replyTo) >= MaxRepliesPerPost {
357 panic(ufmt.Sprintf("reply limit reached: %d per post", MaxRepliesPerPost))
358 }
359 }
360
361 id := nextPostID
362 nextPostID++
363
364 p := &Post{
365 ID: id,
366 Author: caller,
367 Body: body,
368 ReplyTo: replyTo,
369 BlockH: now,
370 }
371 posts.Set(padID(id), p)
372 addToLiveIndexes(p)
373 lastPostH.Set(caller.String(), now)
374
375 chain.Emit("PostCreated",
376 "postId", strconv.FormatUint(id, 10),
377 "author", caller.String(),
378 "replyTo", strconv.FormatUint(replyTo, 10),
379 "body", body,
380 )
381 return id
382}
383
384// EditPost lets the author replace the body of a visible post.
385func EditPost(cur realm, id uint64, newBody string) {
386 assertNotPaused()
387 caller := unsafe.PreviousRealm().Address()
388
389 p := mustGetPost(id)
390 if p.Author != caller {
391 panic("only the author can edit")
392 }
393 if p.Deleted {
394 panic("cannot edit a deleted post")
395 }
396 if p.Hidden {
397 panic("cannot edit a hidden post")
398 }
399 if len(newBody) == 0 || len(newBody) > MaxBodyLen {
400 panic(ufmt.Sprintf("body must be 1-%d characters", MaxBodyLen))
401 }
402
403 p.Body = newBody
404 p.EditedAt = runtime.ChainHeight()
405 posts.Set(padID(id), p)
406
407 chain.Emit("PostEdited",
408 "postId", strconv.FormatUint(id, 10),
409 "author", caller.String(),
410 "body", newBody,
411 )
412}
413
414// DeletePost soft-deletes the author's own post: content cleared, dropped from
415// every live index (frees render slots immediately — B1), queued for hard-GC
416// (B2). Replies stay: they render as replies to an unavailable post.
417func DeletePost(cur realm, id uint64) {
418 assertNotPaused()
419 caller := unsafe.PreviousRealm().Address()
420
421 p := mustGetPost(id)
422 if p.Author != caller {
423 panic("only the author can delete")
424 }
425 if p.Deleted {
426 panic("post already deleted")
427 }
428
429 removeFromLiveIndexes(p)
430 p.Deleted = true
431 p.Body = ""
432 p.MediaCIDs = nil
433 posts.Set(padID(id), p)
434 tombstones.Set(padID(id), true)
435
436 chain.Emit("PostDeleted",
437 "postId", strconv.FormatUint(id, 10),
438 "author", caller.String(),
439 )
440}
441
442// ── Flag pipeline ────────────────────────────────────────────
443
444// FlagPost files a community flag. Guards, in order:
445// - flag rights are EARNED BY PARTICIPATION: the caller must have a
446// firstSeen record (anchored by their first successful CreatePost) at
447// least MinAccountAgeForFlag blocks old. A failed flag cannot anchor age
448// itself — an on-chain abort reverts every write in the tx, so recording
449// firstSeen here and then panicking would revert the record and deadlock
450// pure flaggers; requiring a prior post is the honest, implementable
451// account-age gate (realms cannot query global account age);
452// - one flag per address per post;
453// - per-day flag budget per address (blunts coordinated flag-brigades).
454//
455// At FlagThreshold unique flags the post auto-hides (reversible via UnhidePost).
456func FlagPost(cur realm, id uint64) {
457 assertNotPaused()
458 caller := unsafe.PreviousRealm().Address()
459 now := runtime.ChainHeight()
460
461 if accountAge(caller, now) < MinAccountAgeForFlag {
462 panic(ufmt.Sprintf("flagging requires having posted at least %d blocks ago", MinAccountAgeForFlag))
463 }
464
465 p := mustGetPost(id)
466 if p.Deleted {
467 panic("cannot flag a deleted post")
468 }
469 if p.Hidden {
470 panic("post is already hidden")
471 }
472
473 // Per-day budget window.
474 var fb *flagBudget
475 if v, ok := flagSpend.Get(caller.String()); ok {
476 fb = v.(*flagBudget)
477 } else {
478 fb = &flagBudget{DayStartH: now}
479 }
480 if now-fb.DayStartH >= BlocksPerDay {
481 fb.DayStartH = now
482 fb.Used = 0
483 }
484 if fb.Used >= FlagsPerDayBudget {
485 panic(ufmt.Sprintf("daily flag budget reached: %d per %d blocks", FlagsPerDayBudget, BlocksPerDay))
486 }
487
488 // Unique flaggers per post.
489 var flagTree *avl.Tree
490 if v, ok := flags.Get(padID(id)); ok {
491 flagTree = v.(*avl.Tree)
492 } else {
493 flagTree = avl.NewTree()
494 }
495 if _, already := flagTree.Get(caller.String()); already {
496 panic("already flagged")
497 }
498 flagTree.Set(caller.String(), true)
499 flags.Set(padID(id), flagTree)
500
501 fb.Used++
502 flagSpend.Set(caller.String(), fb)
503
504 p.FlagCount = flagTree.Size()
505 wasHidden := p.Hidden
506 if p.FlagCount >= FlagThreshold {
507 p.Hidden = true
508 removeFromLiveIndexes(p)
509 // A flag-hidden post is deliberately NOT tombstoned: its node and its
510 // `flags` subtree (the flagger set) are retained on purpose as the audit
511 // trail the W8.2 moderation board reviews (and UnhidePost needs, to
512 // reverse a brigade). It leaves the live indexes (B1 render bounds still
513 // hold), so this is bounded, intentional retention — not a leak. Author
514 // delete / mod-remove are the paths that tombstone for GC.
515 }
516 posts.Set(padID(id), p)
517
518 chain.Emit("PostFlagged",
519 "postId", strconv.FormatUint(id, 10),
520 "flagger", caller.String(),
521 "flagCount", strconv.Itoa(p.FlagCount),
522 )
523 if !wasHidden && p.Hidden {
524 chain.Emit("PostAutoHidden", "postId", strconv.FormatUint(id, 10))
525 }
526}
527
528// ── Reactions (one-per-emoji toggle; open-write, gas is the throttle) ─
529//
530// A wallet may add each supported emoji at most once per post, and remove it
531// again. Reactions carry no content (no DoS/GDPR surface like a post body), so
532// there is no account-age gate — the per-(post,emoji,addr) dedup plus the gas
533// cost of an on-chain tx are the throttle. Per-emoji COUNTS are aggregated
534// off-chain by the indexer from the events below; the realm NEVER scans a
535// post's reaction set, so the B1 render bounds are untouched.
536
537// reactionEmojis is the fixed on-chain reaction set. Changing it is a realm
538// version bump (redeploy), so it is deliberately small and stable.
539var reactionEmojis = []string{"👍", "❤️", "😂", "😮", "😢", "🔥", "🎉", "👀", "🚀"}
540
541func isReactionEmoji(e string) bool {
542 for _, r := range reactionEmojis {
543 if r == e {
544 return true
545 }
546 }
547 return false
548}
549
550// reactionSubKey namespaces a reactor's entry within a post's reaction tree.
551// The NUL separator cannot appear in a fixed-set emoji or a bech32 address, so
552// (emoji, addr) round-trips unambiguously.
553func reactionSubKey(emoji string, addr address) string {
554 return emoji + "\x00" + addr.String()
555}
556
557// HasReacted reports whether addr currently has the given emoji on the post.
558// O(log n) point lookup — never a scan.
559func HasReacted(id uint64, emoji string, addr address) bool {
560 v, ok := reactions.Get(padID(id))
561 if !ok {
562 return false
563 }
564 _, exists := v.(*avl.Tree).Get(reactionSubKey(emoji, addr))
565 return exists
566}
567
568// AddReaction records the caller's emoji reaction. A repeat aborts (rather than
569// no-op) so an optimistic client's double-tap cannot silently burn gas twice —
570// the client gates the button on HasReacted / indexed state.
571func AddReaction(cur realm, id uint64, emoji string) {
572 assertNotPaused()
573 if !isReactionEmoji(emoji) {
574 panic("unsupported reaction")
575 }
576 caller := unsafe.PreviousRealm().Address()
577 now := runtime.ChainHeight()
578
579 p := mustGetPost(id)
580 if p.Deleted {
581 panic("cannot react to a deleted post")
582 }
583 if p.Hidden {
584 panic("cannot react to a hidden post")
585 }
586 touchFirstSeen(caller, now)
587
588 var tree *avl.Tree
589 if v, ok := reactions.Get(padID(id)); ok {
590 tree = v.(*avl.Tree)
591 } else {
592 tree = avl.NewTree()
593 }
594 key := reactionSubKey(emoji, caller)
595 if _, already := tree.Get(key); already {
596 panic("already reacted with this emoji")
597 }
598 tree.Set(key, true)
599 reactions.Set(padID(id), tree)
600
601 chain.Emit("ReactionAdded",
602 "postId", strconv.FormatUint(id, 10),
603 "emoji", emoji,
604 "by", caller.String(),
605 )
606}
607
608// RemoveReaction removes the caller's emoji reaction (toggle-off). Allowed even
609// on a hidden/deleted post so a reactor can always retract; aborts if the caller
610// had not reacted with that emoji.
611func RemoveReaction(cur realm, id uint64, emoji string) {
612 assertNotPaused()
613 if !isReactionEmoji(emoji) {
614 panic("unsupported reaction")
615 }
616 caller := unsafe.PreviousRealm().Address()
617 mustGetPost(id) // aborts if the post never existed
618
619 v, ok := reactions.Get(padID(id))
620 if !ok {
621 panic("you have not reacted to this post")
622 }
623 tree := v.(*avl.Tree)
624 key := reactionSubKey(emoji, caller)
625 if _, exists := tree.Get(key); !exists {
626 panic("you have not reacted with this emoji")
627 }
628 tree.Remove(key)
629 if tree.Size() == 0 {
630 reactions.Remove(padID(id)) // GC the empty subtree
631 } else {
632 reactions.Set(padID(id), tree)
633 }
634
635 chain.Emit("ReactionRemoved",
636 "postId", strconv.FormatUint(id, 10),
637 "emoji", emoji,
638 "by", caller.String(),
639 )
640}
641
642// ── Moderator role (W8.2) ────────────────────────────────────
643// The owner grants/revokes moderator addresses that can ModRemovePost /
644// UnhidePost. Fast (no owner-multisig-per-post) and reversible; grant/revoke
645// stays owner-only. Every mutation is emitted for public audit.
646
647func assertCallerIsOwnerOrModerator() {
648 caller := unsafe.PreviousRealm().Address()
649 if caller == owner {
650 return
651 }
652 if _, ok := moderators.Get(caller.String()); ok {
653 return
654 }
655 panic("unauthorized: caller " + caller.String() + " is not the owner or a moderator")
656}
657
658// IsModerator reports whether addr currently holds the moderator role.
659func IsModerator(addr address) bool {
660 _, ok := moderators.Get(addr.String())
661 return ok
662}
663
664// AddModerator grants the moderator role (owner only).
665func AddModerator(cur realm, addr address) {
666 assertCallerIsOwner()
667 if addr == "" {
668 panic("address cannot be empty")
669 }
670 moderators.Set(addr.String(), true)
671 chain.Emit("ModeratorAdded", "moderator", addr.String(), "by", owner.String())
672}
673
674// RemoveModerator revokes the moderator role (owner only).
675func RemoveModerator(cur realm, addr address) {
676 assertCallerIsOwner()
677 if _, ok := moderators.Get(addr.String()); !ok {
678 panic("address is not a moderator")
679 }
680 moderators.Remove(addr.String())
681 chain.Emit("ModeratorRemoved", "moderator", addr.String(), "by", owner.String())
682}
683
684// ── Moderation (owner OR moderator; W8.2 hot-key lever) ───────
685
686// ModRemovePost permanently hides a post by moderation. Emits an audited
687// ModAction. The node is tombstoned for hard-GC like an author delete.
688func ModRemovePost(cur realm, id uint64) {
689 assertCallerIsOwnerOrModerator()
690
691 p := mustGetPost(id)
692 if !p.Deleted {
693 removeFromLiveIndexes(p)
694 tombstones.Set(padID(id), true)
695 }
696 p.Deleted = true
697 p.Hidden = true
698 p.Body = ""
699 p.MediaCIDs = nil
700 posts.Set(padID(id), p)
701
702 chain.Emit("ModAction",
703 "action", "remove",
704 "postId", strconv.FormatUint(id, 10),
705 "moderator", unsafe.PreviousRealm().Address().String(),
706 )
707}
708
709// UnhidePost clears flags and restores a flag-hidden post to the live set.
710func UnhidePost(cur realm, id uint64) {
711 assertCallerIsOwnerOrModerator()
712
713 p := mustGetPost(id)
714 if p.Deleted {
715 panic("cannot unhide a deleted post")
716 }
717 if !p.Hidden {
718 panic("post is not hidden")
719 }
720 p.Hidden = false
721 p.FlagCount = 0
722 posts.Set(padID(id), p)
723 flags.Remove(padID(id))
724 addToLiveIndexes(p)
725
726 chain.Emit("ModAction",
727 "action", "unhide",
728 "postId", strconv.FormatUint(id, 10),
729 "moderator", unsafe.PreviousRealm().Address().String(),
730 )
731}
732
733// ── Tombstone sweep (B2 hard-GC, ported from channels_v2) ────
734
735// SweepTombstones hard-removes up to `limit` soft-deleted posts, reclaiming
736// AVL storage so post+delete spam cannot accrete permanent state.
737// Permissionless by design (state-shrink hygiene primitive, not a refund
738// path). Bounded + idempotent: keep `limit` small (1-10); re-running drains
739// the next batch and stops at 0. Returns the number of posts swept.
740//
741// For a swept post that was a PARENT, its surviving replies' byParent links
742// (`padID(parent):padID(child)`) would otherwise be orphaned under an id no
743// longer in `posts` — a permanent leak plus a read inconsistency (replies
744// still enumerable, getReplyCount() disagreeing). So the sweep range-deletes
745// that byParent prefix; the reply posts themselves stay as standalone live
746// content (their parent was removed by its own author). Bounded because the
747// live reply set per post is capped at MaxRepliesPerPost — but a reply-heavy
748// parent makes one sweep step do up to that many removes, so keep `limit`
749// small (1) when draining known reply-heavy tombstones.
750func SweepTombstones(cur realm, limit int) int {
751 assertNotPaused()
752 if limit <= 0 {
753 return 0
754 }
755
756 // Collect the oldest `limit` tombstoned ids (ascending), then mutate —
757 // never remove from the tree we are iterating (AVL footgun).
758 ids := []uint64{}
759 tombstones.Iterate("", "", func(key string, _ interface{}) bool {
760 ids = append(ids, idFromPadded(key))
761 return len(ids) >= limit
762 })
763
764 for _, id := range ids {
765 // Drop this parent's remaining child links (if any) so no byParent
766 // entry outlives its parent. Collect-then-remove over the prefix.
767 prefix := padID(id) + ":"
768 childKeys := []string{}
769 byParent.Iterate(prefix, prefix+"\xff", func(k string, _ interface{}) bool {
770 childKeys = append(childKeys, k)
771 return false
772 })
773 for _, k := range childKeys {
774 byParent.Remove(k)
775 }
776
777 posts.Remove(padID(id))
778 flags.Remove(padID(id))
779 replyCount.Remove(padID(id))
780 tombstones.Remove(padID(id))
781 }
782
783 if len(ids) > 0 {
784 chain.Emit("TombstonesSwept", "count", strconv.Itoa(len(ids)))
785 }
786 return len(ids)
787}
788
789// GetTombstoneCount returns how many soft-deleted posts await hard-GC.
790func GetTombstoneCount() int { return tombstones.Size() }
791
792// ── JSON reads (vm/qeval; cursor-paginated, O(limit) at any depth) ──
793
794// jsonEscape escapes a string for embedding in a JSON string literal.
795func jsonEscape(s string) string {
796 var sb strings.Builder
797 for _, c := range s {
798 switch c {
799 case '"':
800 sb.WriteString("\\\"")
801 case '\\':
802 sb.WriteString("\\\\")
803 case '\n':
804 sb.WriteString("\\n")
805 case '\r':
806 sb.WriteString("\\r")
807 case '\t':
808 sb.WriteString("\\t")
809 default:
810 if c < 0x20 {
811 continue // drop raw control chars (ufmt has no \uXXXX support)
812 }
813 sb.WriteRune(c)
814 }
815 }
816 return sb.String()
817}
818
819// postJSON is the qeval read fallback. Note it exposes `mediaCids` (P2) and
820// `repostOf` (P1) which the CANONICAL indexed path (chain.Emit → backend →
821// feed_rpc → UI) does NOT carry yet — PostCreated emits neither, the proto
822// reserves repost_of, and there is no media column. A future dev wiring the
823// qeval fallback must not assume those two are plumbed end-to-end.
824func postJSON(p *Post) string {
825 var media strings.Builder
826 media.WriteString("[")
827 for i, cid := range p.MediaCIDs {
828 if i > 0 {
829 media.WriteString(",")
830 }
831 media.WriteString("\"" + jsonEscape(cid) + "\"")
832 }
833 media.WriteString("]")
834
835 return ufmt.Sprintf(
836 `{"id":%d,"author":"%s","body":"%s","mediaCids":%s,"replyTo":%d,"repostOf":%d,"blockH":%d,"editedAt":%d,"flagCount":%d,"hidden":%s,"deleted":%s,"replies":%d}`,
837 p.ID, p.Author.String(), jsonEscape(p.Body), media.String(),
838 p.ReplyTo, p.RepostOf, p.BlockH, p.EditedAt, p.FlagCount,
839 boolStr(p.Hidden), boolStr(p.Deleted), getReplyCount(p.ID),
840 )
841}
842
843func boolStr(b bool) string {
844 if b {
845 return "true"
846 }
847 return "false"
848}
849
850func clampLimit(limit int) int {
851 if limit <= 0 {
852 return FeedPageSize
853 }
854 if limit > MaxPageLimit {
855 return MaxPageLimit
856 }
857 return limit
858}
859
860// GetPostJSON returns one post (any state — the indexer needs tombstones too).
861func GetPostJSON(id uint64) string {
862 p, ok := getPost(id)
863 if !ok {
864 return "null"
865 }
866 return postJSON(p)
867}
868
869// listWindow walks `tree` REVERSE (newest first) starting below cursorKey
870// ("" = from the newest), collecting up to limit ids via extract.
871func listWindow(tree *avl.Tree, cursorKey string, limit int, extract func(key string) uint64) []uint64 {
872 ids := []uint64{}
873 count := 0
874 tree.ReverseIterate("", cursorKey, func(key string, _ interface{}) bool {
875 id := extract(key)
876 if id != 0 {
877 ids = append(ids, id)
878 count++
879 }
880 return count >= limit
881 })
882 return ids
883}
884
885func idFromPadded(key string) uint64 {
886 n, err := strconv.ParseUint(key, 10, 64)
887 if err != nil {
888 return 0
889 }
890 return n
891}
892
893func idFromComposite(key string) uint64 {
894 i := strings.LastIndexByte(key, ':')
895 if i < 0 {
896 return 0
897 }
898 return idFromPadded(key[i+1:])
899}
900
901func idsToJSON(ids []uint64) string {
902 var sb strings.Builder
903 sb.WriteString("[")
904 for i, id := range ids {
905 if i > 0 {
906 sb.WriteString(",")
907 }
908 if p, ok := getPost(id); ok {
909 sb.WriteString(postJSON(p))
910 }
911 }
912 sb.WriteString("]")
913 return sb.String()
914}
915
916// ListFeedJSON returns the newest live posts strictly older than cursor
917// (cursor == 0 → from the top). Pass the last id of the previous window as
918// the next cursor.
919//
920// ReverseIterate treats BOTH bounds as inclusive (avl/v0 TraverseInRange), so
921// "strictly older than cursor" = end bound padID(cursor-1): ids are integers,
922// keys are fixed-width, hence key < padID(cursor) ⟺ key ≤ padID(cursor-1).
923func ListFeedJSON(cursor uint64, limit int) string {
924 limit = clampLimit(limit)
925 cursorKey := ""
926 if cursor != 0 {
927 cursorKey = padID(cursor - 1)
928 }
929 return idsToJSON(listWindow(liveFeed, cursorKey, limit, idFromPadded))
930}
931
932// ListUserJSON returns a user's newest live posts strictly older than cursor
933// (same inclusive-bound rule as ListFeedJSON).
934func ListUserJSON(addr string, cursor uint64, limit int) string {
935 limit = clampLimit(limit)
936 cursorKey := ""
937 if cursor != 0 {
938 cursorKey = addr + ":" + padID(cursor-1)
939 } else {
940 // End bound just past every "addr:XXXXXXXXXXXX" key: ':' + 0xff.
941 cursorKey = addr + ":\xff"
942 }
943 ids := []uint64{}
944 count := 0
945 byAuthor.ReverseIterate(addr+":", cursorKey, func(key string, _ interface{}) bool {
946 if id := idFromComposite(key); id != 0 {
947 ids = append(ids, id)
948 count++
949 }
950 return count >= limit
951 })
952 return idsToJSON(ids)
953}
954
955// ListRepliesJSON returns a post's live replies, OLDEST first (conversation
956// order), starting strictly after cursor (0 → from the beginning).
957func ListRepliesJSON(parent uint64, cursor uint64, limit int) string {
958 limit = clampLimit(limit)
959 start := padID(parent) + ":"
960 if cursor != 0 {
961 start = parentKey(parent, cursor) + "\x00" // strictly after the cursor child
962 }
963 ids := []uint64{}
964 count := 0
965 byParent.Iterate(start, padID(parent)+":\xff", func(key string, _ interface{}) bool {
966 if id := idFromComposite(key); id != 0 {
967 ids = append(ids, id)
968 count++
969 }
970 return count >= limit
971 })
972 return idsToJSON(ids)
973}
974
975// GetStatsJSON returns realm-level counters (monotonic id is informational —
976// no read iterates it).
977func GetStatsJSON() string {
978 return ufmt.Sprintf(`{"livePosts":%d,"nextPostId":%d,"tombstones":%d,"paused":%s}`,
979 liveCount, nextPostID, tombstones.Size(), boolStr(paused))
980}
981
982// ── Render (gnoweb human view; bounded — B1) ─────────────────
983//
984// Paths:
985// "" → newest window (page 1)
986// page/K → K-th newest window, K ≤ MaxRenderPage
987// user/ADDR → ADDR's newest window
988// user/ADDR/K → K-th window of ADDR's posts
989// post/ID → one post + its newest reply window
990//
991// Every page scans at most MaxRenderPage*FeedPageSize live-index keys and
992// fetches at most FeedPageSize posts — bounded regardless of feed size or
993// churn (deleted/hidden posts are not in the live indexes at all).
994
995func Render(path string) string {
996 if path == "" {
997 return renderFeedPage(1)
998 }
999 if strings.HasPrefix(path, "page/") {
1000 k, err := strconv.ParseUint(strings.TrimPrefix(path, "page/"), 10, 64)
1001 if err != nil || k == 0 {
1002 return "# 404\nBad page"
1003 }
1004 return renderFeedPage(k)
1005 }
1006 if strings.HasPrefix(path, "user/") {
1007 rest := strings.TrimPrefix(path, "user/")
1008 addr := rest
1009 k := uint64(1)
1010 if i := strings.IndexByte(rest, '/'); i >= 0 {
1011 addr = rest[:i]
1012 n, err := strconv.ParseUint(rest[i+1:], 10, 64)
1013 if err != nil || n == 0 {
1014 return "# 404\nBad page"
1015 }
1016 k = n
1017 }
1018 return renderUserPage(addr, k)
1019 }
1020 if strings.HasPrefix(path, "post/") {
1021 id, err := strconv.ParseUint(strings.TrimPrefix(path, "post/"), 10, 64)
1022 if err != nil {
1023 return "# 404\nBad post id"
1024 }
1025 return renderPost(id)
1026 }
1027 return "# 404\nPage not found: " + path
1028}
1029
1030// collectPage walks a live index reverse and returns the ids for window k
1031// (1-based). Scan is bounded: k is capped at MaxRenderPage by callers.
1032func collectPage(tree *avl.Tree, prefix, endKey string, k uint64) []uint64 {
1033 skip := (k - 1) * FeedPageSize
1034 ids := []uint64{}
1035 seen := uint64(0)
1036 tree.ReverseIterate(prefix, endKey, func(key string, _ interface{}) bool {
1037 if seen >= skip {
1038 var id uint64
1039 if prefix == "" {
1040 id = idFromPadded(key)
1041 } else {
1042 id = idFromComposite(key)
1043 }
1044 if id != 0 {
1045 ids = append(ids, id)
1046 }
1047 }
1048 seen++
1049 return len(ids) >= FeedPageSize
1050 })
1051 return ids
1052}
1053
1054func renderPostLine(sb *strings.Builder, p *Post) {
1055 sb.WriteString(ufmt.Sprintf("**%s** (block %d)", truncAddr(p.Author), p.BlockH))
1056 if p.EditedAt != 0 {
1057 sb.WriteString(" *(edited)*")
1058 }
1059 if p.ReplyTo != 0 {
1060 sb.WriteString(ufmt.Sprintf(" — reply to [post %d](:post/%d)", p.ReplyTo, p.ReplyTo))
1061 }
1062 sb.WriteString("\n\n")
1063 sb.WriteString(sanitizeForRender(p.Body) + "\n\n")
1064 rc := getReplyCount(p.ID)
1065 sb.WriteString(ufmt.Sprintf("[permalink](:post/%d) | %d replies\n\n---\n\n", p.ID, rc))
1066}
1067
1068func renderFeedPage(k uint64) string {
1069 if k > MaxRenderPage {
1070 return ufmt.Sprintf("# Memba Feed\n\n*Pages beyond %d are not rendered — use the JSON cursor API (ListFeedJSON) or the Memba app.*\n", MaxRenderPage)
1071 }
1072 var sb strings.Builder
1073 sb.WriteString("# Memba Feed\n\n")
1074 sb.WriteString(ufmt.Sprintf("**Live posts:** %d\n\n", liveCount))
1075
1076 ids := collectPage(liveFeed, "", "", k)
1077 if len(ids) == 0 {
1078 sb.WriteString("*No posts on this page.*\n")
1079 return sb.String()
1080 }
1081 for _, id := range ids {
1082 if p, ok := getPost(id); ok {
1083 renderPostLine(&sb, p)
1084 }
1085 }
1086 sb.WriteString(ufmt.Sprintf("*page %d — older: `:page/%d`*\n", k, k+1))
1087 return sb.String()
1088}
1089
1090func renderUserPage(addr string, k uint64) string {
1091 if k > MaxRenderPage {
1092 return ufmt.Sprintf("# Feed — %s\n\n*Pages beyond %d are not rendered — use ListUserJSON or the Memba app.*\n", addr, MaxRenderPage)
1093 }
1094 var sb strings.Builder
1095 sb.WriteString(ufmt.Sprintf("# Feed — %s\n\n", addr))
1096 ids := collectPage(byAuthor, addr+":", addr+":\xff", k)
1097 if len(ids) == 0 {
1098 sb.WriteString("*No posts on this page.*\n")
1099 return sb.String()
1100 }
1101 for _, id := range ids {
1102 if p, ok := getPost(id); ok {
1103 renderPostLine(&sb, p)
1104 }
1105 }
1106 sb.WriteString(ufmt.Sprintf("*page %d — older: `:user/%s/%d`*\n", k, addr, k+1))
1107 return sb.String()
1108}
1109
1110func renderPost(id uint64) string {
1111 p, ok := getPost(id)
1112 if !ok {
1113 return "# 404\nPost not found"
1114 }
1115 // Same suppression rule as channels_v2: the direct path must not leak
1116 // hidden/deleted content.
1117 if p.Hidden || p.Deleted {
1118 return "# Post unavailable\n\n*This post has been hidden or removed.*\n"
1119 }
1120
1121 var sb strings.Builder
1122 sb.WriteString(ufmt.Sprintf("# Post %d\n\n", p.ID))
1123 renderPostLine(&sb, p)
1124
1125 // Newest reply window only (fixed size); full thread = JSON cursor API.
1126 rc := getReplyCount(p.ID)
1127 if rc > 0 {
1128 sb.WriteString("## Replies\n\n")
1129 if rc > FeedPageSize {
1130 sb.WriteString(ufmt.Sprintf("*Showing the %d newest of %d — full thread via ListRepliesJSON or the app.*\n\n", FeedPageSize, rc))
1131 }
1132 ids := collectPage(byParent, padID(p.ID)+":", padID(p.ID)+":\xff", 1)
1133 // collectPage walks reverse (newest first); show oldest→newest.
1134 for i := len(ids) - 1; i >= 0; i-- {
1135 if r, ok := getPost(ids[i]); ok {
1136 renderPostLine(&sb, r)
1137 }
1138 }
1139 }
1140 return sb.String()
1141}
1142
1143// ── Shared render helpers (ported verbatim from channels_v2) ─
1144
1145func truncAddr(addr address) string {
1146 s := string(addr)
1147 if len(s) > 13 {
1148 return s[:10] + "..."
1149 }
1150 return s
1151}
1152
1153// sanitizeForRender strips markdown-sensitive characters to prevent injection.
1154func sanitizeForRender(s string) string {
1155 var out strings.Builder
1156 for _, c := range s {
1157 switch c {
1158 case '[', ']', '(', ')', '#', '*', '`', '!', '<', '>', '|', '\\', '_', '~', '\n', '\r', '\t':
1159 continue
1160 default:
1161 out.WriteRune(c)
1162 }
1163 }
1164 return out.String()
1165}