Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}