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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

memba_reviews_v2.gno

23.01 Kb · 759 lines
  1package memba_reviews_v2
  2
  3// Memba Reviews / Web-of-Trust realm.
  4//
  5// OPEN, on-chain ratings + reviews for any Memba subject (validator/candidate/
  6// individual address, or an org/DAO realm path). Anyone with a wallet may post
  7// ONE editable review per subject, react (like/dislike), reply (flat, one
  8// level), and flag. A running net-likes reputation counter per author drives
  9// ranking. Moderation = author delete (tombstone) + community flag + multisig
 10// hide (soft-delete). Text is permanent on-chain; Hide only omits from reads.
 11//
 12// Reads: exported *JSON funcs queried via RPC vm/qeval (paginated). Render() is
 13// a secondary human view for gnoweb.
 14
 15import (
 16	"strconv"
 17	"strings"
 18
 19	"gno.land/p/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/avl"
 20	"gno.land/p/nt/ufmt/v0"
 21
 22	"chain"
 23	"chain/runtime"
 24	"chain/runtime/unsafe"
 25)
 26
 27// ── Constants ────────────────────────────────────────────────
 28const (
 29	MaxBodyLen    = 2000 // review body
 30	MaxCommentLen = 1000 // comment body
 31	MaxPageLimit  = 100  // hard cap on any paginated read (DoS guard)
 32
 33	// The moderator is not a constant: admin.gno seeds it from the publisher at
 34	// package load and rotates it via TransferOwnership/AcceptOwnership.
 35)
 36
 37// ── Types ────────────────────────────────────────────────────
 38type Review struct {
 39	ID        uint64
 40	Subject   string  // g1… address OR realm path
 41	Author    address
 42	Rating    int     // 1..5
 43	Body      string  // optional, ≤ MaxBodyLen
 44	CreatedAt int64   // block height
 45	EditedAt  int64   // block height of last edit (0 if never)
 46	Hidden    bool    // multisig soft-delete / auto-hide
 47	Deleted   bool    // author tombstone
 48	Likes     uint64
 49	Dislikes  uint64
 50	FlagCount uint64
 51}
 52
 53type Comment struct {
 54	ID        uint64
 55	ReviewID  uint64
 56	Author    address
 57	Body      string // ≤ MaxCommentLen
 58	CreatedAt int64
 59	EditedAt  int64
 60	Hidden    bool
 61	Deleted   bool
 62	Likes     uint64
 63	Dislikes  uint64
 64	FlagCount uint64
 65}
 66
 67// ── State ────────────────────────────────────────────────────
 68var (
 69	reviews       *avl.Tree // strID(id) -> *Review
 70	comments      *avl.Tree // strID(id) -> *Comment
 71	subjectIndex  *avl.Tree // subject -> []uint64 (review IDs, ascending) — bounds reads
 72	commentIndex  *avl.Tree // strID(reviewID) -> []uint64 (comment IDs, ascending)
 73	authorSubject *avl.Tree // subject + "\x00" + author -> uint64 (reviewID) — one-per-pair
 74	reactions     *avl.Tree // strID(targetID) + "/" + addr -> "like"|"dislike"
 75	flags         *avl.Tree // strID(targetID) + "/" + addr -> true (one flag per acct/target)
 76	reputation    *avl.Tree // addr -> int64 (Σ likes−dislikes on their reviews+comments)
 77	flaggedIDs    *avl.Tree // strID(targetID) -> true (visible targets with ≥1 flag, for the mod dashboard)
 78	// RV-1: O(1) per-subject visible-review counters (visible = !Deleted && !Hidden) so
 79	// GetSubjectSummaryJSON never scans a sybil-growable review list. Maintained by every
 80	// transition that changes a review's counted state (post / edit-rating / delete / hide / unhide).
 81	subjStatCount *avl.Tree // subject -> int64 (# visible reviews)
 82	subjStatSum   *avl.Tree // subject -> int64 (Σ rating over visible reviews)
 83	nextID        uint64
 84)
 85
 86func init() {
 87	reviews = avl.NewTree()
 88	comments = avl.NewTree()
 89	subjectIndex = avl.NewTree()
 90	commentIndex = avl.NewTree()
 91	authorSubject = avl.NewTree()
 92	reactions = avl.NewTree()
 93	flags = avl.NewTree()
 94	reputation = avl.NewTree()
 95	flaggedIDs = avl.NewTree()
 96	subjStatCount = avl.NewTree()
 97	subjStatSum = avl.NewTree()
 98	nextID = 1
 99}
