reads.gno
4.84 Kb · 167 lines
1package memba_appstore_v3
2
3import (
4 "strings"
5
6 "gno.land/p/nt/avl/v0"
7 "gno.land/p/nt/ufmt/v0"
8)
9
10const (
11 DefaultPageSize = 20 // default JSON window
12 MaxPageLimit = 100 // hard cap on any JSON read window (read-DoS guard)
13)
14
15func clampLimit(limit int) int {
16 if limit <= 0 {
17 return DefaultPageSize
18 }
19 if limit > MaxPageLimit {
20 return MaxPageLimit
21 }
22 return limit
23}
24
25// jsonEscape escapes a string for embedding in a JSON string literal — every listing text field
26// is attacker-supplied, so this is the only thing between a malicious name/tagline/reason/CID and
27// a broken/injected JSON payload on the read path.
28func jsonEscape(s string) string {
29 var sb strings.Builder
30 for _, r := range s {
31 switch r {
32 case '"':
33 sb.WriteString("\\\"")
34 case '\\':
35 sb.WriteString("\\\\")
36 case '\n':
37 sb.WriteString("\\n")
38 case '\r':
39 sb.WriteString("\\r")
40 case '\t':
41 sb.WriteString("\\t")
42 default:
43 if r < 0x20 {
44 sb.WriteString(ufmt.Sprintf("\\u%04x", r))
45 } else {
46 sb.WriteString(string(r))
47 }
48 }
49 }
50 return sb.String()
51}
52
53// screenshotsJSON renders a CID slice as a JSON string array body (each element escaped).
54func screenshotsJSON(cids []string) string {
55 var sb strings.Builder
56 for i, c := range cids {
57 if i > 0 {
58 sb.WriteString(",")
59 }
60 sb.WriteString(`"`)
61 sb.WriteString(jsonEscape(c))
62 sb.WriteString(`"`)
63 }
64 return sb.String()
65}
66
67// listingJSON renders one listing. `full` adds the (large) description + the screenshot gallery —
68// omitted from list windows so a bounded page stays small. rejectReason is short and always
69// included (the My-Submissions view needs it in list windows).
70func listingJSON(l *Listing, full bool) string {
71 extra := ""
72 if full {
73 extra = ufmt.Sprintf(`,"descr":"%s","screenshotCIDs":[%s]`,
74 jsonEscape(l.Descr), screenshotsJSON(l.ScreenshotCIDs))
75 }
76 return ufmt.Sprintf(
77 `{"id":%d,"pkgPath":"%s","name":"%s","tagline":"%s","category":"%s","iconCID":"%s","appURL":"%s","publisher":"%s","status":"%s","rejectReason":"%s","paidResubmitCredit":%t,"resubmitCount":%d,"flagCount":%d,"createdAt":%d%s}`,
78 l.Id,
79 jsonEscape(l.PkgPath),
80 jsonEscape(l.Name),
81 jsonEscape(l.Tagline),
82 jsonEscape(l.Category),
83 jsonEscape(l.IconCID),
84 jsonEscape(l.AppURL),
85 l.Publisher.String(),
86 l.Status,
87 jsonEscape(l.RejectReason),
88 l.PaidResubmitCredit,
89 l.ResubmitCount,
90 l.FlagCount,
91 l.CreatedAt,
92 extra,
93 )
94}
95
96// windowByPrefix iterates a composite-key index (statusIndex or publisherIndex) over the prefix
97// range [prefix+"\x00", prefix+"\x01") — a true O(offset+limit) window ordered by zero-padded id.
98// When flagFilter is set, flag-hidden listings are excluded (the public live/pending lists).
99func windowByPrefix(index *avl.Tree, prefix string, offset, limit int, flagFilter bool) string {
100 limit = clampLimit(limit)
101 if offset < 0 {
102 offset = 0
103 }
104 var sb strings.Builder
105 sb.WriteString("[")
106 skipped, taken := 0, 0
107 first := true
108 index.Iterate(prefix+"\x00", prefix+"\x01", func(_ string, v any) bool {
109 lv, ok := listings.Get(v.(string))
110 if !ok {
111 return false
112 }
113 l := lv.(*Listing)
114 if flagFilter && l.FlagCount >= FlagHideThreshold {
115 return false
116 }
117 if skipped < offset {
118 skipped++
119 return false
120 }
121 if taken >= limit {
122 return true // window full — stop
123 }
124 if !first {
125 sb.WriteString(",")
126 }
127 first = false
128 sb.WriteString(listingJSON(l, false))
129 taken++
130 return false
131 })
132 sb.WriteString("]")
133 return sb.String()
134}
135
136// ListLiveJSON returns a bounded JSON array of the visible (live, un-flag-hidden) listings —
137// the Verified tab. Bounded by scanning only the "live" index slice.
138func ListLiveJSON(offset, limit int) string {
139 return windowByPrefix(statusIndex, StatusLive, offset, limit, true)
140}
141
142// ListByStatusJSON returns a bounded window of listings in `status`. Public tabs (live, pending)
143// exclude flag-hidden listings; rejected/delisted are returned raw (used by curator/owner views).
144// An unknown status returns an empty array — the client passes a fixed enum, never free text.
145func ListByStatusJSON(status string, offset, limit int) string {
146 if !validStatus(status) {
147 return "[]"
148 }
149 flagFilter := status == StatusLive || status == StatusPending
150 return windowByPrefix(statusIndex, status, offset, limit, flagFilter)
151}
152
153// ListByPublisherJSON returns a bounded window of a publisher's listings across ALL statuses
154// (the My-Submissions view) — no flag filter, since a publisher sees their own flagged/rejected.
155func ListByPublisherJSON(publisher string, offset, limit int) string {
156 return windowByPrefix(publisherIndex, publisher, offset, limit, false)
157}
158
159// GetListingJSON returns a single listing (any status) with its full detail (descr + screenshots),
160// or the JSON literal `null` if the package path is not registered.
161func GetListingJSON(pkgPath string) string {
162 v, ok := listings.Get(pkgPath)
163 if !ok {
164 return "null"
165 }
166 return listingJSON(v.(*Listing), true)
167}