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