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