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