# 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/<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`**
```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/<code>`
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/<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`

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

