Files
ushort/README.md
T

10 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.