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}