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

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}