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

linktree.gno

4.86 Kb · 192 lines
  1package linktree
  2
  3import (
  4	"strconv"
  5	"strings"
  6
  7	"chain"
  8
  9	"gno.land/p/moul/kit/ui/v0"
 10	"gno.land/p/nt/avl/v0"
 11)
 12
 13// selfPath is this realm's own page. A root-relative link resolves against
 14// the domain, not the realm, so every link back into it names it in full.
 15const selfPath = "/r/moul/x/daily/linktree/v1"
 16
 17// link is a single labeled URL on a profile.
 18type link struct {
 19	Label string
 20	URL   string
 21}
 22
 23// profile is one address's personal links page.
 24type profile struct {
 25	Bio   string
 26	Links []link
 27}
 28
 29// profiles maps an address (string key) to its *profile.
 30// avl.Tree keeps iteration deterministic for Render.
 31var profiles avl.Tree
 32
 33// The bounds on what a caller may store. Anyone may create a profile, and the
 34// root page lists every profile while a profile page lists every link.
 35const (
 36	MaxProfiles = 1000
 37	MaxLinks    = 20
 38	MaxBioLen   = 280
 39	MaxLabelLen = 64
 40	MaxURLLen   = 512
 41)
 42
 43// SetBio sets (or replaces) the calling address's bio.
 44func SetBio(cur realm, bio string) {
 45	addr := caller(cur)
 46	if len(bio) > MaxBioLen {
 47		panic("bio too long")
 48	}
 49	p := getOrCreate(addr.String())
 50	p.Bio = bio
 51	chain.Emit("BioSet", "addr", addr.String())
 52}
 53
 54// AddLink appends a labeled URL to the calling address's profile.
 55func AddLink(cur realm, label string, url string) {
 56	addr := caller(cur)
 57	if label == "" {
 58		panic("label must not be empty")
 59	}
 60	if url == "" {
 61		panic("url must not be empty")
 62	}
 63	// The URL becomes a markdown link destination as written, so it is
 64	// checked here rather than escaped there: http(s) only, and nothing that
 65	// could close the destination or start another.
 66	if !safeURL(url) {
 67		panic("url must be http:// or https:// with no spaces, parentheses or angle brackets")
 68	}
 69	if len(label) > MaxLabelLen || len(url) > MaxURLLen {
 70		panic("label or url too long")
 71	}
 72	p := getOrCreate(addr.String())
 73	if len(p.Links) >= MaxLinks {
 74		panic("a profile holds at most " + strconv.Itoa(MaxLinks) + " links")
 75	}
 76	p.Links = append(p.Links, link{Label: label, URL: url})
 77	chain.Emit("LinkAdded", "addr", addr.String(), "label", label)
 78}
 79
 80// RemoveLink deletes the link at index from the calling address's profile.
 81func RemoveLink(cur realm, index int) {
 82	addr := caller(cur)
 83	key := addr.String()
 84	v := profiles.Get(key)
 85	if v == nil {
 86		panic("no profile for caller")
 87	}
 88	p := v.(*profile)
 89	if index < 0 || index >= len(p.Links) {
 90		panic("index out of range")
 91	}
 92	// A fresh slice, not append(s[:i], s[i+1:]...): the in-place form keeps the
 93	// old backing array, so the deposit for a removed link never comes back.
 94	kept := make([]link, 0, len(p.Links)-1)
 95	kept = append(kept, p.Links[:index]...)
 96	kept = append(kept, p.Links[index+1:]...)
 97	p.Links = kept
 98	chain.Emit("LinkRemoved", "addr", key, "index", strconv.Itoa(index))
 99}
100
101// getOrCreate returns the profile for key, creating an empty one if absent.
102func getOrCreate(key string) *profile {
103	if v := profiles.Get(key); v != nil {
104		return v.(*profile)
105	}
106	if profiles.Size() >= MaxProfiles {
107		panic("linktree is full at " + strconv.Itoa(MaxProfiles) + " profiles")
108	}
109	p := &profile{}
110	profiles.Set(key, p)
111	return p
112}
113
114// Render shows all profiles at root, or a single profile at "/<address>".
115func Render(path string) string {
116	addr := strings.Trim(path, "/")
117	if addr == "" {
118		return renderIndex()
119	}
120	return renderProfile(addr)
121}
122
123func renderIndex() string {
124	var b strings.Builder
125	b.WriteString("# Linktree\n\n")
126	if profiles.Size() == 0 {
127		b.WriteString("_No profiles yet. Call `SetBio` or `AddLink` to create one._\n")
128		return b.String()
129	}
130	b.WriteString("Profiles:\n\n")
131	profiles.Iterate("", "", func(key string, _ interface{}) bool {
132		b.WriteString("- [")
133		b.WriteString(key)
134		b.WriteString("](" + selfPath + ":")
135		b.WriteString(key)
136		b.WriteString(")\n")
137		return false
138	})
139	return b.String()
140}
141
142func renderProfile(addr string) string {
143	var b strings.Builder
144	b.WriteString("# ")
145	b.WriteString(ui.Inline(addr))
146	b.WriteString("\n\n")
147
148	v := profiles.Get(addr)
149	if v == nil {
150		b.WriteString("_No profile for this address._\n")
151		return b.String()
152	}
153	p := v.(*profile)
154
155	if p.Bio != "" {
156		b.WriteString(ui.Inline(p.Bio))
157		b.WriteString("\n\n")
158	}
159
160	if len(p.Links) == 0 {
161		b.WriteString("_No links yet._\n")
162		return b.String()
163	}
164
165	b.WriteString("## Links\n\n")
166	for _, l := range p.Links {
167		b.WriteString("- [")
168		b.WriteString(ui.Inline(l.Label))
169		b.WriteString("](")
170		b.WriteString(l.URL)
171		b.WriteString(")\n")
172	}
173	return b.String()
174}
175
176// caller is the address that crossed into this realm, after checking that the
177// realm token is this frame's own.
178func caller(cur realm) address {
179	if !cur.IsCurrent() {
180		panic("linktree: spoofed realm")
181	}
182	return cur.Previous().Address()
183}
184
185// safeURL reports whether u can be written into a markdown link destination
186// unescaped.
187func safeURL(u string) bool {
188	if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
189		return false
190	}
191	return !strings.ContainsAny(u, " \t\r\n()<>[]`\\")
192}