Files
ushort/README.md
T
2026-03-18 04:41:57 +08:00

319 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 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`**
```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 '{"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` | 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. Set `"production": true` in config to disable backend static serving (nginx handles it).
### Caching strategy
The nginx config uses a layered caching strategy designed for deployments behind Cloudflare or other CDNs:
| Resource | `Cache-Control` | Why |
|---|---|---|
| `/s/` (index.html) | `no-store` | Never cached by CDN or browser. This is the HTML entry point (~2KB) that contains `?v=` cache-buster query strings for CSS/JS. Must always be fresh so that version bumps take effect immediately. |
| `/s/static/*.css?v=…` | `no-cache` | Cached but revalidated on each request. The `?v=` query string acts as a cache key — Cloudflare and browsers treat each version as a distinct resource. Bump the `?v=` value in `index.html` whenever CSS/JS files change. |
| `/s/static/*.js?v=…` | `no-cache` | Same as CSS. |
| `/s/api/*`, `/s/<code>` | (proxied) | Not cached by nginx; backend controls caching via response headers. |
### Updating static files
When you modify `style.css` or `app.js`:
1. Deploy the updated files to the server
2. Bump the `?v=` value in `static/index.html` (e.g. `?v=20260318``?v=20260319`)
3. Reload nginx (`nginx -s reload`)
Since `index.html` has `no-store`, browsers and Cloudflare always fetch the latest version, which in turn references the new `?v=` URLs for CSS/JS — busting all downstream caches automatically. No manual CDN purge is needed.