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