Files
ushort/README.md
T

235 lines
8.0 KiB
Markdown

# ushort
`ushort` is a compact URL shortener written in Rust and backed by SQLite. It is
a behavior-compatible replacement for the original stdlib-only Python service:
the routes, base-path handling, response shapes, database schema, retention,
API-key checks, throttling, redirects, and frontend are preserved.
The frontend under `static/` is embedded into the executable at compile time.
The final Linux image contains only one statically linked executable and runs
without Python, a shell, a package manager, or external static files.
## Quick start
```bash
cp config.example.toml config.toml
# Edit base_url and api_key, then:
docker compose up --build -d
```
The root `docker-compose.yml` is the local-development definition and builds
the checked-out source. `deploy/compose.production.yml` is the source-free
server definition and pulls a versioned image; copy it to the deployment host
as `docker-compose.yml`.
The service listens on `127.0.0.1:18082` on the host. Its SQLite database is
stored at `./data/urlshort.db` and survives container replacement.
The container runs as UID/GID `1001:1001` by default. Override
`USHORT_UID`/`USHORT_GID` if needed, and ensure `./data` is writable by that
identity.
## Configuration
TOML is the preferred format; see `config.example.toml`. Existing JSON files
remain supported, including the old `short_length` alias for
`min_short_length`.
```bash
ushort config.toml
ushort legacy-config.json
```
| Key | Required | Default | Description |
|---|---:|---:|---|
| `base_url` | yes | — | Public URL. Its path becomes the routing prefix. |
| `api_key` | yes | — | Secret used by list and delete operations. |
| `host` | no | `0.0.0.0` | Bind address. |
| `port` | no | `8080` | Bind port. |
| `db_path` | no | `data/urlshort.db` | SQLite database path. |
| `retention_days` | no | `0` | Default lifetime for new URLs; zero never expires. |
| `min_short_length` | no | `6` | Initial generated code length. |
| `max_short_length` | no | `32` | Maximum generated and accepted code length. |
| `max_url_length` | no | `2048` | Maximum original URL length. |
| `max_retention_days` | no | `3650` | Maximum per-URL retention value. |
| `rate_limit_requests` | no | `60` | Requests allowed per client/window. |
| `rate_limit_window` | no | `60` | Sliding-window length in seconds. |
| `production` | no | `false` | When true, disable embedded frontend routes for compatibility with deployments that serve static files separately. |
The same binary supports both common reverse-proxy layouts:
- `base_url = "https://s.example.com"` serves the frontend, API, and short
codes at the subdomain root, producing `https://s.example.com/aB3xYz`.
- `base_url = "https://example.com/go/links"` limits routing to `/go/links`
and produces `https://example.com/go/links/aB3xYz`. Requests outside that
prefix return 404.
A trailing slash on `base_url` is accepted in either layout and is normalized
when short URLs are generated.
Real API keys belong only in the deployment's ignored `config.toml`, never in
the repository or container image.
## API
All paths below are relative to the path in `base_url`. Trailing slashes are
accepted by every frontend, API, metadata, deletion, and short-code route,
including when a query string follows the slash. Every response includes the
legacy CORS and security headers.
### Health and frontend
- `GET /` serves the byte-identical embedded frontend when `production=false`.
- `GET /api/health` returns HTTP 200:
```json
{"status":"ok","service":"url-shortener"}
```
When the configured base path is non-empty, a GET of the bare path (for
example `/s`) redirects to `/s/`.
### Create a short URL
`POST /api/shorten` and `GET /api/shorten` are both supported and do not
require an API key. Fields may be supplied as query parameters or in a JSON
body; query parameters take precedence.
| Field | Required | Description |
|---|---:|---|
| `url` | yes | Absolute `http://` or `https://` URL. |
| `retention_days` | no | Per-URL lifetime, or zero to keep forever. |
Success is HTTP 201 with only the short URL as UTF-8 plain text:
```text
https://example.com/s/aB3xYz
```
Codes begin at `min_short_length`. After ten collisions at one length the
service tries the next length, through `max_short_length`.
### List URLs
`GET /api/urls?api_key=<key>` returns all rows newest first. A missing or
invalid key returns HTTP 403 with an empty body.
```json
{
"count": 1,
"urls": [
{
"short_code": "aB3xYz",
"short_url": "https://example.com/s/aB3xYz",
"original_url": "https://example.org",
"created_at": 1710000000,
"visit_count": 5,
"retention_days": 30
}
]
}
```
### Metadata and lookup
- `GET /api/urls/<code>` returns one metadata object without authentication.
- `GET /api/lookup?url=<encoded-url>` returns the newest matching metadata
object without authentication.
- A missing record returns HTTP 404 with an empty body.
### Delete
`DELETE /api/urls/<code>?api_key=<key>` returns:
- HTTP 204 with an empty body on success;
- HTTP 404 with an empty body when the code does not exist; or
- HTTP 403 with an empty body when authentication fails.
### Redirect
`GET /<code>` atomically increments `visit_count` and responds with HTTP 302 to
the stored original URL.
## Data compatibility
The existing database can be mounted directly; no export/import is required.
The schema remains:
```sql
CREATE TABLE 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
);
```
Older databases missing `retention_days` are upgraded in place with a default
of zero. Expired records are removed on startup and before each GET, POST, or
DELETE request, using the original strict expiry boundary.
Before a production cutover, make a filesystem-consistent backup of the
database (or use SQLite's backup command) and test the copy with the new image.
## Frontend
The existing `index.html`, JavaScript, CSS, locally hosted fonts, sorting,
pagination, theme selection, URL lookup, copy/delete actions, and admin flow
are unchanged. Compile-time embedding makes those files part of the standalone
artifact; no bind mount or separate web root is needed.
For a path deployment such as the existing `/s`, place the location blocks
from `nginx.conf` in the public vhost. For a dedicated subdomain, use
`deploy/nginx-subdomain.conf` in its `server` block. Both examples proxy the
frontend, API, and redirects while retaining `no-store` HTML and `no-cache`
asset policies.
## Build and test
The repository intentionally does not require a host Rust installation.
```bash
docker build -t ushort:local .
docker compose up -d
```
Run the Rust test suite in an isolated build stage:
```bash
docker build --target tester .
```
Run the real HTTP trailing-slash smoke test against any disposable deployment:
```bash
sh tests/smoke.sh https://s.example.com "$API_KEY"
sh tests/smoke.sh https://example.com/s "$API_KEY"
```
The Dockerfile builds against musl and uses `scratch` for the runtime image.
Docker Buildx can publish both required architectures from the same source:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t sodium/ushort:latest \
--push .
```
## Security model
- SQL statements use bound parameters.
- Short codes are restricted to ASCII alphanumerics.
- URL, retention, code, and request-body sizes are bounded.
- API-key comparison is constant-time; protected operations return no body on
authentication failure.
- Per-IP sliding-window throttling applies to API calls and redirects; embedded
frontend files do not consume API quota. The app uses `X-Real-IP`, then the
first `X-Forwarded-For` value, then the TCP peer. Keep the container port
bound to loopback and let the trusted reverse proxy overwrite those headers.
- CORS preflight uses `OPTIONS`; it does not consume rate-limit quota.
- The runtime image is read-only, drops Linux capabilities, and runs as a
numeric non-root user through Compose.
- `SIGINT`/`SIGTERM` cleanly unblock the server for prompt container shutdown.