gnotif.gno
3.29 Kb · 131 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 !strings.HasPrefix(target, targetPrefix) || len(target) > maxTarget:
87 panic("invalid target")
88 case !sized(event, 1, maxName):
89 panic("invalid event")
90 case !validFilter(filter):
91 panic("invalid filter")
92 case !sized(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
107func validFilter(filter string) bool {
108 if filter == "" {
109 return true
110 }
111 pairs := strings.Split(filter, ",")
112 if len(pairs) > maxFilterPairs {
113 return false
114 }
115 for _, pair := range pairs {
116 key, value, ok := strings.Cut(pair, "=")
117 if !ok || !sized(key, 1, maxName) || !sized(value, 1, maxName) || strings.Contains(value, "=") {
118 return false
119 }
120 }
121 return true
122}
123
124// validLink accepts only a path, so a click always stays on the dapp's origin.
125// "//host" and "/\host" start with a slash yet resolve to another host.
126func validLink(link string) bool {
127 return sized(link, 1, maxLink) &&
128 strings.HasPrefix(link, "/") &&
129 !strings.HasPrefix(link, "//") &&
130 !strings.HasPrefix(link, "/\\")
131}