urlshort.gno
5.45 Kb · 203 lines
1// Package urlshort is a simple on-chain URL alias registry.
2//
3// Callers register an `alias -> url` mapping they own. An alias can only be
4// claimed once; the owner may later update the target URL, but nobody else can
5// overwrite an alias they do not own. A per-alias click counter is bumped every
6// time the alias is resolved through Render("/<alias>").
7package urlshort
8
9import (
10 "strings"
11
12 "chain"
13
14 "gno.land/p/nym-moultest123/kit/ui/v0"
15 "gno.land/p/nym-moultest123/md/v0"
16 "gno.land/p/nt/avl/v0"
17)
18
19// selfPath is this realm's own page. A root-relative link resolves against
20// the domain, not the realm, so every link back into it names it in full.
21const selfPath = "/r/nym-moultest123/x/daily/urlshort/v1"
22
23// entry is the persisted record for a single alias.
24type entry struct {
25 url string
26 owner address
27 note string
28 clicks int
29}
30
31// aliases maps alias (string) -> *entry, kept in an avl.Tree for deterministic
32// iteration order in Render.
33var aliases avl.Tree
34
35// isAlphanumeric reports whether s is non-empty and made only of [0-9A-Za-z].
36func isAlphanumeric(s string) bool {
37 if s == "" {
38 return false
39 }
40 for _, c := range s {
41 switch {
42 case c >= '0' && c <= '9':
43 case c >= 'a' && c <= 'z':
44 case c >= 'A' && c <= 'Z':
45 default:
46 return false
47 }
48 }
49 return true
50}
51
52// Shorten registers `alias -> url` owned by the caller.
53//
54// Rules:
55// - alias must be non-empty and alphanumeric;
56// - url must be non-empty;
57// - if the alias is unclaimed, it is created owned by the caller;
58// - if it is already claimed by the caller, the url/note are updated;
59// - if it is claimed by someone else, the call aborts.
60func Shorten(cur realm, alias string, url string, note string) {
61 if !cur.IsCurrent() {
62 panic("urlshort: spoofed realm")
63 }
64 if !isAlphanumeric(alias) {
65 panic("alias must be non-empty and alphanumeric")
66 }
67 if url == "" {
68 panic("url must be non-empty")
69 }
70 // The URL is rendered as an <autolink>, so it is checked here rather than
71 // escaped there: http(s) only, and nothing that could close the brackets.
72 if !safeURL(url) {
73 panic("url must be http:// or https:// with no spaces or angle brackets")
74 }
75
76 caller := cur.Previous().Address()
77
78 if v := aliases.Get(alias); v != nil {
79 e := v.(*entry)
80 if e.owner != caller {
81 panic("alias already taken by another owner")
82 }
83 e.url = url
84 e.note = note
85 chain.Emit("AliasUpdated", "alias", alias, "owner", caller.String())
86 return
87 }
88
89 e := &entry{url: url, owner: caller, note: note, clicks: 0}
90 aliases.Set(alias, e)
91 chain.Emit("AliasCreated", "alias", alias, "owner", caller.String())
92}
93
94// Remove deletes an alias owned by the caller. Aborts if the alias does not
95// exist or the caller is not the owner.
96func Remove(cur realm, alias string) {
97 if !cur.IsCurrent() {
98 panic("urlshort: spoofed realm")
99 }
100 v := aliases.Get(alias)
101 if v == nil {
102 panic("alias not found")
103 }
104 e := v.(*entry)
105 caller := cur.Previous().Address()
106 if e.owner != caller {
107 panic("only the owner can remove an alias")
108 }
109 aliases.Remove(alias)
110 chain.Emit("AliasRemoved", "alias", alias, "owner", caller.String())
111}
112
113// Lookup returns the target url for an alias and whether it exists. It is a
114// read-only helper and does NOT increment the click counter.
115func Lookup(alias string) (string, bool) {
116 v := aliases.Get(alias)
117 if v == nil {
118 return "", false
119 }
120 return v.(*entry).url, true
121}
122
123// Render renders Markdown. The root path lists every alias; "/<alias>" shows a
124// single alias detail and bumps its click counter.
125func Render(path string) string {
126 if path == "" || path == "/" {
127 return renderIndex()
128 }
129
130 alias := strings.TrimPrefix(path, "/")
131 v := aliases.Get(alias)
132 if v == nil {
133 return "# URL Shortener\n\nNo alias named " + md.InlineCode(alias) + ".\n\n[← all aliases](" + selfPath + ")\n"
134 }
135
136 e := v.(*entry)
137 e.clicks++
138
139 var b strings.Builder
140 b.WriteString("# 🔗 " + alias + "\n\n")
141 b.WriteString("**Target:** <" + e.url + ">\n\n")
142 b.WriteString("**Owner:** " + e.owner.String() + "\n\n")
143 if e.note != "" {
144 b.WriteString("**Note:** " + ui.Inline(e.note) + "\n\n")
145 }
146 b.WriteString("**Clicks:** " + itoa(e.clicks) + "\n\n")
147 b.WriteString("[← all aliases](" + selfPath + ")\n")
148 return b.String()
149}
150
151func renderIndex() string {
152 var b strings.Builder
153 b.WriteString("# 🔗 URL Shortener\n\n")
154
155 if aliases.Size() == 0 {
156 b.WriteString("_No aliases registered yet._\n\n")
157 b.WriteString("Call `Shorten(alias, url, note)` to register one.\n")
158 return b.String()
159 }
160
161 b.WriteString("| Alias | Target | Owner | Clicks |\n")
162 b.WriteString("|-------|--------|-------|--------|\n")
163 aliases.Iterate("", "", func(key string, value interface{}) bool {
164 e := value.(*entry)
165 b.WriteString("| [" + key + "](" + selfPath + ":" + key + ") | <" + e.url + "> | " +
166 ui.Addr(e.owner) + " | " + itoa(e.clicks) + " |\n")
167 return false
168 })
169 b.WriteString("\nTotal aliases: " + itoa(aliases.Size()) + "\n")
170 return b.String()
171}
172
173// itoa converts a non-negative int to its decimal string without importing
174// strconv (kept minimal & deterministic).
175func itoa(n int) string {
176 if n == 0 {
177 return "0"
178 }
179 neg := n < 0
180 if neg {
181 n = -n
182 }
183 var buf [20]byte
184 i := len(buf)
185 for n > 0 {
186 i--
187 buf[i] = byte('0' + n%10)
188 n /= 10
189 }
190 if neg {
191 i--
192 buf[i] = '-'
193 }
194 return string(buf[i:])
195}
196
197// safeURL reports whether u can be written into a markdown autolink unescaped.
198func safeURL(u string) bool {
199 if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
200 return false
201 }
202 return !strings.ContainsAny(u, " \t\r\n<>`\\")
203}