urlshort.gno
4.56 Kb Β· 180 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 "chain/runtime/unsafe"
14
15 "gno.land/p/moul/kit/ui/v0"
16 "gno.land/p/nt/avl/v0"
17)
18
19// entry is the persisted record for a single alias.
20type entry struct {
21 url string
22 owner address
23 note string
24 clicks int
25}
26
27// aliases maps alias (string) -> *entry, kept in an avl.Tree for deterministic
28// iteration order in Render.
29var aliases avl.Tree
30
31// isAlphanumeric reports whether s is non-empty and made only of [0-9A-Za-z].
32func isAlphanumeric(s string) bool {
33 if s == "" {
34 return false
35 }
36 for _, c := range s {
37 switch {
38 case c >= '0' && c <= '9':
39 case c >= 'a' && c <= 'z':
40 case c >= 'A' && c <= 'Z':
41 default:
42 return false
43 }
44 }
45 return true
46}
47
48// Shorten registers `alias -> url` owned by the caller.
49//
50// Rules:
51// - alias must be non-empty and alphanumeric;
52// - url must be non-empty;
53// - if the alias is unclaimed, it is created owned by the caller;
54// - if it is already claimed by the caller, the url/note are updated;
55// - if it is claimed by someone else, the call aborts.
56func Shorten(cur realm, alias string, url string, note string) {
57 if !isAlphanumeric(alias) {
58 panic("alias must be non-empty and alphanumeric")
59 }
60 if url == "" {
61 panic("url must be non-empty")
62 }
63
64 caller := unsafe.PreviousRealm().Address()
65
66 if v := aliases.Get(alias); v != nil {
67 e := v.(*entry)
68 if e.owner != caller {
69 panic("alias already taken by another owner")
70 }
71 e.url = url
72 e.note = note
73 chain.Emit("AliasUpdated", "alias", alias, "owner", caller.String())
74 return
75 }
76
77 e := &entry{url: url, owner: caller, note: note, clicks: 0}
78 aliases.Set(alias, e)
79 chain.Emit("AliasCreated", "alias", alias, "owner", caller.String())
80}
81
82// Remove deletes an alias owned by the caller. Aborts if the alias does not
83// exist or the caller is not the owner.
84func Remove(cur realm, alias string) {
85 v := aliases.Get(alias)
86 if v == nil {
87 panic("alias not found")
88 }
89 e := v.(*entry)
90 caller := unsafe.PreviousRealm().Address()
91 if e.owner != caller {
92 panic("only the owner can remove an alias")
93 }
94 aliases.Remove(alias)
95 chain.Emit("AliasRemoved", "alias", alias, "owner", caller.String())
96}
97
98// Lookup returns the target url for an alias and whether it exists. It is a
99// read-only helper and does NOT increment the click counter.
100func Lookup(alias string) (string, bool) {
101 v := aliases.Get(alias)
102 if v == nil {
103 return "", false
104 }
105 return v.(*entry).url, true
106}
107
108// Render renders Markdown. The root path lists every alias; "/<alias>" shows a
109// single alias detail and bumps its click counter.
110func Render(path string) string {
111 if path == "" || path == "/" {
112 return renderIndex()
113 }
114
115 alias := strings.TrimPrefix(path, "/")
116 v := aliases.Get(alias)
117 if v == nil {
118 return "# URL Shortener\n\nNo alias named `" + alias + "`.\n\n[β all aliases](/)\n"
119 }
120
121 e := v.(*entry)
122 e.clicks++
123
124 var b strings.Builder
125 b.WriteString("# π " + alias + "\n\n")
126 b.WriteString("**Target:** <" + e.url + ">\n\n")
127 b.WriteString("**Owner:** " + e.owner.String() + "\n\n")
128 if e.note != "" {
129 b.WriteString("**Note:** " + e.note + "\n\n")
130 }
131 b.WriteString("**Clicks:** " + itoa(e.clicks) + "\n\n")
132 b.WriteString("[β all aliases](/)\n")
133 return b.String()
134}
135
136func renderIndex() string {
137 var b strings.Builder
138 b.WriteString("# π URL Shortener\n\n")
139
140 if aliases.Size() == 0 {
141 b.WriteString("_No aliases registered yet._\n\n")
142 b.WriteString("Call `Shorten(alias, url, note)` to register one.\n")
143 return b.String()
144 }
145
146 b.WriteString("| Alias | Target | Owner | Clicks |\n")
147 b.WriteString("|-------|--------|-------|--------|\n")
148 aliases.Iterate("", "", func(key string, value interface{}) bool {
149 e := value.(*entry)
150 b.WriteString("| [" + key + "](/" + key + ") | <" + e.url + "> | " +
151 ui.Addr(e.owner) + " | " + itoa(e.clicks) + " |\n")
152 return false
153 })
154 b.WriteString("\nTotal aliases: " + itoa(aliases.Size()) + "\n")
155 return b.String()
156}
157
158// itoa converts a non-negative int to its decimal string without importing
159// strconv (kept minimal & deterministic).
160func itoa(n int) string {
161 if n == 0 {
162 return "0"
163 }
164 neg := n < 0
165 if neg {
166 n = -n
167 }
168 var buf [20]byte
169 i := len(buf)
170 for n > 0 {
171 i--
172 buf[i] = byte('0' + n%10)
173 n /= 10
174 }
175 if neg {
176 i--
177 buf[i] = '-'
178 }
179 return string(buf[i:])
180}