gnotif.gno
4.83 Kb · 190 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)
32
33var (
34 triggers avl.Tree // id → *Trigger
35 lastID seqid.ID
36)
37
38// Declare stores a trigger and returns its id. The trigger is verified when
39// its caller is the target realm itself.
40func Declare(cur realm, target, event, filter, param, title, body, link string) string {
41 validate(target, event, filter, param, title, body, link)
42 caller := cur.Previous()
43 tr := &Trigger{
44 ID: lastID.Next().String(),
45 Target: target,
46 Event: event,
47 Filter: filter,
48 Param: param,
49 Title: title,
50 Body: body,
51 Link: link,
52 Declarer: caller.Address(),
53 Verified: caller.IsCode() && caller.PkgPath() == target,
54 }
55 triggers.Set(tr.ID, tr)
56 chain.Emit("TriggerDeclared",
57 "id", tr.ID,
58 "target", tr.Target,
59 "event", tr.Event,
60 "filter", tr.Filter,
61 "param", tr.Param,
62 "title", tr.Title,
63 "body", tr.Body,
64 "link", tr.Link,
65 "declarer", tr.Declarer.String(),
66 "verified", strconv.FormatBool(tr.Verified),
67 )
68 return tr.ID
69}
70
71// Remove deletes a trigger. Only its declarer may remove it.
72func Remove(cur realm, id string) {
73 v := triggers.Get(id)
74 if v == nil {
75 panic("unknown trigger")
76 }
77 if v.(*Trigger).Declarer != cur.Previous().Address() {
78 panic("not the declarer")
79 }
80 triggers.Remove(id)
81 chain.Emit("TriggerRemoved", "id", id)
82}
83
84func validate(target, event, filter, param, title, body, link string) {
85 switch {
86 case !validTarget(target):
87 panic("invalid target")
88 case !validName(event, 1, maxName):
89 panic("invalid event")
90 case !validFilter(filter):
91 panic("invalid filter")
92 case !validName(param, 0, maxName):
93 panic("invalid param")
94 case !sized(title, 1, maxName):
95 panic("invalid title")
96 case !sized(body, 0, maxBody):
97 panic("invalid body")
98 case !validLink(link):
99 panic("invalid link")
100 }
101}
102
103func sized(s string, lo, hi int) bool {
104 return len(s) >= lo && len(s) <= hi
105}
106
107// validTarget accepts a realm path whose segments each match the chain's
108// Re_name, so a target names a path where a realm can exist.
109func validTarget(target string) bool {
110 if !strings.HasPrefix(target, targetPrefix) || len(target) > maxTarget {
111 return false
112 }
113 rest := target[len(targetPrefix):]
114 start := 0 // index of the current segment's first byte
115 for i := 0; i <= len(rest); i++ {
116 if i == len(rest) || rest[i] == '/' {
117 if i == start || !isLowerAlnum(rest[i-1]) {
118 return false
119 }
120 start = i + 1
121 continue
122 }
123 c := rest[i]
124 switch {
125 case i == start:
126 if !(c >= 'a' && c <= 'z') {
127 return false
128 }
129 case c == '_' || c == '-':
130 if !isLowerAlnum(rest[i-1]) {
131 return false
132 }
133 case !isLowerAlnum(c):
134 return false
135 }
136 }
137 return true
138}
139
140func isLowerAlnum(c byte) bool {
141 return c >= 'a' && c <= 'z' || c >= '0' && c <= '9'
142}
143
144// validName accepts identifier bytes only, so a name cannot carry spaces or
145// symbols that imitate the verified mark in rendered output.
146func validName(s string, lo, hi int) bool {
147 if !sized(s, lo, hi) {
148 return false
149 }
150 for i := 0; i < len(s); i++ {
151 c := s[i]
152 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_') {
153 return false
154 }
155 }
156 return true
157}
158
159func validFilter(filter string) bool {
160 if filter == "" {
161 return true
162 }
163 pairs := strings.Split(filter, ",")
164 if len(pairs) > maxFilterPairs {
165 return false
166 }
167 for _, pair := range pairs {
168 key, value, ok := strings.Cut(pair, "=")
169 if !ok || !validName(key, 1, maxName) || !sized(value, 1, maxName) || strings.Contains(value, "=") {
170 return false
171 }
172 }
173 return true
174}
175
176// validLink accepts only a path, so a click always stays on the dapp's origin.
177// "//host" starts with a slash yet resolves to another host. A URL parser
178// drops tab, LF and CR and reads a backslash as a slash, so "/\t/host" and
179// "/\host" would do the same; control bytes and backslashes are refused.
180func validLink(link string) bool {
181 if !sized(link, 1, maxLink) || !strings.HasPrefix(link, "/") || strings.HasPrefix(link, "//") {
182 return false
183 }
184 for i := 0; i < len(link); i++ {
185 if c := link[i]; c < 0x20 || c == 0x7f || c == '\\' {
186 return false
187 }
188 }
189 return true
190}