hop: short links and pastes in one small Go binary
Twice a day I need one of two things: a URL short enough to say out loud or paste into a chat, or a place to drop a log, a stack trace or the output of kubectl get pods so someone can look at it without me attaching a file. Every free service that does this either tracks the clicks, expires in ways I don’t control, or goes away. The box behind this site already has Caddy, CrowdSec and a deploy loop; adding one more tiny service was cheaper than another account. The result is hop, MIT-licensed, running at go.divyam.top and paste.divyam.top.
What it is
One Go binary, standard library plus a pure-Go SQLite driver, ~30 MB as a distroless image. It routes by Host header: on the links host GET /<slug> answers 302 to the stored URL and counts the hit; on the pastes host GET /<id> returns the text as text/plain, while a browser (or ?html=1, or /<id>.sh to pick a language) gets a dark, numbered view. /<id>/raw always returns the bytes untouched.
Reads are anonymous. Both can be created anonymously within strict limits (random slugs plus a confirmation page for links; size and a 24 h expiry for pastes; rate limits on both) — the token lifts the limits. Deleting anything, custom slugs, plain redirects and the full paste limits still require the bearer token that lives only on the server. That split is the security model: the public side can submit only small, short-lived text at a throttled rate, and the per-IP token bucket plus CrowdSec in front take care of the rest.
A few decisions that earned their keep:
- Expiry is a first-class field. Pastes default to 30 days (
0means forever); links can carry attltoo. A janitor goroutine deletes expired rows every ten minutes, so the database never becomes an archive of things I meant to throw away. - Pastes are never HTML unless you ask. The raw and default responses are
text/plain; the pretty view is a rendered template with everything escaped and a strictContent-Security-Policy(default-src 'none'). A paste containing<script>is just text. - Reserved names.
api,raw,static,healthz,adminand friends cannot be slugs, so nothing you create can shadow the service’s own routes. - Boring operations. It is an entry in the GitOps repo’s
apps.txt, built on the box by the deploy script, reverse-proxied by Caddy with the same security headers as the rest, and its write token was minted once by the deploy script into a secrets file. Push tomain, wait for the next tick, done.
How I actually use it
Four ways, depending on where my hands are.
In a browser. The landing pages at go.divyam.top and paste.divyam.top have a small UI: unlock once with the token (kept in the browser’s local storage, never sent anywhere but the service itself), then a form — URL plus optional slug and expiry for links; a text box with title, language and expiry for pastes. This is the path I point people to when they ask “how do I use it”.
From the infrastructure repo, where I already am most of the day:
make short URL=https://kubernetes.io/docs/ SLUG=k8s # -> https://go.divyam.top/k8s
make short URL=https://some.long/url # random 5-character slug
make paste FILE=notes.md # -> https://paste.divyam.top/8hZp2Kq3
make hop-token # prints the write token (it lives on the box)
With the CLI, which is the same binary in a different mode. One-time setup is make hop-setup (or go install github.com/nihaldivyam/hop@latest followed by hop login --api https://go.divyam.top --token …), and then:
hop link https://kubernetes.io/docs/ k8s # custom slug
hop link https://example.com # random slug
kubectl get pods -A | hop paste # anything from stdin
hop paste deploy.sh --ttl 1d # title and language from the file name
hop ls # what exists
hop rm k8s # delete a link (or a paste by id)
With curl, or anything that speaks HTTP — useful from CI or a script on another machine:
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"url":"https://kubernetes.io/docs/","slug":"k8s","ttl":"720h"}' \
https://go.divyam.top/api/links
curl -H "Authorization: Bearer $TOKEN" -H "X-Title: notes.md" -H "X-TTL: 7d" \
--data-binary @notes.md https://paste.divyam.top/api/pastes
Both answer with JSON (short_url / url, plus expires_at); GET on the same endpoints lists, DELETE /api/links/<slug> and DELETE /api/pastes/<id> remove. Reading never needs the token: curl https://paste.divyam.top/<id> prints the text, and go.divyam.top/<slug> is a plain redirect that any browser follows.
Limits, deliberately
Pastes are capped at 256 KiB — this is for logs and snippets, not file sharing. Slugs are [A-Za-z0-9_-], up to 64 characters. Anonymous reads are rate-limited per IP (using the proxy’s forwarded address, so the limit is per visitor and not per Caddy). There is one token, because there is one user; if that ever changes it grows a users table, not a login page.
What’s next
Syntax highlighting in the browser view is the obvious gap, and a hit counter per link on the landing page would be nice to have. Neither needs a new service, which is rather the point: the box gets one more useful thing without getting one more thing to operate.
Update: you can also name your own paste URL — open paste.divyam.top/<name> (up to 15
characters) and, if nobody has used it, write the paste right there; hop paste file --id <name>
and X-Id on the API do the same.