# URL Shortener A minimal URL shortener written in pure Python 3.8+ (zero third-party dependencies) backed by SQLite. ## Quick start ### Run locally ```bash python urlshort.py config.json ``` ### Run with Docker Compose Edit `config.json` first (especially `api_key` and `base_url`), then: ```bash 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: ```json { "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 ```bash # 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/ ──► 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= # or in JSON body {"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`** ```json { "status": "ok", "service": "url-shortener" } ``` --- ### `POST /api/shorten` 🔒 Create a new short URL. 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 | |------------------|----------|-----------------------------------------------------------| | `api_key` | ✅ | API key for authentication | | `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`** ```json { "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/` Get metadata for a single short code. No API key required. **Response `200`** ```json { "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/`. **Response `404`** — no short URL exists for this original URL. --- ### `DELETE /api/urls/` 🔒 Delete a short URL entry. **Response `204`** — success (empty body). **Response `404`** — code not found (empty body). **Response `403`** — not authorized (empty body). --- ### `GET /` Redirect to the original URL (HTTP 302). Increments `visit_count` on each hit. --- ## Example with `curl` ```bash # Shorten a URL (JSON body) curl -X POST http://localhost:8080/s/api/shorten \ -H "Content-Type: application/json" \ -d '{"api_key": "change-this-secret-key", "url": "https://github.com"}' # Shorten a URL (query parameters) curl -X POST "http://localhost:8080/s/api/shorten?api_key=change-this-secret-key&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 (requires API key) - **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` | 0–`max_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.