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.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 := aliases.Get(alias); v != nil {
 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 := aliases.Get(alias)
 85	if v == nil {
 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 := aliases.Get(alias)
101	if v == nil {
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 := aliases.Get(alias)
116	if v == nil {
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}