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