app refactored
This commit is contained in:
@@ -24,34 +24,98 @@ The SQLite database is stored in `./data/urlshort.db` on the host — it survive
|
||||
|
||||
## Configuration (`config.json`)
|
||||
|
||||
| Key | Required | Default | Description |
|
||||
|----------------|----------|----------------------|----------------------------------------------|
|
||||
| `base_url` | ✅ | — | Public base URL used in generated short URLs |
|
||||
| `short_length` | ✅ | — | Character length of auto-generated codes |
|
||||
| `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 |
|
||||
| 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/<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 `/api/*` endpoints require the API key.
|
||||
Pass it as a **Bearer token** in the `Authorization` header or as a query parameter:
|
||||
All endpoints marked with 🔒 require the API key.
|
||||
Pass it as a **query parameter** or in the **JSON request body**:
|
||||
|
||||
```
|
||||
Authorization: Bearer <api_key>
|
||||
# or
|
||||
?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 /`
|
||||
Health check.
|
||||
Serves the frontend page (if `static/index.html` exists), otherwise returns health check JSON.
|
||||
|
||||
### `GET /api/health`
|
||||
Health check endpoint.
|
||||
|
||||
**Response `200`**
|
||||
```json
|
||||
@@ -63,22 +127,21 @@ Health check.
|
||||
### `POST /api/shorten` 🔒
|
||||
Create a new short URL.
|
||||
|
||||
**Request body**
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com/very/long/path",
|
||||
"custom_code": "mycode" // optional
|
||||
}
|
||||
```
|
||||
Fields can be passed as **query parameters** (URL-encoded) or in a **JSON request body**.
|
||||
Query parameters take precedence over body fields.
|
||||
|
||||
**Response `201`**
|
||||
```json
|
||||
{
|
||||
"short_code": "aB3xYz",
|
||||
"short_url": "http://localhost:8080/aB3xYz",
|
||||
"original_url": "https://example.com/very/long/path",
|
||||
"created_at": 1710000000
|
||||
}
|
||||
| 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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -93,10 +156,11 @@ List all short URLs, newest first.
|
||||
"urls": [
|
||||
{
|
||||
"short_code": "aB3xYz",
|
||||
"short_url": "http://localhost:8080/aB3xYz",
|
||||
"short_url": "http://localhost:8080/s/aB3xYz",
|
||||
"original_url": "https://example.com",
|
||||
"created_at": 1710000000,
|
||||
"visit_count": 5
|
||||
"visit_count": 5,
|
||||
"retention_days": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -104,21 +168,44 @@ List all short URLs, newest first.
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/urls/<code>` 🔒
|
||||
Get metadata for a single short code.
|
||||
### `GET /api/urls/<code>`
|
||||
Get metadata for a single short code. No API key required.
|
||||
|
||||
**Response `200`** — same shape as one item from the list above.
|
||||
**Response `404`** — code not found.
|
||||
**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/<code>`.
|
||||
**Response `404`** — no short URL exists for this original URL.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/urls/<code>` 🔒
|
||||
Delete a short URL entry.
|
||||
|
||||
**Response `200`**
|
||||
```json
|
||||
{ "message": "Deleted 'aB3xYz'" }
|
||||
```
|
||||
**Response `204`** — success (empty body).
|
||||
**Response `404`** — code not found (empty body).
|
||||
**Response `403`** — not authorized (empty body).
|
||||
|
||||
---
|
||||
|
||||
@@ -131,21 +218,81 @@ Increments `visit_count` on each hit.
|
||||
## Example with `curl`
|
||||
|
||||
```bash
|
||||
# Shorten a URL
|
||||
curl -X POST http://localhost:8080/api/shorten \
|
||||
-H "Authorization: Bearer change-this-secret-key" \
|
||||
# Shorten a URL (JSON body)
|
||||
curl -X POST http://localhost:8080/s/api/shorten \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "https://github.com"}'
|
||||
-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/aB3xYz
|
||||
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/api/urls \
|
||||
-H "Authorization: Bearer change-this-secret-key"
|
||||
curl "http://localhost:8080/s/api/urls?api_key=change-this-secret-key"
|
||||
|
||||
# Delete a URL
|
||||
curl -X DELETE http://localhost:8080/api/urls/aB3xYz \
|
||||
-H "Authorization: Bearer change-this-secret-key"
|
||||
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.
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@ I'd like to implement a URL shortener, mocking the de-facto `urlshortener` proje
|
||||
6. GET `<base_url>/<short_id>` - redirects to the original URL (302). Increments the visit count on each hit.
|
||||
|
||||
# Simple Frontend
|
||||
* Implement a clean and simple frontend, follow the same design and style requirements for the single page HTML located in the `navpage` folder.
|
||||
* Add an optional one-line API key input field to top left corner, so that admin can access to the restricted APIs.
|
||||
* Implement a clean and simple frontend, follow the same design and style requirements for the single page HTML located in the `navpage` folder, as described in its TASK md file.
|
||||
* Add an optional one-line API key input field to top right corner, so that admin can access to the restricted APIs.
|
||||
* There is a long search input bar on top center of the page, where user can input a URL to shorten once enter is hit. if shortening is successful, show all its metadata below in a table at center of the page below the search bar;
|
||||
* If the input URL is existing (even without hitting enter), also show all its metadata in the table.
|
||||
* If the input URL is malformed on hitting enter, display nothing below the search bar.
|
||||
|
||||
+7
-2
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"base_url": "http://localhost:8080/s",
|
||||
"short_length": 6,
|
||||
"api_key": "change-this-secret-key",
|
||||
"host": "0.0.0.0",
|
||||
"port": 8080,
|
||||
"db_path": "data/urlshort.db",
|
||||
"retention_days": 0
|
||||
"retention_days": 0,
|
||||
"min_short_length": 6,
|
||||
"max_short_length": 32,
|
||||
"max_url_length": 2048,
|
||||
"max_retention_days": 3650,
|
||||
"rate_limit_requests": 60,
|
||||
"rate_limit_window": 60
|
||||
}
|
||||
|
||||
|
||||
+264
-41
@@ -4,24 +4,43 @@ URL Shortener — stdlib-only, Python 3.8+
|
||||
Usage: python urlshort.py <config.json>
|
||||
|
||||
API (all routes are prefixed with base_path, e.g. /s):
|
||||
GET / Health check
|
||||
GET / Frontend (or health check if no static/)
|
||||
GET /api/health Health check
|
||||
POST /api/shorten Create a short URL (API key required)
|
||||
GET /api/urls List all short URLs (API key required)
|
||||
GET /api/urls/<code> Get info for a code (no API key required)
|
||||
GET /api/lookup?url=<url> Look up by original URL (no API key required)
|
||||
DELETE /api/urls/<code> Delete a short URL (API key required)
|
||||
GET /<code> Redirect to original URL
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sqlite3
|
||||
import string
|
||||
import sys
|
||||
import time
|
||||
import collections
|
||||
import mimetypes
|
||||
import re
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging — outputs to console (stdout); Docker captures it automatically.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
log = logging.getLogger("urlshort")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
@@ -31,19 +50,26 @@ def load_config(path: str) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
cfg = json.load(fh)
|
||||
|
||||
for key in ("base_url", "short_length", "api_key"):
|
||||
for key in ("base_url", "api_key"):
|
||||
if key not in cfg:
|
||||
raise ValueError(f"Missing required config key: '{key}'")
|
||||
|
||||
# Backward compat: old 'short_length' → 'min_short_length'
|
||||
if "short_length" in cfg and "min_short_length" not in cfg:
|
||||
cfg["min_short_length"] = cfg["short_length"]
|
||||
|
||||
cfg.setdefault("host", "0.0.0.0")
|
||||
cfg.setdefault("port", 8080)
|
||||
cfg.setdefault("db_path", "data/urlshort.db")
|
||||
cfg.setdefault("retention_days", 0)
|
||||
cfg.setdefault("min_short_length", 6)
|
||||
cfg.setdefault("max_short_length", 32)
|
||||
cfg.setdefault("max_url_length", 2048)
|
||||
cfg.setdefault("max_retention_days", 3650)
|
||||
cfg.setdefault("rate_limit_requests", 60)
|
||||
cfg.setdefault("rate_limit_window", 60)
|
||||
|
||||
# Derive base_path from the path component of base_url.
|
||||
# e.g. "http://example.com/s" → "/s"
|
||||
# "http://example.com/s/" → "/s"
|
||||
# "http://example.com" → ""
|
||||
raw = urlparse(cfg["base_url"]).path.strip("/")
|
||||
cfg["base_path"] = f"/{raw}" if raw else ""
|
||||
|
||||
@@ -118,6 +144,53 @@ def is_valid_url(url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CODE_RE = re.compile(r'^[A-Za-z0-9]+$')
|
||||
|
||||
|
||||
def is_valid_code(code: str, max_length: int = 32) -> bool:
|
||||
"""Short codes must be alphanumeric and within length limits."""
|
||||
return bool(code) and len(code) <= max_length and bool(_CODE_RE.match(code))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static file serving
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limiter (sliding window, per IP)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RateLimiter:
|
||||
"""Simple in-memory sliding-window rate limiter."""
|
||||
|
||||
def __init__(self, max_requests: int = 60, window: int = 60):
|
||||
self.max_requests = max_requests
|
||||
self.window = window
|
||||
self._hits: dict = collections.defaultdict(list)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def is_allowed(self, ip: str) -> bool:
|
||||
now = time.time()
|
||||
cutoff = now - self.window
|
||||
with self._lock:
|
||||
hits = self._hits[ip]
|
||||
self._hits[ip] = hits = [t for t in hits if t > cutoff]
|
||||
if len(hits) >= self.max_requests:
|
||||
return False
|
||||
hits.append(now)
|
||||
return True
|
||||
|
||||
|
||||
_rate_limiter = RateLimiter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP Handler
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -132,6 +205,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# Routing helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _client_ip(self) -> str:
|
||||
"""Get the real client IP, checking reverse-proxy headers first.
|
||||
|
||||
nginx adds X-Real-IP and X-Forwarded-For via proxy_set_header.
|
||||
Without these headers, falls back to the TCP connection source.
|
||||
"""
|
||||
ip = self.headers.get("X-Real-IP", "").strip()
|
||||
if ip:
|
||||
return ip
|
||||
xff = self.headers.get("X-Forwarded-For", "").strip()
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return self.client_address[0]
|
||||
|
||||
def _local_path(self):
|
||||
"""Return the request path with base_path prefix stripped.
|
||||
|
||||
@@ -155,11 +242,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def log_message(self, fmt, *args): # noqa: N802 – stdlib override
|
||||
sys.stderr.write(
|
||||
f"[{self.log_date_time_string()}] {self.address_string()} - "
|
||||
+ (fmt % args)
|
||||
+ "\n"
|
||||
)
|
||||
log.info("%s - %s", self._client_ip(), fmt % args)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Low-level response helpers
|
||||
@@ -195,6 +278,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CORS & Security headers (injected into every response)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.send_header("Access-Control-Max-Age", "86400")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("X-XSS-Protection", "1; mode=block")
|
||||
super().end_headers()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auth — purely api_key based (query param or JSON body)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -232,12 +329,31 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self): # noqa: N802
|
||||
cleanup_expired(self.cfg["db_path"])
|
||||
|
||||
if not _rate_limiter.is_allowed(self._client_ip()):
|
||||
self._error(429, "Too many requests")
|
||||
return
|
||||
|
||||
# Redirect bare base path to base path + / for correct relative URLs
|
||||
raw = urlparse(self.path).path
|
||||
base = self.cfg.get("base_path", "")
|
||||
if base and raw == base:
|
||||
self._redirect(base + "/")
|
||||
return
|
||||
|
||||
path = self._local_path()
|
||||
if path is None:
|
||||
self._send_empty(404)
|
||||
return
|
||||
|
||||
if path == "/":
|
||||
# Serve frontend if static/index.html exists, else health check
|
||||
index_path = os.path.join(STATIC_DIR, "index.html")
|
||||
if os.path.isfile(index_path):
|
||||
self._serve_static("index.html")
|
||||
else:
|
||||
self._send_json(200, {"status": "ok", "service": "url-shortener"})
|
||||
|
||||
elif path == "/api/health":
|
||||
self._send_json(200, {"status": "ok", "service": "url-shortener"})
|
||||
|
||||
elif path == "/api/urls":
|
||||
@@ -247,6 +363,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
code = path[len("/api/urls/"):]
|
||||
self._handle_get_url(code)
|
||||
|
||||
elif path == "/api/lookup":
|
||||
self._handle_lookup()
|
||||
|
||||
elif path == "/static" or path.startswith("/static/"):
|
||||
rel = path[len("/static"):].lstrip("/") or "index.html"
|
||||
self._serve_static(rel)
|
||||
|
||||
else:
|
||||
code = path.lstrip("/")
|
||||
self._handle_redirect(code)
|
||||
@@ -254,6 +377,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self): # noqa: N802
|
||||
cleanup_expired(self.cfg["db_path"])
|
||||
|
||||
if not _rate_limiter.is_allowed(self._client_ip()):
|
||||
self._error(429, "Too many requests")
|
||||
return
|
||||
|
||||
path = self._local_path()
|
||||
if path is None:
|
||||
self._send_empty(404)
|
||||
@@ -267,6 +394,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_DELETE(self): # noqa: N802
|
||||
cleanup_expired(self.cfg["db_path"])
|
||||
|
||||
if not _rate_limiter.is_allowed(self._client_ip()):
|
||||
self._error(429, "Too many requests")
|
||||
return
|
||||
|
||||
path = self._local_path()
|
||||
if path is None:
|
||||
self._send_empty(404)
|
||||
@@ -278,6 +409,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._send_empty(404)
|
||||
|
||||
def do_OPTIONS(self): # noqa: N802
|
||||
"""Handle CORS preflight requests."""
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.send_header("Allow", "GET, POST, DELETE, OPTIONS")
|
||||
self.end_headers()
|
||||
self.wfile.flush()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Handlers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -303,19 +442,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not is_valid_url(original_url):
|
||||
self._error(400, "Invalid URL — must start with http:// or https://")
|
||||
return
|
||||
|
||||
# short_length: query param, then body, then config default
|
||||
raw_sl = qs.get("short_length", [None])[0]
|
||||
if raw_sl is None:
|
||||
raw_sl = body.get("short_length")
|
||||
if raw_sl is not None:
|
||||
try:
|
||||
short_length = int(raw_sl)
|
||||
except (ValueError, TypeError):
|
||||
self._error(400, "Invalid short_length")
|
||||
return
|
||||
else:
|
||||
short_length = self.cfg["short_length"]
|
||||
max_url = self.cfg["max_url_length"]
|
||||
if len(original_url) > max_url:
|
||||
self._error(400, f"URL too long (max {max_url} characters)")
|
||||
return
|
||||
|
||||
# retention_days: query param, then body, then config default
|
||||
raw_rd = qs.get("retention_days", [None])[0]
|
||||
@@ -330,8 +460,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
retention_days = self.cfg.get("retention_days", 0)
|
||||
|
||||
max_ret = self.cfg["max_retention_days"]
|
||||
if not (0 <= retention_days <= max_ret):
|
||||
self._error(400, f"retention_days must be 0–{max_ret}")
|
||||
return
|
||||
|
||||
with db_connect(self.cfg["db_path"]) as conn:
|
||||
short_code = self._unique_code(conn, short_length)
|
||||
short_code = self._unique_code(conn)
|
||||
if short_code is None:
|
||||
self._error(500, "Could not generate a unique short code — try again")
|
||||
return
|
||||
@@ -367,11 +502,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"original_url": r["original_url"],
|
||||
"created_at": r["created_at"],
|
||||
"visit_count": r["visit_count"],
|
||||
"retention_days": r["retention_days"],
|
||||
})
|
||||
self._send_json(200, {"count": len(urls), "urls": urls})
|
||||
|
||||
def _handle_get_url(self, code: str) -> None:
|
||||
# No API key required
|
||||
if not is_valid_code(code, self.cfg["max_short_length"]):
|
||||
self._send_empty(404)
|
||||
return
|
||||
|
||||
with db_connect(self.cfg["db_path"]) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM urls WHERE short_code = ?", (code,)
|
||||
@@ -388,6 +528,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"original_url": row["original_url"],
|
||||
"created_at": row["created_at"],
|
||||
"visit_count": row["visit_count"],
|
||||
"retention_days": row["retention_days"],
|
||||
})
|
||||
|
||||
def _handle_delete_url(self, code: str) -> None:
|
||||
@@ -395,6 +536,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not self._check_api_key():
|
||||
self._send_empty(403)
|
||||
return
|
||||
if not is_valid_code(code, self.cfg["max_short_length"]):
|
||||
self._send_empty(404)
|
||||
return
|
||||
|
||||
with db_connect(self.cfg["db_path"]) as conn:
|
||||
row = conn.execute(
|
||||
@@ -411,6 +555,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._send_empty(204)
|
||||
|
||||
def _handle_redirect(self, code: str) -> None:
|
||||
if not is_valid_code(code, self.cfg["max_short_length"]):
|
||||
self._send_empty(404)
|
||||
return
|
||||
|
||||
with db_connect(self.cfg["db_path"]) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT original_url FROM urls WHERE short_code = ?", (code,)
|
||||
@@ -426,20 +574,85 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
self._redirect(row["original_url"])
|
||||
|
||||
def _handle_lookup(self) -> None:
|
||||
"""Look up a URL by its original URL. No API key required."""
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
url = qs.get("url", [""])[0].strip()
|
||||
if not url:
|
||||
self._error(400, "Missing required parameter: url")
|
||||
return
|
||||
max_url = self.cfg["max_url_length"]
|
||||
if len(url) > max_url:
|
||||
self._error(400, f"URL too long (max {max_url} characters)")
|
||||
return
|
||||
|
||||
with db_connect(self.cfg["db_path"]) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM urls WHERE original_url = ? ORDER BY created_at DESC LIMIT 1",
|
||||
(url,),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
self._send_empty(404)
|
||||
return
|
||||
|
||||
base = self.cfg["base_url"].rstrip("/")
|
||||
self._send_json(200, {
|
||||
"short_code": row["short_code"],
|
||||
"short_url": f"{base}/{row['short_code']}",
|
||||
"original_url": row["original_url"],
|
||||
"created_at": row["created_at"],
|
||||
"visit_count": row["visit_count"],
|
||||
"retention_days": row["retention_days"],
|
||||
})
|
||||
|
||||
def _serve_static(self, rel_path: str) -> None:
|
||||
"""Serve a static file from STATIC_DIR (for local dev; nginx in prod)."""
|
||||
if not rel_path:
|
||||
rel_path = "index.html"
|
||||
# Prevent directory traversal
|
||||
safe = os.path.normpath(rel_path)
|
||||
if safe.startswith("..") or os.path.isabs(safe):
|
||||
self._send_empty(403)
|
||||
return
|
||||
fpath = os.path.join(STATIC_DIR, safe)
|
||||
if not os.path.isfile(fpath):
|
||||
self._send_empty(404)
|
||||
return
|
||||
mime, _ = mimetypes.guess_type(fpath)
|
||||
if not mime:
|
||||
mime = "application/octet-stream"
|
||||
with open(fpath, "rb") as f:
|
||||
data = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", mime)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "public, max-age=3600")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal utils
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _unique_code(self, conn: sqlite3.Connection, length: int = None, attempts: int = 10):
|
||||
if length is None:
|
||||
length = self.cfg["short_length"]
|
||||
for _ in range(attempts):
|
||||
code = generate_code(length)
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM urls WHERE short_code = ?", (code,)
|
||||
).fetchone()
|
||||
if not exists:
|
||||
return code
|
||||
def _unique_code(self, conn: sqlite3.Connection, attempts_per_length: int = 10):
|
||||
"""Generate a unique short code with progressive length increment.
|
||||
|
||||
Starts at min_short_length and tries `attempts_per_length` random
|
||||
codes at each length. On exhaustion, increments the length by 1
|
||||
and repeats, up to max_short_length. Returns None only when the
|
||||
entire range is exhausted (extremely unlikely).
|
||||
"""
|
||||
min_len = self.cfg["min_short_length"]
|
||||
max_len = self.cfg["max_short_length"]
|
||||
for length in range(min_len, max_len + 1):
|
||||
for _ in range(attempts_per_length):
|
||||
code = generate_code(length)
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM urls WHERE short_code = ?", (code,)
|
||||
).fetchone()
|
||||
if not exists:
|
||||
return code
|
||||
return None
|
||||
|
||||
|
||||
@@ -448,29 +661,39 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
global _rate_limiter
|
||||
|
||||
if len(sys.argv) != 2:
|
||||
print(f"Usage: {sys.argv[0]} <config.json>", file=sys.stderr)
|
||||
log.error("Usage: %s <config.json>", sys.argv[0])
|
||||
sys.exit(1)
|
||||
|
||||
cfg = load_config(sys.argv[1])
|
||||
init_db(cfg["db_path"])
|
||||
cleanup_expired(cfg["db_path"])
|
||||
|
||||
# Re-initialize rate limiter with config values
|
||||
_rate_limiter = RateLimiter(
|
||||
max_requests=cfg["rate_limit_requests"],
|
||||
window=cfg["rate_limit_window"],
|
||||
)
|
||||
|
||||
Handler.cfg = cfg
|
||||
|
||||
host, port = cfg["host"], int(cfg["port"])
|
||||
server = HTTPServer((host, port), Handler)
|
||||
|
||||
print(f"URL Shortener listening on http://{host}:{port}", file=sys.stderr)
|
||||
print(f"Base URL : {cfg['base_url']}", file=sys.stderr)
|
||||
print(f"Base path : {cfg['base_path'] or '/'}", file=sys.stderr)
|
||||
print(f"DB path : {cfg['db_path']}", file=sys.stderr)
|
||||
print(f"Retention days : {cfg['retention_days']}", file=sys.stderr)
|
||||
log.info("URL Shortener listening on http://%s:%s", host, port)
|
||||
log.info("Base URL : %s", cfg["base_url"])
|
||||
log.info("Base path : %s", cfg["base_path"] or "/")
|
||||
log.info("DB path : %s", cfg["db_path"])
|
||||
log.info("Short codes : %s–%s chars", cfg["min_short_length"], cfg["max_short_length"])
|
||||
log.info("Retention days : %s", cfg["retention_days"])
|
||||
log.info("Rate limit : %s req/%ss per IP", cfg["rate_limit_requests"], cfg["rate_limit_window"])
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down.", file=sys.stderr)
|
||||
log.info("Shutting down.")
|
||||
server.shutdown()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user