Skip to content
les-kPublic

About

A URL shortener with a REST API, a counting redirect, and a localStorage landing page - FastAPI + SQLite, no build step

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

snip

Live demo CI Python License: MIT

Paste a long link, get a short one — like bit.ly. Built end to end and deployed, so you can click it rather than take my word for it.

Try it: https://snip-u5ll.onrender.com

Three parts: a web page you type into, a server that stores the links and sends visitors on to the right place, and an API other programs can call. FastAPI and SQLite on the back, plain JavaScript on the front — no framework, no build step.

What it demonstrates

Full-stack Database, server, API and user interface, all written here and working together
Deployed and live In a container on real hosting, not just running on my laptop
Security thinking A link shortener will redirect anywhere you point it, which makes it a target. It refuses several classes of malicious destination — see below
Tested 134 tests, 95% coverage, run automatically on 3 operating systems
API design Correct HTTP status codes, self-documenting endpoints at /docs

Deploying it also exposed four bugs that every local test had passed — all documented, because that's the honest argument for deploying things rather than trusting a green test suite.

Hosted on a free instance, which sleeps after fifteen minutes idle: the first request can take about fifty seconds. There's no persistent disk either, so shortened links do not survive a restart. It's a demo, not a service.

$ curl -X POST localhost:8000/api/links \
    -H 'Content-Type: application/json' \
    -d '{"url": "https://example.com/a/very/long/path"}'

{"code":"Kf7mQx2","short_url":"http://localhost:8000/Kf7mQx2","target":"https://example.com/a/very/long/path","created_at":"2026-08-04T07:52:26.326411+00:00","clicks":0,"last_used_at":null}

Running it

Needs Python 3.10 or newer.

git clone https://github.com/les-k/snip.git
cd snip
python -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
python -m snip

Open http://localhost:8000. Interactive API docs are at /docs.

Variable Default
SNIP_DB snip.db Where the SQLite file lives
SNIP_HOST 127.0.0.1 Bind address
SNIP_PORT 8000 Port
SNIP_BASE_URL derived from the request Set this behind a proxy so short_url is right

The API

POST /api/links Create a link. {"url": "...", "code": "optional-alias"}
GET /api/links The most recent links, newest first (?limit=)
GET /api/links/{code} One link and its click count
DELETE /api/links/{code} Remove a link
GET /{code} Redirect, and count the visit
GET /api/health Liveness and how many links exist

Failures come back as the status the situation deserves, with a sentence saying what to do about it:

Status When
400 The URL or the alias was refused — the message says which and why
404 No link with that code
409 That alias is already taken
422 The request body wasn't the right shape

What's actually interesting here

A shortener is a small idea, so the substance is in the parts that aren't obvious. Three decisions carry the design.

Codes are random, not sequential

Base62 of a row id gives shorter codes and never collides. It also makes every link on the service enumerable: given /3 you can walk to /4 and read everyone else's. Random codes trade one collision check for the property that knowing one code tells you nothing about any other. create_unique retries on collision, so the caller never has to think about it.

The generated alphabet drops 0, O, 1, l and I — codes get read aloud and retyped from screenshots, and those pairs are where that goes wrong.

Custom aliases are held to a looser standard on purpose. That rule exists to protect someone transcribing a code they didn't choose; a person typing my-project-ideas picked every character and would only be annoyed to learn the letter l was unavailable. Aliases take the full alphanumeric set plus hyphens and underscores.

A redirector is attack surface, not a key-value store

Anyone can point this at anything, so urls.py refuses three classes of destination:

  • Non-HTTP schemes. A stored javascript: URL is a cross-site scripting vector the moment something renders it as a link; file: reaches into the client's disk.
  • Embedded credentials. https://evil.com:x@bank.example displays in some clients as though the part before the @ were the destination — a ready-made phishing primitive.
  • Private and loopback addresses. Without this, the service becomes a probe for whatever is reachable from wherever it's deployed. 169.254.169.254 is the address that matters: on most cloud providers it serves instance credentials to anything that asks.

The host is checked as a literal and after DNS resolution, so internal.example.com pointing at 10.0.0.5 is caught too.

Known gap: resolution happens at creation time, so this doesn't stop DNS rebinding — a domain that resolves publicly when the link is made and privately when it's followed. Closing that means resolving at redirect time and pinning the address, which is the next thing to do here.

The redirect is a 307, not a 301

A permanent redirect is exactly what a shortener looks like it wants, and it's the wrong answer: browsers cache 301s, so the second visit never reaches the server and the click counter silently stops counting. 307 keeps every visit observable. If you'd rather have the caching than the analytics, that's a one-line change — but it should be a decision, not an accident.

Layout

File
codes.py Generating codes, and validating the ones people choose
urls.py Deciding which destinations are acceptable
store.py SQLite persistence and the click counter
app.py Routes, error mapping, static mounting
static/ The landing page — one HTML file, one stylesheet, one script

The store keeps a single connection behind a lock rather than a pool. SQLite serialises writes anyway, and one connection makes the click increment correct without a transaction dance. A pool is the change to make when reads become the bottleneck, not before.

The landing page stores your history in localStorage and reads it back on load, so it survives a refresh without the server ever knowing what you shortened. Both the light and dark themes come from prefers-color-scheme.

Tests

pytest

134 tests, 95% coverage. CI runs them on Python 3.10–3.13 on Linux plus Windows and macOS, checks formatting and lint with ruff, and then starts a real server to shorten a link, follow the redirect, assert the click was counted, and confirm a request for 169.254.169.254 is refused.

URL validation is tested against an injected resolver, so the private-address checks are exercised without the suite ever touching the network.

Deploying

Docker

docker build -t snip .
docker run -p 8000:8000 -v snip-data:/data snip

The image runs as a non-root user, keeps the database on a volume at /data, and honours $PORT so it drops into most hosting platforms unchanged.

Render

render.yaml is a blueprint — point Render at this repository and it configures the service itself. Free tier, no card required.

Known limit: the free tier has no persistent disk, so the SQLite file resets whenever the instance restarts, including after about fifteen minutes idle. Shortened links do not survive that. It's a demo, not a service. Attaching a disk or moving to Postgres is the fix, and neither is free.

Configuration that matters in production

Variable Why
SNIP_BASE_URL The public origin. Without it, short_url echoes whatever hostname the proxy passed through. A bare hostname is accepted — https:// is added if missing.
SNIP_DB Put this on a persistent volume, or links vanish on restart.
SNIP_STATIC Pins the landing page directory. The container sets it; without it a pip-installed package looks in the wrong place and the page 404s while the API keeps working.

That last one is worth calling out: resolving the static directory by walking up from __file__ works from the repository and silently fails once installed into site-packages. Every test passed locally while the deployed page would have been broken. tests/test_deployment.py covers that class of bug specifically — the ones that only appear in a container.

Before real traffic

Two things are missing: rate limiting on POST /api/links, and authentication — anyone who can reach the API can create and delete links.

License

MIT — see LICENSE.

About

A URL shortener with a REST API, a counting redirect, and a localStorage landing page - FastAPI + SQLite, no build step

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages