#!/usr/bin/env python3 """ URL Shortener — stdlib-only, Python 3.8+ Usage: python urlshort.py API (all routes are prefixed with base_path, e.g. /s): GET / 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/ Get info for a code (no API key required) DELETE /api/urls/ Delete a short URL (API key required) GET / Redirect to original URL """ import json import os import random import sqlite3 import string import sys import time from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import parse_qs, urlparse # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- 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"): if key not in cfg: raise ValueError(f"Missing required config key: '{key}'") cfg.setdefault("host", "0.0.0.0") cfg.setdefault("port", 8080) cfg.setdefault("db_path", "data/urlshort.db") cfg.setdefault("retention_days", 0) # 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 "" return cfg # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- def init_db(db_path: str) -> None: parent = os.path.dirname(db_path) if parent: os.makedirs(parent, exist_ok=True) with sqlite3.connect(db_path) as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS urls ( short_code TEXT PRIMARY KEY, original_url TEXT NOT NULL, created_at INTEGER NOT NULL, visit_count INTEGER NOT NULL DEFAULT 0, retention_days INTEGER NOT NULL DEFAULT 0 ) """) conn.commit() # Migrate: add retention_days column if missing (existing DB). try: conn.execute( "ALTER TABLE urls ADD COLUMN retention_days INTEGER NOT NULL DEFAULT 0" ) conn.commit() except sqlite3.OperationalError: pass # column already exists def db_connect(db_path: str) -> sqlite3.Connection: conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row return conn def cleanup_expired(db_path: str) -> None: """Delete URLs whose retention period has elapsed.""" now = int(time.time()) with db_connect(db_path) as conn: conn.execute( "DELETE FROM urls WHERE retention_days > 0 " "AND (created_at + retention_days * 86400) < ?", (now,), ) conn.commit() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _CHARS = string.ascii_letters + string.digits def generate_code(length: int) -> str: return "".join(random.choices(_CHARS, k=length)) def is_valid_url(url: str) -> bool: try: p = urlparse(url) return p.scheme in ("http", "https") and bool(p.netloc) except Exception: return False # --------------------------------------------------------------------------- # HTTP Handler # --------------------------------------------------------------------------- class Handler(BaseHTTPRequestHandler): """Single handler that serves the whole URL shortener API.""" # Injected by main() before the server starts. cfg: dict = {} # ------------------------------------------------------------------ # Routing helpers # ------------------------------------------------------------------ def _local_path(self): """Return the request path with base_path prefix stripped. Returns None (→ 404) when the request path does not start with the configured base_path at all. """ raw = urlparse(self.path).path base = self.cfg.get("base_path", "") if base: if raw == base or raw == base + "/": # exact match on the prefix itself → treat as root return "/" if raw.startswith(base + "/"): return raw[len(base):].rstrip("/") or "/" # path is outside our prefix entirely return None return raw.rstrip("/") or "/" # ------------------------------------------------------------------ # Logging # ------------------------------------------------------------------ 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" ) # ------------------------------------------------------------------ # Low-level response helpers # ------------------------------------------------------------------ def _send_json(self, status: int, payload: object) -> None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _send_plain(self, status: int, text: str) -> None: body = text.encode("utf-8") self.send_response(status) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _send_empty(self, status: int) -> None: self.send_response(status) self.send_header("Content-Length", "0") self.end_headers() def _error(self, status: int, message: str) -> None: self._send_json(status, {"error": message}) def _redirect(self, location: str) -> None: self.send_response(302) self.send_header("Location", location) self.send_header("Content-Length", "0") self.end_headers() # ------------------------------------------------------------------ # Auth — purely api_key based (query param or JSON body) # ------------------------------------------------------------------ def _check_api_key(self, body: dict = None) -> bool: """Check api_key from query parameter or JSON body.""" qs = parse_qs(urlparse(self.path).query) key = qs.get("api_key", [""])[0] if key: return key == self.cfg["api_key"] if body and isinstance(body, dict): key = str(body.get("api_key", "")) if key: return key == self.cfg["api_key"] return False # ------------------------------------------------------------------ # Body # ------------------------------------------------------------------ def _read_json(self): length = int(self.headers.get("Content-Length", 0)) if length == 0: return {} raw = self.rfile.read(length) try: return json.loads(raw) except json.JSONDecodeError: return None # signals parse failure to caller # ------------------------------------------------------------------ # Route dispatch # ------------------------------------------------------------------ def do_GET(self): # noqa: N802 cleanup_expired(self.cfg["db_path"]) path = self._local_path() if path is None: self._send_empty(404) return if path == "/": self._send_json(200, {"status": "ok", "service": "url-shortener"}) elif path == "/api/urls": self._handle_list_urls() elif path.startswith("/api/urls/"): code = path[len("/api/urls/"):] self._handle_get_url(code) else: code = path.lstrip("/") self._handle_redirect(code) def do_POST(self): # noqa: N802 cleanup_expired(self.cfg["db_path"]) path = self._local_path() if path is None: self._send_empty(404) return if path == "/api/shorten": self._handle_shorten() else: self._send_empty(404) def do_DELETE(self): # noqa: N802 cleanup_expired(self.cfg["db_path"]) path = self._local_path() if path is None: self._send_empty(404) return if path.startswith("/api/urls/"): code = path[len("/api/urls/"):] self._handle_delete_url(code) else: self._send_empty(404) # ------------------------------------------------------------------ # Handlers # ------------------------------------------------------------------ def _handle_shorten(self) -> None: # Parse both query parameters and JSON body qs = parse_qs(urlparse(self.path).query) body = self._read_json() if body is None: self._error(400, "Invalid JSON body") return # Auth: check api_key from query param or body if not self._check_api_key(body): self._send_empty(403) return # url: query param takes precedence, then body original_url = qs.get("url", [""])[0] or str(body.get("url", "")).strip() if not original_url: self._error(400, "Missing required field: url") return 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"] # retention_days: query param, then body, then config default raw_rd = qs.get("retention_days", [None])[0] if raw_rd is None: raw_rd = body.get("retention_days") if raw_rd is not None: try: retention_days = int(raw_rd) except (ValueError, TypeError): self._error(400, "Invalid retention_days") return else: retention_days = self.cfg.get("retention_days", 0) with db_connect(self.cfg["db_path"]) as conn: short_code = self._unique_code(conn, short_length) if short_code is None: self._error(500, "Could not generate a unique short code — try again") return created_at = int(time.time()) conn.execute( "INSERT INTO urls (short_code, original_url, created_at, retention_days) " "VALUES (?, ?, ?, ?)", (short_code, original_url, created_at, retention_days), ) conn.commit() short_url = f"{self.cfg['base_url'].rstrip('/')}/{short_code}" # Response: only the short URL in plain text self._send_plain(201, short_url) def _handle_list_urls(self) -> None: if not self._check_api_key(): self._send_empty(403) return with db_connect(self.cfg["db_path"]) as conn: rows = conn.execute( "SELECT * FROM urls ORDER BY created_at DESC" ).fetchall() base = self.cfg["base_url"].rstrip("/") urls = [] for r in rows: urls.append({ "short_code": r["short_code"], "short_url": f"{base}/{r['short_code']}", "original_url": r["original_url"], "created_at": r["created_at"], "visit_count": r["visit_count"], }) self._send_json(200, {"count": len(urls), "urls": urls}) def _handle_get_url(self, code: str) -> None: # No API key required with db_connect(self.cfg["db_path"]) as conn: row = conn.execute( "SELECT * FROM urls WHERE short_code = ?", (code,) ).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"], }) def _handle_delete_url(self, code: str) -> None: # API key required — 403 if not authorized (no body) if not self._check_api_key(): self._send_empty(403) return with db_connect(self.cfg["db_path"]) as conn: row = conn.execute( "SELECT short_code FROM urls WHERE short_code = ?", (code,) ).fetchone() if row is None: # 404 if not found (no body) self._send_empty(404) return conn.execute("DELETE FROM urls WHERE short_code = ?", (code,)) conn.commit() # 204 on success (no body) self._send_empty(204) def _handle_redirect(self, code: str) -> None: with db_connect(self.cfg["db_path"]) as conn: row = conn.execute( "SELECT original_url FROM urls WHERE short_code = ?", (code,) ).fetchone() if row is None: self._error(404, "Short code not found") return conn.execute( "UPDATE urls SET visit_count = visit_count + 1 WHERE short_code = ?", (code,), ) conn.commit() self._redirect(row["original_url"]) # ------------------------------------------------------------------ # 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 return None # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main() -> None: if len(sys.argv) != 2: print(f"Usage: {sys.argv[0]} ", file=sys.stderr) sys.exit(1) cfg = load_config(sys.argv[1]) init_db(cfg["db_path"]) cleanup_expired(cfg["db_path"]) 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) try: server.serve_forever() except KeyboardInterrupt: print("\nShutting down.", file=sys.stderr) server.shutdown() if __name__ == "__main__": main()