Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}