Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}