gnotif.gno
5.40 Kb · 215 lines
1// Package gnotif is a registry of notification triggers. Each trigger names
2// a realm and an event type to watch, and the text of the notification that
3// gnotif servers send when a matching event lands on chain.
4package gnotif
5
6import (
7 "chain"
8 "strconv"
9 "strings"
10
11 "gno.land/p/nt/avl/v0"
12 "gno.land/p/nt/seqid/v0"
13)
14
15// Trigger is one declared notification rule. Filter holds comma-separated
16// key=value pairs; Title, Body and Link are templates where {key} stands for
17// the value of the event's attribute key.
18type Trigger struct {
19 ID, Target, Event, Filter, Param, Title, Body, Link string
20 Declarer address
21 Verified bool
22}
23
24const (
25 targetPrefix = "gno.land/r/"
26 maxTarget = 256
27 maxName = 64
28 maxFilterPairs = 8
29 maxBody = 255
30 maxLink = 256
31 maxPerTarget = 64
32)
33
34var (
35 triggers avl.Tree // id → *Trigger
36 byTarget avl.Tree // target → *avl.Tree of id → *Trigger, verified triggers only
37 lastID seqid.ID
38)
39
40// Declare stores a trigger and returns its id. The trigger is verified when
41// its caller is the target realm itself.
42func Declare(cur realm, target, event, filter, param, title, body, link string) string {
43 validate(target, event, filter, param, title, body, link)
44 caller := cur.Previous()
45 verified := caller.IsCode() && caller.PkgPath() == target
46 var own *avl.Tree
47 if v := byTarget.Get(target); v != nil {
48 own = v.(*avl.Tree)
49 }
50 if verified && own != nil && own.Size() >= maxPerTarget {
51 panic("too many triggers for target")
52 }
53 tr := &Trigger{
54 ID: lastID.Next().String(),
55 Target: target,
56 Event: event,
57 Filter: filter,
58 Param: param,
59 Title: title,
60 Body: body,
61 Link: link,
62 Declarer: caller.Address(),
63 Verified: verified,
64 }
65 triggers.Set(tr.ID, tr)
66 if verified {
67 if own == nil {
68 own = avl.NewTree()
69 byTarget.Set(target, own)
70 }
71 own.Set(tr.ID, tr)
72 }
73 chain.Emit("TriggerDeclared",
74 "id", tr.ID,
75 "target", tr.Target,
76 "event", tr.Event,
77 "filter", tr.Filter,
78 "param", tr.Param,
79 "title", tr.Title,
80 "body", tr.Body,
81 "link", tr.Link,
82 "declarer", tr.Declarer.String(),
83 "verified", strconv.FormatBool(tr.Verified),
84 )
85 return tr.ID
86}
87
88// Remove deletes a trigger. Only its declarer may remove it.
89func Remove(cur realm, id string) {
90 v := triggers.Get(id)
91 if v == nil {
92 panic("unknown trigger")
93 }
94 tr := v.(*Trigger)
95 if tr.Declarer != cur.Previous().Address() {
96 panic("not the declarer")
97 }
98 triggers.Remove(id)
99 if tr.Verified {
100 own := byTarget.Get(tr.Target).(*avl.Tree)
101 own.Remove(id)
102 if own.Size() == 0 {
103 byTarget.Remove(tr.Target)
104 }
105 }
106 chain.Emit("TriggerRemoved", "id", id)
107}
108
109func validate(target, event, filter, param, title, body, link string) {
110 switch {
111 case !validTarget(target):
112 panic("invalid target")
113 case !validName(event, 1, maxName):
114 panic("invalid event")
115 case !validFilter(filter):
116 panic("invalid filter")
117 case !validName(param, 0, maxName):
118 panic("invalid param")
119 case !sized(title, 1, maxName):
120 panic("invalid title")
121 case !sized(body, 0, maxBody):
122 panic("invalid body")
123 case !validLink(link):
124 panic("invalid link")
125 }
126}
127
128func sized(s string, lo, hi int) bool {
129 return len(s) >= lo && len(s) <= hi
130}
131
132// validTarget accepts a realm path whose segments each match the chain's
133// Re_name, so a target names a path where a realm can exist.
134func validTarget(target string) bool {
135 if !strings.HasPrefix(target, targetPrefix) || len(target) > maxTarget {
136 return false
137 }
138 rest := target[len(targetPrefix):]
139 start := 0 // index of the current segment's first byte
140 for i := 0; i <= len(rest); i++ {
141 if i == len(rest) || rest[i] == '/' {
142 if i == start || !isLowerAlnum(rest[i-1]) {
143 return false
144 }
145 start = i + 1
146 continue
147 }
148 c := rest[i]
149 switch {
150 case i == start:
151 if !(c >= 'a' && c <= 'z') {
152 return false
153 }
154 case c == '_' || c == '-':
155 if !isLowerAlnum(rest[i-1]) {
156 return false
157 }
158 case !isLowerAlnum(c):
159 return false
160 }
161 }
162 return true
163}
164
165func isLowerAlnum(c byte) bool {
166 return c >= 'a' && c <= 'z' || c >= '0' && c <= '9'
167}
168
169// validName accepts identifier bytes only, so a name cannot carry spaces or
170// symbols that imitate the verified mark in rendered output.
171func validName(s string, lo, hi int) bool {
172 if !sized(s, lo, hi) {
173 return false
174 }
175 for i := 0; i < len(s); i++ {
176 c := s[i]
177 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_') {
178 return false
179 }
180 }
181 return true
182}
183
184func validFilter(filter string) bool {
185 if filter == "" {
186 return true
187 }
188 pairs := strings.Split(filter, ",")
189 if len(pairs) > maxFilterPairs {
190 return false
191 }
192 for _, pair := range pairs {
193 key, value, ok := strings.Cut(pair, "=")
194 if !ok || !validName(key, 1, maxName) || !sized(value, 1, maxName) || strings.Contains(value, "=") {
195 return false
196 }
197 }
198 return true
199}
200
201// validLink accepts only a path, so a click always stays on the dapp's origin.
202// "//host" starts with a slash yet resolves to another host. A URL parser
203// drops tab, LF and CR and reads a backslash as a slash, so "/\t/host" and
204// "/\host" would do the same; control bytes and backslashes are refused.
205func validLink(link string) bool {
206 if !sized(link, 1, maxLink) || !strings.HasPrefix(link, "/") || strings.HasPrefix(link, "//") {
207 return false
208 }
209 for i := 0; i < len(link); i++ {
210 if c := link[i]; c < 0x20 || c == 0x7f || c == '\\' {
211 return false
212 }
213 }
214 return true
215}