100
101// ── RV-1 subject-stat counters (O(1) summary) ────────────────
102func getStat(t *avl.Tree, subject string) int64 {
103	if v, ok := t.Get(subject); ok {
104		return v.(int64)
105	}
106	return 0
107}
108
109// applySubjectStat adjusts the visible-review counters for a subject. Callers apply a delta
110// only when a review's *counted* state (visible = !Deleted && !Hidden) actually changes.
111func applySubjectStat(subject string, dCount, dSum int64) {
112	if dCount != 0 {
113		subjStatCount.Set(subject, getStat(subjStatCount, subject)+dCount)
114	}
115	if dSum != 0 {
116		subjStatSum.Set(subject, getStat(subjStatSum, subject)+dSum)
117	}
118}
119
120// ── Helpers ──────────────────────────────────────────────────
121func strID(id uint64) string { return strconv.FormatUint(id, 10) }
122
123func getReview(id uint64) (*Review, bool) {
124	v, ok := reviews.Get(strID(id))
125	if !ok {
126		return nil, false
127	}
128	return v.(*Review), true
129}
130
131func getComment(id uint64) (*Comment, bool) {
132	v, ok := comments.Get(strID(id))
133	if !ok {
134		return nil, false
135	}
136	return v.(*Comment), true
137}
138
139func assertModerator() {
140	caller := unsafe.PreviousRealm().Address()
141	if caller != moderator {
142		panic("unauthorized: moderator multisig only")
143	}
144}
145
146func getReputation(addr string) int64 {
147	if v, ok := reputation.Get(addr); ok {
148		return v.(int64)
149	}
150	return 0
151}
152
153func addReputation(addr string, delta int64) {
154	reputation.Set(addr, getReputation(addr)+delta)
155}
156
157func idList(t *avl.Tree, key string) []uint64 {
158	if v, ok := t.Get(key); ok {
159		return v.([]uint64)
160	}
161	return nil
162}
163
164// removeID returns ids with the first occurrence of target removed.
165func removeID(ids []uint64, target uint64) []uint64 {
166	out := make([]uint64, 0, len(ids))
167	removed := false
168	for _, id := range ids {
169		if !removed && id == target {
170			removed = true
171			continue
172		}
173		out = append(out, id)
174	}
175	return out
176}
177
178// sanitizeForRender strips markdown/HTML-sensitive chars from user strings used
179// inside Render() markdown (defense-in-depth; the frontend also DOMPurifies).
180// "&" is escaped FIRST to prevent entity-injection (e.g. "<" → "<").
181func sanitizeForRender(s string) string {
182	r := strings.NewReplacer(
183		"&", "&",
184		"<", "&lt;", ">", "&gt;",
185		"[", "(", "]", ")",
186		"`", "'", "|", "/",
187		"\n", " ", "\r", " ",
188	)
189	return r.Replace(s)
190}
191
192// jsonEscape escapes a string for embedding in the realm's hand-built JSON.
193func jsonEscape(s string) string {
194	var b strings.Builder
195	for _, c := range s {
196		switch c {
197		case '"':
198			b.WriteString("\\\"")
199		case '\\':
200			b.WriteString("\\\\")
201		case '\n':
202			b.WriteString("\\n")
203		case '\r':
204			b.WriteString("\\r")
205		case '\t':
206			b.WriteString("\\t")
207		default:
208			if c < 0x20 {
209				const hexDigits = "0123456789abcdef"
210				b.WriteString("\\u00")
211				b.WriteByte(hexDigits[int((c>>4)&0xf)])
212				b.WriteByte(hexDigits[int(c&0xf)])
213			} else {
214				b.WriteRune(c)
215			}
216		}
217	}
218	return b.String()
219}
220
221// ── Validation helpers (pure, no side-effects) ────────────────
222
223func validRating(r int) bool { return r >= 1 && r <= 5 }
224
225func validBody(b string) bool { return len(b) <= MaxBodyLen }
226
227// pairKey returns the authorSubject tree key for a (subject, author) pair.
228// The NUL separator prevents prefix collisions between subject and author.
229func pairKey(subject string, a address) string { return subject + "\x00" + a.String() }
230
231// ── Write functions ───────────────────────────────────────────
232
233// PostReview creates the caller's review for `subject`, or replaces it in place
234// if one already exists (the "one editable review per pair" rule).
235func PostReview(cur realm, subject string, rating int, body string) {
236	caller := unsafe.PreviousRealm().Address()
237	if subject == "" {
238		panic("subject required")
239	}
240	if !validRating(rating) {
241		panic("rating must be 1..5")
242	}
243	if !validBody(body) {
244		panic("body too long")
245	}
246
247	pk := pairKey(subject, caller)
248	if v, ok := authorSubject.Get(pk); ok {
249		r, found := getReview(v.(uint64))
250		if found && !r.Deleted && !r.Hidden {
251			// counted review already exists for this (subject, author): rating change only.
252			applySubjectStat(subject, 0, int64(rating)-int64(r.Rating))
253			r.Rating = rating
254			r.Body = body
255			r.EditedAt = runtime.ChainHeight()
256			reviews.Set(strID(r.ID), r)
257			chain.Emit("ReviewUpdated", "id", strID(r.ID), "subject", subject)
258			return
259		}
260	}
261
262	id := nextID
263	nextID++
264	r := &Review{
265		ID:        id,
266		Subject:   subject,
267		Author:    caller,
268		Rating:    rating,
269		Body:      body,
270		CreatedAt: runtime.ChainHeight(),
271	}
272	reviews.Set(strID(id), r)
273	authorSubject.Set(pk, id)
274	subjectIndex.Set(subject, append(idList(subjectIndex, subject), id))
275	applySubjectStat(subject, 1, int64(rating)) // new visible review
276	chain.Emit("ReviewPosted", "id", strID(id), "subject", subject, "author", caller.String())
277}
278
279// EditReview updates rating + body of an existing non-deleted review.
280// Only the original author may edit.
281func EditReview(cur realm, reviewID uint64, rating int, body string) {
282	caller := unsafe.PreviousRealm().Address()
283	r, ok := getReview(reviewID)
284	if !ok || r.Deleted {
285		panic("review not found")
286	}
287	if r.Author != caller {
288		panic("author only")
289	}
290	if !validRating(rating) {
291		panic("rating must be 1..5")
292	}
293	if !validBody(body) {
294		panic("body too long")
295	}
296	if !r.Hidden { // counted (r.Deleted is false per the guard above): apply the rating delta
297		applySubjectStat(r.Subject, 0, int64(rating)-int64(r.Rating))
298	}
299	r.Rating = rating
300	r.Body = body
301	r.EditedAt = runtime.ChainHeight()
302	reviews.Set(strID(reviewID), r)
303	chain.Emit("ReviewUpdated", "id", strID(reviewID), "subject", r.Subject)
304}
305
306// DeleteReview tombstones the caller's review: keeps the ID + reaction history,
307// clears the body, and frees the (author, subject) pair for a fresh review.
308// Only the original author may delete.
309func DeleteReview(cur realm, reviewID uint64) {
310	caller := unsafe.PreviousRealm().Address()
311	r, ok := getReview(reviewID)
312	if !ok || r.Deleted {
313		panic("review not found")
314	}
315	if r.Author != caller {
316		panic("author only")
317	}
318	if !r.Hidden { // was counted (r.Deleted false per guard): drop it from the subject stats
319		applySubjectStat(r.Subject, -1, -int64(r.Rating))
320	}
321	r.Deleted = true
322	r.Body = ""
323	reviews.Set(strID(reviewID), r)
324	authorSubject.Remove(pairKey(r.Subject, caller))
325	subjectIndex.Set(r.Subject, removeID(idList(subjectIndex, r.Subject), reviewID))
326	flaggedIDs.Remove(strID(reviewID)) // RV-3: don't leave a tombstoned review in the mod dashboard
327	chain.Emit("ReviewDeleted", "id", strID(reviewID), "subject", r.Subject)
328}
329
330// ── Comment validation (pure, no side-effects) ────────────────
331
332// validComment returns true iff body is non-empty and within MaxCommentLen.
333func validComment(b string) bool { return b != "" && len(b) <= MaxCommentLen }
334
335// ── Comment write functions ───────────────────────────────────
336
337// PostComment posts a flat reply to an existing, non-deleted, non-hidden review.
338func PostComment(cur realm, reviewID uint64, body string) {
339	caller := unsafe.PreviousRealm().Address()
340	r, ok := getReview(reviewID)
341	if !ok || r.Deleted || r.Hidden {
342		panic("review not found")
343	}
344	if !validComment(body) {
345		panic("comment length invalid")
346	}
347	id := nextID
348	nextID++
349	c := &Comment{
350		ID:        id,
351		ReviewID:  reviewID,
352		Author:    caller,
353		Body:      body,
354		CreatedAt: runtime.ChainHeight(),
355	}
356	comments.Set(strID(id), c)
357	commentIndex.Set(strID(reviewID), append(idList(commentIndex, strID(reviewID)), id))
358	chain.Emit("CommentPosted", "id", strID(id), "review", strID(reviewID), "author", caller.String())
359}
360
361// EditComment updates the body of an existing, non-deleted comment.
362// Only the original author may edit.
363func EditComment(cur realm, commentID uint64, body string) {
364	caller := unsafe.PreviousRealm().Address()
365	c, ok := getComment(commentID)
366	if !ok || c.Deleted {
367		panic("comment not found")
368	}
369	if c.Author != caller {
370		panic("author only")
371	}
372	if !validComment(body) {
373		panic("comment length invalid")
374	}
375	c.Body = body
376	c.EditedAt = runtime.ChainHeight()
377	comments.Set(strID(commentID), c)
378	chain.Emit("CommentUpdated", "id", strID(commentID))
379}
380
381// DeleteComment tombstones the caller's comment: clears the body.
382// Only the original author may delete.
383func DeleteComment(cur realm, commentID uint64) {
384	caller := unsafe.PreviousRealm().Address()
385	c, ok := getComment(commentID)
386	if !ok || c.Deleted {
387		panic("comment not found")
388	}
389	if c.Author != caller {
390		panic("author only")
391	}
392	c.Deleted = true
393	c.Body = ""
394	comments.Set(strID(commentID), c)
395	flaggedIDs.Remove(strID(commentID)) // RV-3: don't leave a tombstoned comment in the mod dashboard
396	chain.Emit("CommentDeleted", "id", strID(commentID))
397}
398
399// ── Reaction helpers (pure, no side-effects) ──────────────────
400
401func boolToInt(b bool) int {
402	if b {
403		return 1
404	}
405	return 0
406}
407
408// reactionDelta returns how a reaction change (old -> newKind, each "" | "like" | "dislike")
409// moves the target's like count, dislike count, and the target author's reputation.
410// repDelta = likesDelta - dislikesDelta.
411func reactionDelta(old, newKind string) (likesDelta, dislikesDelta, repDelta int) {
412	likesDelta = boolToInt(newKind == "like") - boolToInt(old == "like")
413	dislikesDelta = boolToInt(newKind == "dislike") - boolToInt(old == "dislike")
414	repDelta = likesDelta - dislikesDelta
415	return
416}
417
418// applyDelta adjusts an unsigned counter by ±1-style delta without underflow.
419func applyDelta(v uint64, delta int) uint64 {
420	if delta < 0 {
421		d := uint64(-delta)
422		if v < d {
423			return 0
424		}
425		return v - d
426	}
427	return v + uint64(delta)
428}
429
430// ── React ─────────────────────────────────────────────────────
431
432// React records or toggles a like/dislike on a review or comment.
433// Re-reacting with the same kind toggles it off; switching kind replaces it.
434// Updates the target's Likes/Dislikes counters and the author's reputation by
435// Δ(likes−dislikes). Self-reactions are rejected. Deleted/hidden targets are rejected.
436func React(cur realm, targetID uint64, kind string) {
437	caller := unsafe.PreviousRealm().Address()
438	if kind != "like" && kind != "dislike" {
439		panic("kind must be like or dislike")
440	}
441
442	r, isReview := getReview(targetID)
443	c, isComment := getComment(targetID)
444	if !isReview && !isComment {
445		panic("target not found")
446	}
447
448	var author address
449	if isReview {
450		author = r.Author
451		if r.Deleted || r.Hidden {
452			panic("target not found")
453		}
454	} else {
455		author = c.Author
456		if c.Deleted || c.Hidden {
457			panic("target not found")
458		}
459	}
460	if author == caller {
461		panic("cannot react to your own review or comment")
462	}
463
464	rk := strID(targetID) + "/" + caller.String()
465	var old string
466	if v, ok := reactions.Get(rk); ok {
467		old = v.(string)
468	}
469
470	newKind := kind
471	if old == kind {
472		newKind = "" // toggle off
473	}
474
475	likesDelta, dislikesDelta, repDelta := reactionDelta(old, newKind)
476
477	if newKind == "" {
478		reactions.Remove(rk)
479	} else {
480		reactions.Set(rk, newKind)
481	}
482
483	if isReview {
484		r.Likes = applyDelta(r.Likes, likesDelta)
485		r.Dislikes = applyDelta(r.Dislikes, dislikesDelta)
486		reviews.Set(strID(targetID), r)
487	} else {
488		c.Likes = applyDelta(c.Likes, likesDelta)
489		c.Dislikes = applyDelta(c.Dislikes, dislikesDelta)
490		comments.Set(strID(targetID), c)
491	}
492	addReputation(author.String(), int64(repDelta))
493	chain.Emit("Reacted", "target", strID(targetID), "kind", newKind, "by", caller.String())
494}
495
496// ── Flag + moderation ─────────────────────────────────────────
497
498// Flag records one community flag per account per target (review or comment).
499// Auto-hide is NOT performed here; takedowns are multisig-only (HideReview/HideComment).
500// Deleted/hidden targets are rejected.
501func Flag(cur realm, targetID uint64) {
502	caller := unsafe.PreviousRealm().Address()
503	r, isReview := getReview(targetID)
504	c, isComment := getComment(targetID)
505	if !isReview && !isComment {
506		panic("target not found")
507	}
508	if isReview && (r.Deleted || r.Hidden) {
509		panic("target not found")
510	}
511	if isComment && (c.Deleted || c.Hidden) {
512		panic("target not found")
513	}
514	fk := strID(targetID) + "/" + caller.String()
515	if _, ok := flags.Get(fk); ok {
516		panic("already flagged")
517	}
518	flags.Set(fk, true)
519
520	if isReview {
521		r.FlagCount++
522		reviews.Set(strID(targetID), r)
523	} else {
524		c.FlagCount++
525		comments.Set(strID(targetID), c)
526	}
527	flaggedIDs.Set(strID(targetID), true)
528	chain.Emit("Flagged", "target", strID(targetID), "by", caller.String())
529}
530
531// HideReview soft-deletes a review. Moderator (multisig) only.
532func HideReview(cur realm, id uint64) {
533	assertModerator()
534	r, ok := getReview(id)
535	if !ok {
536		panic("review not found")
537	}
538	if !r.Hidden && !r.Deleted { // was counted → drop from subject stats (guard against re-hide)
539		applySubjectStat(r.Subject, -1, -int64(r.Rating))
540	}
541	r.Hidden = true
542	reviews.Set(strID(id), r)
543	chain.Emit("Hidden", "target", strID(id))
544}
545
546// HideComment soft-deletes a comment. Moderator (multisig) only.
547func HideComment(cur realm, id uint64) {
548	assertModerator()
549	c, ok := getComment(id)
550	if !ok {
551		panic("comment not found")
552	}
553	c.Hidden = true
554	comments.Set(strID(id), c)
555	chain.Emit("Hidden", "target", strID(id))
556}
557
558// Unhide reverses a hide (manual or auto-flag) on a review or comment.
559// Moderator (multisig) only. Also removes the target from the mod dashboard index.
560func Unhide(cur realm, targetID uint64) {
561	assertModerator()
562	if r, ok := getReview(targetID); ok {
563		if r.Hidden && !r.Deleted { // becomes counted again → restore to subject stats
564			applySubjectStat(r.Subject, 1, int64(r.Rating))
565		}
566		r.Hidden = false
567		reviews.Set(strID(targetID), r)
568		flaggedIDs.Remove(strID(targetID))
569		chain.Emit("Unhidden", "target", strID(targetID))
570		return
571	}
572	if c, ok := getComment(targetID); ok {
573		c.Hidden = false
574		comments.Set(strID(targetID), c)
575		flaggedIDs.Remove(strID(targetID))
576		chain.Emit("Unhidden", "target", strID(targetID))
577		return
578	}
579	panic("target not found")
580}
581
582// ── Read helpers ──────────────────────────────────────────────
583
584// clampLimit ensures limit is in [1, MaxPageLimit]. Zero or negative values and
585// values above MaxPageLimit are all clamped to MaxPageLimit.
586func clampLimit(limit int) int {
587	if limit <= 0 || limit > MaxPageLimit {
588		return MaxPageLimit
589	}
590	return limit
591}
592
593// window slices ids[offset : offset+limit] safely (no panics on out-of-range).
594func window(ids []uint64, offset, limit int) []uint64 {
595	limit = clampLimit(limit)
596	if offset < 0 {
597		offset = 0
598	}
599	if offset >= len(ids) {
600		return nil
601	}
602	end := offset + limit
603	if end > len(ids) {
604		end = len(ids)
605	}
606	return ids[offset:end]
607}
608
609func reviewJSON(r *Review) string {
610	body := ""
611	if !r.Deleted {
612		body = jsonEscape(r.Body)
613	}
614	return ufmt.Sprintf(
615		`{"id":%d,"subject":"%s","author":"%s","rating":%d,"body":"%s","createdAt":%d,"editedAt":%d,"deleted":%t,"likes":%d,"dislikes":%d,"flags":%d,"reputation":%d}`,
616		r.ID, jsonEscape(r.Subject), jsonEscape(r.Author.String()), r.Rating, body,
617		r.CreatedAt, r.EditedAt, r.Deleted, r.Likes, r.Dislikes, r.FlagCount,
618		getReputation(r.Author.String()),
619	)
620}
621
622func commentJSON(c *Comment) string {
623	body := ""
624	if !c.Deleted {
625		body = jsonEscape(c.Body)
626	}
627	return ufmt.Sprintf(
628		`{"id":%d,"reviewId":%d,"author":"%s","body":"%s","createdAt":%d,"editedAt":%d,"deleted":%t,"likes":%d,"dislikes":%d,"flags":%d,"reputation":%d}`,
629		c.ID, c.ReviewID, jsonEscape(c.Author.String()), body,
630		c.CreatedAt, c.EditedAt, c.Deleted, c.Likes, c.Dislikes, c.FlagCount,
631		getReputation(c.Author.String()),
632	)
633}
634
635// GetReviewsJSON returns a JSON array of a subject's non-hidden reviews (paginated).
636// Deleted reviews are included as tombstones (empty body, deleted:true).
637func GetReviewsJSON(subject string, offset, limit int) string {
638	ids := window(idList(subjectIndex, subject), offset, limit)
639	var b strings.Builder
640	b.WriteString("[")
641	first := true
642	for _, id := range ids {
643		r, ok := getReview(id)
644		if !ok || r.Hidden {
645			continue
646		}
647		if !first {
648			b.WriteString(",")
649		}
650		b.WriteString(reviewJSON(r))
651		first = false
652	}
653	b.WriteString("]")
654	return b.String()
655}
656
657// GetCommentsJSON returns a JSON array of a review's non-hidden comments (paginated).
658func GetCommentsJSON(reviewID uint64, offset, limit int) string {
659	ids := window(idList(commentIndex, strID(reviewID)), offset, limit)
660	var b strings.Builder
661	b.WriteString("[")
662	first := true
663	for _, id := range ids {
664		c, ok := getComment(id)
665		if !ok || c.Hidden {
666			continue
667		}
668		if !first {
669			b.WriteString(",")
670		}
671		b.WriteString(commentJSON(c))
672		first = false
673	}
674	b.WriteString("]")
675	return b.String()
676}
677
678// GetSubjectSummaryJSON returns {"count":N,"average":A,"sum":S} over non-hidden,
679// non-deleted reviews for a subject. Average is integer-rounded.
680// GetSubjectSummaryJSON returns the count/average/sum of a subject's VISIBLE reviews.
681// RV-1: O(1) — reads the maintained per-subject counters instead of scanning the (sybil-
682// growable) review list, so it can never exceed the query gas cap.
683func GetSubjectSummaryJSON(subject string) string {
684	n := getStat(subjStatCount, subject)
685	sum := getStat(subjStatSum, subject)
686	avg := int64(0)
687	if n > 0 {
688		avg = (sum + n/2) / n // round to nearest
689	}
690	return ufmt.Sprintf(`{"count":%d,"average":%d,"sum":%d}`, n, avg, sum)
691}
692
693// GetReputation returns the net likes−dislikes reputation for an address.
694// Returns 0 for unknown addresses. Queried via vm/qeval (bare int64).
695func GetReputation(addr string) int64 { return getReputation(addr) }
696
697// GetFlaggedJSON returns a JSON array of flagged target IDs for the mod dashboard,
698// paginated. IDs are returned as bare integers (not quoted strings).
699// Uses early-stop iteration to avoid loading the entire tree.
700func GetFlaggedJSON(offset, limit int) string {
701	limit = clampLimit(limit)
702	if offset < 0 {
703		offset = 0
704	}
705	var ids []uint64
706	skipped := 0
707	flaggedIDs.Iterate("", "", func(key string, _ interface{}) bool {
708		if skipped < offset {
709			skipped++
710			return false
711		}
712		id, _ := strconv.ParseUint(key, 10, 64)
713		ids = append(ids, id)
714		return len(ids) >= limit // stop once collected enough
715	})
716	var b strings.Builder
717	b.WriteString("[")
718	for i, id := range ids {
719		if i > 0 {
720			b.WriteString(",")
721		}
722		b.WriteString(strID(id))
723	}
724	b.WriteString("]")
725	return b.String()
726}
727
728// Render — human gnoweb view.
729// "" → home (total count); "s/<subject>" → subject review list; else 404.
730func Render(path string) string {
731	if path == "" {
732		return ufmt.Sprintf("# Memba Reviews\n\nOn-chain web-of-trust. %d reviews total.\n", reviews.Size())
733	}
734	if strings.HasPrefix(path, "s/") {
735		subject := strings.TrimPrefix(path, "s/")
736		var b strings.Builder
737		b.WriteString("# Reviews for " + sanitizeForRender(subject) + "\n\n")
738		written := false
739		renderedCount := 0
740		for _, id := range idList(subjectIndex, subject) {
741			if renderedCount >= MaxPageLimit {
742				break
743			}
744			r, ok := getReview(id)
745			if !ok || r.Hidden || r.Deleted {
746				continue
747			}
748			b.WriteString(ufmt.Sprintf("**%d/5** by %s\n\n%s\n\n---\n",
749				r.Rating, r.Author.String(), sanitizeForRender(r.Body)))
750			written = true
751			renderedCount++
752		}
753		if !written {
754			b.WriteString("No reviews yet.\n")
755		}
756		return b.String()
757	}
758	return "# 404\n"
759}