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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

forms.gno

11.55 Kb · 411 lines
  1// Package forms is the Remix Lab forms block: a fixed field schema, typed
  2// and bounded answers, sequential submission IDs and a small review status
  3// (submitted, accepted, rejected).
  4//
  5// All answers are public chain data. Recipes should only ask for information
  6// that is meant to be published.
  7//
  8// Capacity counts live submissions (submitted or accepted). Rejected
  9// submissions stay readable for the record but free their slot, so a flood
 10// of junk can always be cleared by reviewing it.
 11//
 12// The schema can change only while the form has no submissions; after that
 13// a host must create a new Form. A *Form belongs in an unexported host-realm
 14// variable, and the host authenticates submitters and reviewers.
 15package forms
 16
 17import (
 18	"strconv"
 19	"strings"
 20
 21	"gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/remixlab/core/v1"
 22	"gno.land/p/nt/avl/v0"
 23)
 24
 25// Hard bounds that no configuration may exceed.
 26const (
 27	HardMaxFields      = 10
 28	HardMaxSubmissions = 1000
 29	HardMaxFieldBytes  = 2048
 30	HardMaxPayload     = 8192
 31	HardMaxChoices     = 20
 32)
 33
 34// FieldKind is the type of answer a field accepts.
 35type FieldKind int
 36
 37const (
 38	KindText FieldKind = iota
 39	KindInteger
 40	KindChoice
 41)
 42
 43func (k FieldKind) String() string {
 44	switch k {
 45	case KindText:
 46		return "text"
 47	case KindInteger:
 48		return "integer"
 49	case KindChoice:
 50		return "choice"
 51	}
 52	return "unknown"
 53}
 54
 55// Field describes one form field.
 56type Field struct {
 57	Name      string // identifier, e.g. "title"
 58	Label     string
 59	Kind      FieldKind
 60	Required  bool
 61	MaxBytes  int      // text only
 62	Multiline bool     // text only
 63	Min, Max  int64    // integer only, inclusive
 64	Choices   []string // choice only
 65}
 66
 67// Config bounds the form.
 68type Config struct {
 69	MaxSubmissions  int // live (submitted or accepted) submissions
 70	MaxPerSubmitter int // live submissions per address; 0 means unlimited
 71	MaxPayloadBytes int // sum of answer bytes
 72}
 73
 74func (c Config) validate() error {
 75	switch {
 76	case c.MaxSubmissions < 1 || c.MaxSubmissions > HardMaxSubmissions:
 77		return core.Errorf(core.ErrInvalidInput, "maxSubmissions out of range")
 78	case c.MaxPerSubmitter < 0:
 79		return core.Errorf(core.ErrInvalidInput, "maxPerSubmitter out of range")
 80	case c.MaxPayloadBytes < 1 || c.MaxPayloadBytes > HardMaxPayload:
 81		return core.Errorf(core.ErrInvalidInput, "maxPayloadBytes out of range")
 82	}
 83	return nil
 84}
 85
 86type submission struct {
 87	id        uint64
 88	submitter address
 89	answers   []string
 90	status    core.SubmissionStatus
 91}
 92
 93// Form holds a schema and its submissions.
 94type Form struct {
 95	cfg          Config
 96	fields       []Field
 97	subs         *avl.Tree    // zero-padded id -> *submission
 98	byStatus     [3]*avl.Tree // per status: zero-padded id -> *submission
 99	perSubmitter *avl.Tree    // address -> live submission count
100	nextID       uint64
101}
102
103// New creates a form. It panics on an invalid schema or configuration,
104// since hosts call it from init with generated constants.
105func New(cfg Config, fields []Field) *Form {
106	if err := cfg.validate(); err != nil {
107		panic(err.Error())
108	}
109	f := &Form{cfg: cfg, subs: avl.NewTree(), perSubmitter: avl.NewTree()}
110	for i := range f.byStatus {
111		f.byStatus[i] = avl.NewTree()
112	}
113	if err := f.setFields(fields); err != nil {
114		panic(err.Error())
115	}
116	return f
117}
118
119// SetFields replaces the schema. It fails once any submission exists.
120func (f *Form) SetFields(fields []Field) error {
121	if f.subs.Size() > 0 {
122		return core.Errorf(core.ErrInvalidState, "schema is frozen once submissions exist")
123	}
124	return f.setFields(fields)
125}
126
127func (f *Form) setFields(fields []Field) error {
128	if len(fields) == 0 || len(fields) > HardMaxFields {
129		return core.Errorf(core.ErrInvalidInput, "field count out of range")
130	}
131	names := map[string]bool{}
132	out := make([]Field, len(fields))
133	for i, fd := range fields {
134		if err := checkField(fd); err != nil {
135			return err
136		}
137		if names[fd.Name] {
138			return core.Errorf(core.ErrInvalidInput, "duplicate field "+fd.Name)
139		}
140		names[fd.Name] = true
141		out[i] = copyField(fd)
142	}
143	f.fields = out
144	return nil
145}
146
147// Fields returns a copy of the schema.
148func (f *Form) Fields() []Field {
149	out := make([]Field, len(f.fields))
150	for i, fd := range f.fields {
151		out[i] = copyField(fd)
152	}
153	return out
154}
155
156// Normalize validates answers (one per field, in schema order) and returns
157// their canonical form: integers are reformatted, and absent optional
158// answers (including blank text) are "".
159func (f *Form) Normalize(answers []string) ([]string, error) {
160	if len(answers) != len(f.fields) {
161		return nil, core.Errorf(core.ErrInvalidInput, "expected "+strconv.Itoa(len(f.fields))+" answers")
162	}
163	out := make([]string, len(answers))
164	total := 0
165	for i, fd := range f.fields {
166		a := answers[i]
167		total += len(a)
168		if total > f.cfg.MaxPayloadBytes {
169			return nil, core.Errorf(core.ErrLimitExceeded, "answers too large")
170		}
171		if a == "" || (fd.Kind == KindText && strings.TrimSpace(a) == "") {
172			if fd.Required {
173				return nil, core.Errorf(core.ErrInvalidInput, fd.Name+" is required")
174			}
175			continue
176		}
177		switch fd.Kind {
178		case KindText:
179			if err := core.CheckText(fd.Name, a, 1, fd.MaxBytes, fd.Multiline); err != nil {
180				return nil, err
181			}
182			out[i] = a
183		case KindInteger:
184			if len(a) > 21 {
185				return nil, core.Errorf(core.ErrInvalidInput, fd.Name+" must be an integer")
186			}
187			n, err := strconv.ParseInt(a, 10, 64)
188			if err != nil {
189				return nil, core.Errorf(core.ErrInvalidInput, fd.Name+" must be an integer")
190			}
191			if n < fd.Min || n > fd.Max {
192				return nil, core.Errorf(core.ErrInvalidInput, fd.Name+" out of range")
193			}
194			out[i] = strconv.FormatInt(n, 10)
195		case KindChoice:
196			if !contains(fd.Choices, a) {
197				return nil, core.Errorf(core.ErrInvalidInput, fd.Name+" is not an allowed choice")
198			}
199			out[i] = a
200		}
201	}
202	return out, nil
203}
204
205// Submit stores a submission from submitter, who must be the authenticated
206// caller.
207func (f *Form) Submit(submitter address, answers []string) (uint64, error) {
208	if !submitter.IsValid() {
209		return 0, core.Errorf(core.ErrInvalidInput, "invalid submitter")
210	}
211	if f.live() >= f.cfg.MaxSubmissions {
212		return 0, core.Errorf(core.ErrLimitExceeded, "form is full")
213	}
214	prev := f.submittedBy(submitter)
215	if f.cfg.MaxPerSubmitter > 0 && prev >= f.cfg.MaxPerSubmitter {
216		return 0, core.Errorf(core.ErrLimitExceeded, "submission limit reached for this address")
217	}
218	norm, err := f.Normalize(answers)
219	if err != nil {
220		return 0, err
221	}
222	f.nextID++
223	id := f.nextID
224	s := &submission{id: id, submitter: submitter, answers: norm, status: core.SubmissionSubmitted}
225	f.subs.Set(key(id), s)
226	f.byStatus[core.SubmissionSubmitted].Set(key(id), s)
227	f.perSubmitter.Set(submitter.String(), prev+1)
228	return id, nil
229}
230
231// Review moves a submitted entry to accepted or rejected. Decisions are
232// final. Rejecting frees the entry's capacity slot and its submitter's
233// per-address slot. The host must have authorized the reviewer.
234func (f *Form) Review(id uint64, status core.SubmissionStatus) error {
235	if status != core.SubmissionAccepted && status != core.SubmissionRejected {
236		return core.Errorf(core.ErrInvalidInput, "review must accept or reject")
237	}
238	s := f.get(id)
239	if s == nil {
240		return core.Errorf(core.ErrNotFound, "submission")
241	}
242	if s.status != core.SubmissionSubmitted {
243		return core.Errorf(core.ErrInvalidState, "submission already reviewed")
244	}
245	f.byStatus[s.status].Remove(key(id))
246	s.status = status
247	f.byStatus[status].Set(key(id), s)
248	if status == core.SubmissionRejected {
249		f.perSubmitter.Set(s.submitter.String(), f.submittedBy(s.submitter)-1)
250	}
251	return nil
252}
253
254// Submission implements core.SubmissionReader.
255func (f *Form) Submission(id uint64) (core.SubmissionView, bool) {
256	s := f.get(id)
257	if s == nil {
258		return core.SubmissionView{}, false
259	}
260	return s.view(), true
261}
262
263// Submissions implements core.SubmissionReader, ordered by ID. status -1
264// returns all; any other value outside the defined statuses returns none.
265// Each status has its own index, so paging never scans other entries.
266func (f *Form) Submissions(status int, offset, limit int) []core.SubmissionView {
267	var out []core.SubmissionView
268	if offset < 0 || limit <= 0 || status >= len(f.byStatus) || status < -1 {
269		return out
270	}
271	tree := f.subs
272	if status >= 0 {
273		tree = f.byStatus[status]
274	}
275	tree.IterateByOffset(offset, limit, func(_ string, v any) bool {
276		out = append(out, v.(*submission).view())
277		return false
278	})
279	return out
280}
281
282// Count implements core.SubmissionReader. It includes rejected entries.
283func (f *Form) Count() int { return f.subs.Size() }
284
285// CountByStatus returns the number of submissions in status.
286func (f *Form) CountByStatus(status core.SubmissionStatus) int {
287	if status < 0 || int(status) >= len(f.byStatus) {
288		return 0
289	}
290	return f.byStatus[status].Size()
291}
292
293// Config returns the form configuration.
294func (f *Form) Config() Config { return f.cfg }
295
296// SubmittedBy returns how many live (submitted or accepted) submissions
297// addr has.
298func (f *Form) SubmittedBy(addr address) int { return f.submittedBy(addr) }
299
300func (f *Form) live() int {
301	return f.byStatus[core.SubmissionSubmitted].Size() + f.byStatus[core.SubmissionAccepted].Size()
302}
303
304func (f *Form) submittedBy(addr address) int {
305	v := f.perSubmitter.Get(addr.String())
306	if v == nil {
307		return 0
308	}
309	return v.(int)
310}
311
312func (f *Form) get(id uint64) *submission {
313	v := f.subs.Get(key(id))
314	if v == nil {
315		return nil
316	}
317	return v.(*submission)
318}
319
320func (s *submission) view() core.SubmissionView {
321	answers := make([]string, len(s.answers))
322	copy(answers, s.answers)
323	return core.SubmissionView{ID: s.id, Submitter: s.submitter, Answers: answers, Status: s.status}
324}
325
326func checkField(fd Field) error {
327	if !isFieldName(fd.Name) {
328		return core.Errorf(core.ErrInvalidInput, "invalid field name")
329	}
330	if err := core.CheckText("label", fd.Label, 1, 80, false); err != nil {
331		return err
332	}
333	// Attributes of other kinds must be left at their zero values.
334	textAttrs := fd.MaxBytes != 0 || fd.Multiline
335	intAttrs := fd.Min != 0 || fd.Max != 0
336	choiceAttrs := fd.Choices != nil
337	switch fd.Kind {
338	case KindText:
339		if intAttrs || choiceAttrs {
340			return core.Errorf(core.ErrInvalidInput, fd.Name+": attributes do not match kind")
341		}
342		if fd.MaxBytes < 1 || fd.MaxBytes > HardMaxFieldBytes {
343			return core.Errorf(core.ErrInvalidInput, fd.Name+": maxBytes out of range")
344		}
345	case KindInteger:
346		if textAttrs || choiceAttrs {
347			return core.Errorf(core.ErrInvalidInput, fd.Name+": attributes do not match kind")
348		}
349		if fd.Min > fd.Max {
350			return core.Errorf(core.ErrInvalidInput, fd.Name+": min > max")
351		}
352	case KindChoice:
353		if textAttrs || intAttrs {
354			return core.Errorf(core.ErrInvalidInput, fd.Name+": attributes do not match kind")
355		}
356		if len(fd.Choices) < 2 || len(fd.Choices) > HardMaxChoices {
357			return core.Errorf(core.ErrInvalidInput, fd.Name+": choice count out of range")
358		}
359		seen := map[string]bool{}
360		for _, c := range fd.Choices {
361			if err := core.CheckText("choice", c, 1, 80, false); err != nil {
362				return err
363			}
364			if seen[c] {
365				return core.Errorf(core.ErrInvalidInput, fd.Name+": duplicate choice")
366			}
367			seen[c] = true
368		}
369	default:
370		return core.Errorf(core.ErrInvalidInput, fd.Name+": unknown kind")
371	}
372	return nil
373}
374
375func copyField(fd Field) Field {
376	if fd.Choices != nil {
377		c := make([]string, len(fd.Choices))
378		copy(c, fd.Choices)
379		fd.Choices = c
380	}
381	return fd
382}
383
384func isFieldName(s string) bool {
385	if s == "" || len(s) > 32 {
386		return false
387	}
388	for i, c := range s {
389		if !((c >= 'a' && c <= 'z') || (i > 0 && ((c >= '0' && c <= '9') || c == '_'))) {
390			return false
391		}
392	}
393	return true
394}
395
396func contains(list []string, s string) bool {
397	for _, x := range list {
398		if x == s {
399			return true
400		}
401	}
402	return false
403}
404
405func key(id uint64) string {
406	s := strconv.FormatUint(id, 10)
407	for len(s) < 20 {
408		s = "0" + s
409	}
410	return s
411}