Files
ushort/README.md
T
2026-03-18 04:41:57 +08:00

12 KiB
Raw Blame History

URL Shortener

A minimal URL shortener written in pure Python 3.8+ (zero third-party dependencies) backed by SQLite.

Quick start

Run locally

python urlshort.py config.json

Run with Docker Compose

Edit config.json first (especially api_key and base_url), then:

docker compose up --build

The SQLite database is stored in ./data/urlshort.db on the host — it survives container restarts.


Configuration (config.json)

Key Required Default Description
base_url Public base URL. The path component (e.g. /s in http://example.com/s) is automatically used as the server's routing prefix.
api_key Secret key to protect write/read operations
host "0.0.0.0" Bind address
port 8080 Bind port
db_path "data/urlshort.db" Path to the SQLite database file
retention_days 0 Default retention period in days for new URLs. 0 means never expire.
min_short_length 6 Minimum character length for generated short codes
max_short_length 32 Maximum character length for generated short codes
max_url_length 2048 Maximum allowed length for original URLs
max_retention_days 3650 Maximum allowed retention_days value per URL
rate_limit_requests 60 Max requests per IP per rate-limit window
rate_limit_window 60 Rate-limit window in seconds

Backward compat: If an old config contains short_length, it is automatically used as min_short_length.


Deploying behind a sub-path (e.g. http://example.com/s)

Just set base_url to include the desired path prefix — the server derives its routing prefix automatically from it:

{
  "base_url": "http://example.com/s",
  ...
}

The path component /s is extracted at startup. The server will only respond to requests whose path starts with /s; everything else returns 404.

base_url Derived routing prefix Short URL example
http://example.com (none — root) http://example.com/aB3xYz
http://example.com/s /s http://example.com/s/aB3xYz
http://example.com/go/links /go/links http://example.com/go/links/aB3xYz

With Docker Compose

# Set base_url in config.json, then:
docker compose up --build
# Service is now available at http://localhost:8080/s/

With an existing nginx vhost

nginx.conf contains location blocks only — drop them into an existing server { } block.
The app handles the base_path prefix internally; nginx proxies API/redirect requests and serves frontend static files.

Browser ──► nginx /s/         ──► static/index.html
Browser ──► nginx /s/static/… ──► static files (CSS/JS)
Browser ──► nginx /s/api/…    ──► urlshort :8080 (proxy)
Browser ──► nginx /s/<code>   ──► urlshort :8080 (proxy → 302)

To change the prefix, update base_url in config.json and the location /s blocks in nginx.conf.


Authentication

All endpoints marked with 🔒 require the API key.
Pass it as a query parameter or in the JSON request body:

?api_key=<api_key>
# or in JSON body
{"api_key": "<api_key>", ...}

Retention

URLs can have a retention period (retention_days). When set to a positive integer, the URL will be automatically deleted after that many days. If 0 or not set, the URL never expires.

  • The default retention is set in config.json (retention_days key, default 0).
  • Each URL can override the default at creation time via the retention_days field.
  • Expired URLs are cleaned up on startup and lazily on each incoming request.

API Reference

GET /

Serves the frontend page (if static/index.html exists), otherwise returns health check JSON.

GET /api/health

Health check endpoint.

Response 200

{ "status": "ok", "service": "url-shortener" }

POST /api/shorten

Create a new short URL. No API key required.

Fields can be passed as query parameters (URL-encoded) or in a JSON request body. Query parameters take precedence over body fields.

Field Required Description
url The URL to shorten (must start with http:// or https://)
retention_days Override the default retention period for this URL

Short code length is determined automatically: the server starts at min_short_length and progressively tries longer codes on collision, up to max_short_length.

Response 201 — plain text containing only the short URL:

http://localhost:8080/s/aB3xYz

GET /api/urls 🔒

List all short URLs, newest first.

Response 200

{
  "count": 2,
  "urls": [
    {
      "short_code": "aB3xYz",
      "short_url": "http://localhost:8080/s/aB3xYz",
      "original_url": "https://example.com",
      "created_at": 1710000000,
      "visit_count": 5,
      "retention_days": 0
    }
  ]
}

GET /api/urls/<code>

Get metadata for a single short code. No API key required.

Response 200

{
  "short_code": "aB3xYz",
  "short_url": "http://localhost:8080/s/aB3xYz",
  "original_url": "https://example.com",
  "created_at": 1710000000,
  "visit_count": 5,
  "retention_days": 0
}

Response 404 — code not found (empty body).


GET /api/lookup

Look up a URL by its original URL. No API key required.
Used by the frontend to check if a URL has already been shortened.

Parameter Required Description
url The original URL to look up

Response 200 — same metadata JSON as GET /api/urls/<code>.
Response 404 — no short URL exists for this original URL.


DELETE /api/urls/<code> 🔒

Delete a short URL entry.

Response 204 — success (empty body).
Response 404 — code not found (empty body).
Response 403 — not authorized (empty body).


GET /<code>

Redirect to the original URL (HTTP 302).
Increments visit_count on each hit.


Example with curl

# Shorten a URL (JSON body)
curl -X POST http://localhost:8080/s/api/shorten \
     -H "Content-Type: application/json" \
     -d '{"url": "https://github.com"}'

# Shorten a URL (query parameters)
curl -X POST "http://localhost:8080/s/api/shorten?url=https%3A%2F%2Fgithub.com&retention_days=30"

# Follow the redirect
curl -L http://localhost:8080/s/aB3xYz

# Get metadata for a short URL (no API key needed)
curl http://localhost:8080/s/api/urls/aB3xYz

# List all URLs
curl "http://localhost:8080/s/api/urls?api_key=change-this-secret-key"

# Delete a URL
curl -X DELETE "http://localhost:8080/s/api/urls/aB3xYz?api_key=change-this-secret-key"

# Lookup by original URL
curl "http://localhost:8080/s/api/lookup?url=https%3A%2F%2Fexample.com"

Frontend

A clean single-page frontend is included in static/. It provides:

  • URL shortening — paste a URL and press Enter to create a short URL
  • Existing URL lookup — as you type a URL, the frontend checks if it already exists and shows its metadata
  • Admin table — enter a valid API key to see all shortened URLs in a sortable, paginated table
  • Copy & Delete — per-row copy and delete buttons (delete on hover only)
  • Theme switching — matches system dark/light preference, with a manual toggle

Accessing the frontend

  • Via nginx (production): browse to /s/
  • Via Python backend (local dev): browse to http://localhost:8080/s/

Fonts

The CSS includes a Google Fonts @import that works out of the box. For strict local-serve deployments, replace it with locally-hosted font files (use navpage's fetch_fonts.py as a reference).


Security & Hardening

CORS

All responses include Access-Control-Allow-Origin: * headers. OPTIONS preflight requests are handled automatically.

Rate limiting

A per-IP sliding-window rate limiter protects all endpoints. Default: 60 requests per 60-second window (configurable via rate_limit_requests and rate_limit_window). Behind a reverse proxy, the real client IP is extracted from X-Real-IP / X-Forwarded-For headers. Returns 429 Too Many Requests when exceeded.

Field validation

All limits are configurable via config.json.

Field Constraint (defaults)
url Max max_url_length (2048) chars, valid http(s)
retention_days 0max_retention_days (3650)
short_code Alphanumeric only, max max_short_length (32) chars

Security headers

Every response includes: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, X-XSS-Protection: 1; mode=block.

SQL injection prevention

All database queries use parameterized statements (? placeholders).


Nginx configuration

nginx.conf contains location blocks only — drop them into an existing server { } block. Adjust the alias paths to match your deployment layout.

When running without nginx (Docker Compose or local dev), the Python backend serves the frontend directly at the base URL. Set "production": true in config to disable backend static serving (nginx handles it).

Caching strategy

The nginx config uses a layered caching strategy designed for deployments behind Cloudflare or other CDNs:

Resource Cache-Control Why
/s/ (index.html) no-store Never cached by CDN or browser. This is the HTML entry point (~2KB) that contains ?v= cache-buster query strings for CSS/JS. Must always be fresh so that version bumps take effect immediately.
/s/static/*.css?v=… no-cache Cached but revalidated on each request. The ?v= query string acts as a cache key — Cloudflare and browsers treat each version as a distinct resource. Bump the ?v= value in index.html whenever CSS/JS files change.
/s/static/*.js?v=… no-cache Same as CSS.
/s/api/*, /s/<code> (proxied) Not cached by nginx; backend controls caching via response headers.

Updating static files

When you modify style.css or app.js:

  1. Deploy the updated files to the server
  2. Bump the ?v= value in static/index.html (e.g. ?v=20260318?v=20260319)
  3. Reload nginx (nginx -s reload)

Since index.html has no-store, browsers and Cloudflare always fetch the latest version, which in turn references the new ?v= URLs for CSS/JS — busting all downstream caches automatically. No manual CDN purge is needed.