Skip to content

Repository files navigation

redirect

Permanent short URLs with per-visit logging. A link is a slug — random or chosen — that 302-redirects to a target you control. When the target moves, you edit one line and every link you have ever shared keeps working. Each visit is logged with a timestamp, pseudonymous visitor identifier, location/request metadata, referrer, and user agent.

Stack: Python 3.12, FastAPI, Pydantic, SQLite, uv, and ruff.

Run locally

git clone <this repo> && cd redirect
uv sync
cp .env.example .env        # edit BASE_URL
cp links.example.toml links.toml
uv run --env-file .env uvicorn main:app --reload --port 8001

curl -i http://localhost:8001/abc1234 answers 302. Visits land in data/redirect.db. The app and CLI read the environment, not .env — outside Docker, pass it with uv run --env-file .env ... (compose injects it via env_file). Against a Docker deployment, run the CLI inside the container: docker compose exec web uv run --no-sync python -m redirect.cli ....

Managing links

Links live in links.toml — one [slug] table per short URL:

[abc1234]
target = "https://example.com/signup"
note = "march campaign"

Slugs are 1–64 characters of a-z, 0-9, -; targets must be http:// or https:// URLs. The app reads the file on every request, so editing a target takes effect on the next request — no restart. A broken file fails requests loudly until fixed.

Create links with the CLI, which generates a random 7-character slug from an alphabet without l, o, 0, 1 and prints the URL, ready to share:

uv run --env-file .env python -m redirect.cli https://example.com/signup --note "march campaign"
# https://go.example.com/k3x9q2m  →  https://example.com/signup

Choose your own slug with --slug wedding, or edit the file by hand. Two slugs pointing at the same target get separate log entries — that is how you tell two campaigns apart. Slugs match case-insensitively.

Incoming query strings are ignored; the slug alone identifies the link, so a crafted URL cannot inject parameters into the destination.

The visit log

Every visit stores the timestamp, slug, target and note at the time of the visit, a pseudonymous visitor identifier (a salted hash of the client IP — the IP itself is never stored), country (CF-IPCountry, set by Cloudflare), referrer, and user agent in data/redirect.db — a plain SQLite database you can query directly. The salt lives in data/redirect.salt, generated on first run; keep it with your backups, or unique-visitor counts restart. A headers column stores the request headers as JSON, which captures everything Cloudflare adds (CF-Ray, CF-Visitor, and — with the "Add visitor location headers" Managed Transform enabled — CF-IPCity, CF-IPContinent, CF-IPLatitude/Longitude, CF-IPTimeZone, and more) as well as browser headers like Accept-Language, queryable later via SQLite's json_extract. Cookie, Authorization, and the IP-carrying headers (CF-Connecting-IP, X-Forwarded-For, X-Real-IP, True-Client-IP) are excluded from the stored blob. The log grows by one row per visit with no retention cap — prune it by hand if volume ever matters. The built-in /privacy page (/privacy is a reserved slug) states this logging to visitors and shows a contact address (OPERATOR_CONTACT, default privacy@example.com).

Deploy with Docker

Docker Compose is the supported deployment path.

cp .env.example .env        # set BASE_URL
cp links.example.toml links.toml
docker compose up -d --build

Compose binds the app to 127.0.0.1:8001. Put an HTTPS reverse proxy or Cloudflare Tunnel in front of it. For a Cloudflare proxy, use Full (strict) TLS with an origin certificate, or use a tunnel — Flexible mode carries traffic to your origin as plaintext HTTP across the public internet.

Uvicorn runs with --proxy-headers and takes the client IP and scheme from X-Forwarded-*. FORWARDED_ALLOW_IPS names the proxy hops trusted to set those headers (default: loopback); the compose file sets the Docker network ranges. On other hosting, set it to the address the platform's proxy connects from. The client IP it resolves is what the visit log records.

The instance files — .env, links.toml, and data/ — are git- and docker-ignored; keep the real ones in your private instance repo or on the host. SQLite data lives in ./data/; copy that directory as part of your backup routine.

Updates

git pull
docker compose up -d --build

Back up data/, .env, and links.toml before updates.

Development

uv run pytest
uv run ruff check .
uv run ruff format .
uv run basedpyright

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages