212 lines
7.1 KiB
Markdown
212 lines
7.1 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 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. |
|
|
|
|
For example, `base_url = "https://example.com/go/links"` limits routing to
|
|
`/go/links` and produces URLs such as
|
|
`https://example.com/go/links/aB3xYz`. Requests outside that prefix return 404.
|
|
|
|
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. 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 the existing `/s` deployment, place the location blocks from `nginx.conf`
|
|
in the public vhost. They proxy all traffic to the binary while retaining the
|
|
current `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 .
|
|
```
|
|
|
|
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.
|