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. "<" → "&lt;").
181func sanitizeForRender(s string) string {
182 r := strings.NewReplacer(
183 "&", "&",
184 "<", "<", ">", ">",
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}