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

memba_reviews_v2.gno

22.98 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/samcrew/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}