4 Commits
19 changed files with 629 additions and 407 deletions
Generated
+1 -1
View File
@@ -709,7 +709,7 @@ dependencies = [
[[package]]
name = "ushort"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"ctrlc",
"include_dir",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ushort"
version = "0.1.0"
version = "0.1.2"
edition = "2021"
rust-version = "1.86"
description = "A compact, self-contained URL shortener"
-140
View File
@@ -1,140 +0,0 @@
# Production migration and rollback
This runbook moves the titan deployment from the Python source tree at
`/root/repo/urlshortener` to the published `sodium/ushort` image. The final
server-side application directory is `/root/compose/ushort`; no source checkout
is required on titan.
Do not cut over until the exact image tag has been built for both `linux/amd64`
and `linux/arm64`, pushed, and recorded by digest.
## 1. Prepare without affecting production
Create this layout on titan:
```text
/root/compose/ushort/
├── docker-compose.yml
├── config.toml
└── data/
```
Copy `deploy/docker-compose.yml` as the Compose file. Convert the live JSON
settings to TOML using `config.example.toml` as the reference, with these
cutover-specific rules:
- keep the live `base_url`, limits, retention, and rate-limit values;
- set `db_path = "data/urlshort.db"`;
- set `production = false` so the binary serves its embedded frontend; and
- generate a new API key, because the old key existed in the legacy Git
history.
Keep `config.toml` owned by `1001:1001` with mode `0400`, matching the
container identity. Do not copy it into Git or a container image.
Validate the deployment file before stopping anything:
```bash
cd /root/compose/ushort
chown 1001:1001 config.toml
chmod 0400 config.toml
docker-compose config
docker pull sodium/ushort:0.1.0
```
## 2. Take a consistent database copy
The final copy must be made while the Python service is stopped so no committed
row is missed and no SQLite journal is in flight:
```bash
cd /root/repo/urlshortener
docker-compose stop urlshort
cp -a data/urlshort.db /root/compose/ushort/data/urlshort.db
cp -a data/urlshort.db /root/compose/ushort/data/urlshort.db.pre-rust
chown -R 1001:1001 /root/compose/ushort/data
chmod 0750 /root/compose/ushort/data
chmod 0640 /root/compose/ushort/data/urlshort.db*
```
Do not delete or alter the original database during the initial soak period.
## 3. Start and validate ushort locally
```bash
cd /root/compose/ushort
docker-compose up -d
docker-compose ps
docker-compose logs --tail=100 ushort
curl -i http://127.0.0.1:18082/s/api/health
curl -I http://127.0.0.1:18082/s/
```
Expected results are HTTP 200 health JSON and HTTP 200 HTML. Confirm the
container does not restart and the database remains writable.
## 4. Switch nginx
Replace the old `include /root/repo/urlshortener/nginx.conf;` in the `xcel.me`
vhost with the location blocks from the new `nginx.conf`. Those blocks proxy
the frontend as well as API/redirect traffic to `127.0.0.1:18082`; they do not
refer to a source or static-file directory.
Test before reload:
```bash
docker exec nginx nginx -t
docker exec nginx nginx -s reload
```
Then validate through the public endpoint:
```bash
curl -i https://xcel.me/s/api/health
curl -I https://xcel.me/s/
curl -I https://xcel.me/s/static/app.js
```
Check that `/s/` is `no-store`, static assets are `no-cache`, an existing short
code still returns the original 302 target, and its visit count increments once.
## 5. Soak and clean up
During the soak period, monitor:
```bash
cd /root/compose/ushort
docker-compose ps
docker-compose logs --tail=200 ushort
```
After the rollback window closes:
1. retain a protected database backup;
2. remove the obsolete `/root/repo/urlshortener` source tree;
3. remove its `/srv/urlshortener` bind mount from the global nginx Compose
file; and
4. recreate nginx and re-run `nginx -t`.
At that point, application source exists only in Gitea, while titan contains
only the Compose file, TOML config, and data under `/root/compose/ushort`.
## Rollback
If validation fails before public traffic is enabled:
```bash
cd /root/compose/ushort
docker-compose down
cd /root/repo/urlshortener
docker-compose start urlshort
```
Restore the old nginx include and reload nginx.
If public writes occurred after cutover, stop ushort first and copy its current
database back to the legacy data path before starting Python; otherwise those
new short URLs would be lost. The schema is deliberately identical, so no
reverse schema migration is required.
+38 -188
View File
@@ -1,211 +1,61 @@
# 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.
`ushort` is a compact, self-contained URL shortener written in Rust and backed
by SQLite. It serves an embedded web interface and JSON API from a single
statically linked executable.
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.
It supports deployment at either a dedicated host such as `s.example.com` or
under a path such as `example.com/s`. Images are available for Linux AMD64 and
ARM64.
## Quick start
Requirements: Docker with the Compose plugin.
```bash
cp config.example.toml config.toml
# Edit base_url and api_key, then:
docker compose up --build -d
# Set a private api_key in config.toml.
mkdir -p data
USHORT_CONFIG="$PWD/config.toml" \
USHORT_DATA="$PWD/data" \
USHORT_UID="$(id -u)" \
USHORT_GID="$(id -g)" \
docker compose -f deploy/docker-compose.prod.yml up -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 example starts ushort at <http://localhost:18082/s/> and stores its SQLite
database in `./data/urlshort.db`.
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`.
Check it and stop it with:
```bash
ushort config.toml
ushort legacy-config.json
curl http://localhost:18082/s/api/health
docker compose -f deploy/docker-compose.prod.yml down
```
| 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. |
This pulls the published image; it does not compile ushort or require a Rust
toolchain.
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.
## Build from source instead
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.
To build the current checkout locally, use the alternative build definition:
```bash
docker build -t ushort:local .
docker compose up -d
mkdir -p data
USHORT_UID="$(id -u)" USHORT_GID="$(id -g)" \
docker compose -f deploy/docker-compose.build.yml up --build -d
```
Run the Rust test suite in an isolated build stage:
It uses the same root-level `config.toml` and `data/` directory as the quick
start.
```bash
docker build --target tester .
```
## Documentation
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.
- [Configuration](docs/configuration.md)
- [API reference](docs/api.md)
- [Embedded frontend](docs/frontend.md)
- [Data and database compatibility](docs/data.md)
- [Production deployment and rollback](docs/deployment.md)
- [Development, testing, and releases](docs/development.md)
- [Security model](docs/security.md)
+1 -5
View File
@@ -1,5 +1,5 @@
# Public URL, including the optional routing prefix.
base_url = "http://localhost:8080/s"
base_url = "http://localhost:18082/s"
# Replace this in the deployment-only config.toml. Do not commit real secrets.
api_key = "change-this-secret-key"
@@ -16,7 +16,3 @@ max_url_length = 2048
max_retention_days = 3650
rate_limit_requests = 60
rate_limit_window = 60
# Keep false when the embedded frontend should be served by ushort.
# Set true only when a separate web server serves the frontend files.
production = false
@@ -1,14 +1,15 @@
services:
ushort:
image: sodium/ushort:0.1.0
container_name: ushort
image: ushort:local
build:
context: ..
command: ["/app/config.toml"]
ports:
- "127.0.0.1:18082:8080"
volumes:
- ./config.toml:/app/config.toml:ro
- ./data:/app/data
user: "1001:1001"
- "${USHORT_CONFIG:-../config.toml}:/app/config.toml:ro"
- ../data:/app/data
user: "${USHORT_UID:-1001}:${USHORT_GID:-1001}"
read_only: true
security_opt:
- no-new-privileges:true
@@ -1,14 +1,13 @@
services:
ushort:
image: sodium/ushort:latest
build:
context: .
image: sodium/ushort:0.1.2
container_name: ushort
command: ["/app/config.toml"]
ports:
- "127.0.0.1:18082:8080"
volumes:
- "${USHORT_CONFIG:-./config.toml}:/app/config.toml:ro"
- ./data:/app/data
- "${USHORT_DATA:-./data}:/app/data"
user: "${USHORT_UID:-1001}:${USHORT_GID:-1001}"
read_only: true
security_opt:
@@ -16,4 +15,3 @@ services:
cap_drop:
- ALL
restart: unless-stopped
+4 -6
View File
@@ -1,12 +1,11 @@
# URL Shortener — nginx location blocks
# Drop these into the existing server {} block. The Rust binary serves both
# the API and its embedded, byte-identical frontend on 127.0.0.1:18082.
# URL Shortener — path-prefix deployment
# Use inside a server {} block with base_url configured for /s.
location = /s {
return 301 /s/;
}
# Preserve the existing no-store policy for the HTML entry point.
# HTML entry point.
location = /s/ {
proxy_pass http://127.0.0.1:18082;
proxy_http_version 1.1;
@@ -20,7 +19,7 @@ location = /s/ {
add_header Cache-Control "no-store";
}
# Preserve the existing revalidation policy for CSS, JavaScript, and fonts.
# CSS, JavaScript, and fonts.
location ^~ /s/static/ {
proxy_pass http://127.0.0.1:18082;
proxy_http_version 1.1;
@@ -44,4 +43,3 @@ location /s/ {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
+41
View File
@@ -0,0 +1,41 @@
# URL Shortener — dedicated-subdomain deployment
# Use inside a server {} block with base_url configured for that host.
# HTML entry point.
location = / {
proxy_pass http://127.0.0.1:18082;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header Cache-Control;
add_header Cache-Control "no-store";
}
# CSS, JavaScript, and fonts.
location ^~ /static/ {
proxy_pass http://127.0.0.1:18082;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header Cache-Control;
add_header Cache-Control "no-cache";
}
# API calls and short-code redirects.
location / {
proxy_pass http://127.0.0.1:18082;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
+80
View File
@@ -0,0 +1,80 @@
# API reference
All paths are relative to the path in `base_url`. Every frontend, API,
metadata, deletion, and short-code route accepts trailing slashes, including
when a query string follows the slash.
## Health and frontend
- `GET /` serves the embedded frontend.
- `GET /api/health` returns HTTP 200:
```json
{"status": "ok", "service": "url-shortener"}
```
With a non-empty base path, requesting the bare path redirects to its trailing
slash form; for example, `/s` redirects to `/s/`.
## Create a short URL
`POST /api/shorten` and `GET /api/shorten` are supported without 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, ushort
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.
Every response includes the compatibility CORS and security headers.
+45
View File
@@ -0,0 +1,45 @@
# Configuration
ushort accepts TOML and legacy JSON configuration files. TOML is preferred;
[`config.example.toml`](../config.example.toml) is the maintained reference.
```bash
ushort config.toml
ushort legacy-config.json
```
## Options
| 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. |
The legacy `short_length` option remains an alias for `min_short_length`.
The removed legacy `production` key is ignored when present; ushort always
serves its embedded frontend and static assets.
## Public URL layouts
The same binary supports both common reverse-proxy layouts:
- `base_url = "https://s.example.com"` serves everything at the subdomain
root and produces URLs such as `https://s.example.com/aB3xYz`.
- `base_url = "https://example.com/go/links"` routes only under `/go/links`
and produces `https://example.com/go/links/aB3xYz`.
A trailing slash on `base_url` is accepted and normalized. Requests outside a
configured non-empty path prefix return 404.
Keep real API keys only in the ignored deployment `config.toml`. Never commit
them or include them in a container image.
+31
View File
@@ -0,0 +1,31 @@
# Data and database compatibility
ushort stores data in SQLite. The database can be mounted directly across
upgrades; no export or import is required.
```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 a strict expiry boundary.
## Backups
Take a consistent backup before every production update. SQLite's online
backup command avoids copying a database while a WAL transaction is active:
```bash
sqlite3 data/urlshort.db ".backup data/urlshort.db.pre-update"
sqlite3 data/urlshort.db.pre-update "PRAGMA integrity_check;"
```
Retain the backup until the updated release has passed health, redirect,
write, and database-integrity checks.
+89
View File
@@ -0,0 +1,89 @@
# Production deployment and rollback
The production host needs only a Compose file, `config.toml`, and persistent
data. It does not need a source checkout.
## Files
Create an application directory like this:
```text
/opt/ushort/
├── docker-compose.yml
├── config.toml
└── data/
```
Copy [`deploy/docker-compose.prod.yml`](../deploy/docker-compose.prod.yml) to
the server as `docker-compose.yml`. For repeatable releases, replace its image
tag with the verified multi-architecture digest:
```yaml
image: docker.io/sodium/ushort:0.1.2@sha256:<verified-index-digest>
```
Set `db_path = "data/urlshort.db"`. The executable always serves its embedded
frontend. Keep `config.toml` owned by the container identity (`1001:1001`) with
mode `0400`; the `data` directory must be writable by the same identity.
## Start and validate
```bash
cd /opt/ushort
docker compose config
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 ushort
curl -i http://127.0.0.1:18082/s/api/health
```
Adjust the health path to match `base_url`.
## Reverse proxy
Choose one nginx snippet and include it inside the public `server {}` block:
- [`deploy/nginx.path.conf`](../deploy/nginx.path.conf) for a path such as
`https://example.com/s`;
- [`deploy/nginx.subdomain.conf`](../deploy/nginx.subdomain.conf) for a host
such as `https://s.example.com`.
Both snippets proxy the frontend, API, and redirects to `127.0.0.1:18082`.
They apply `no-store` to the HTML entry point and `no-cache` to static assets.
The path snippet is written for `/s`; replace each `/s` location when using a
different configured prefix.
Test nginx before reloading it:
```bash
nginx -t
nginx -s reload
```
Then verify the public health endpoint, frontend, static assets, and an
existing short-code redirect. [`tests/smoke.sh`](../tests/smoke.sh) exercises
the complete trailing-slash route set against a disposable record.
## Update
1. Verify the published image contains both AMD64 and ARM64 manifests.
2. Pull the exact version and digest before changing the running service.
3. Create and integrity-check an online SQLite backup as described in
[Data and database compatibility](data.md).
4. Update the image reference and run `docker compose up -d`.
5. Check health, logs, restart count, database integrity, and public routes.
## Rollback
Restore the previous image reference and run:
```bash
docker compose up -d
```
The schema is release-compatible. If the failed release changed or damaged
data, stop the service and restore the pre-update SQLite backup before bringing
the previous image back up. Preserve any legitimate writes made after the
backup before replacing the database.
+59
View File
@@ -0,0 +1,59 @@
# Development, testing, and releases
The repository does not require a host Rust installation. The Dockerfile uses
musl and produces a `scratch` runtime image containing only the executable.
## Local source build
[`deploy/docker-compose.build.yml`](../deploy/docker-compose.build.yml) builds
the checked-out source. Its build context and bind mounts point back to the
repository root.
```bash
cp config.example.toml config.toml
mkdir -p data
USHORT_UID="$(id -u)" USHORT_GID="$(id -g)" \
docker compose -f deploy/docker-compose.build.yml up --build -d
```
The production definition,
[`deploy/docker-compose.prod.yml`](../deploy/docker-compose.prod.yml), never
builds source. It pulls the published version instead and is therefore the
recommended quick-start path in the root README.
## Automated tests
Run all Rust targets in the isolated tester stage:
```bash
docker build --target tester .
```
Run the real HTTP trailing-slash suite against a disposable deployment:
```bash
sh tests/smoke.sh https://s.example.com "$API_KEY"
sh tests/smoke.sh https://example.com/s "$API_KEY"
```
The smoke test creates and removes one temporary short URL.
## Multi-architecture release
Buildx can publish AMD64 and ARM64 from the same source:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t sodium/ushort:latest \
--push .
```
Inspect and record the OCI index digest before deployment:
```bash
docker buildx imagetools inspect sodium/ushort:latest
```
The embedded frontend test verifies every file under `static/` byte-for-byte,
so no separate web-root packaging step is required.
+21
View File
@@ -0,0 +1,21 @@
# Embedded frontend
The frontend under `static/` is embedded into the executable at compile time.
No separate web root or static-file bind mount is required.
It includes URL creation and lookup, short-link metadata, copy and delete
actions, API-key administration, sorting, pagination, theme selection, and
locally hosted fonts. Relative asset URLs and the browser-derived API prefix
allow the same files to work at either a subdomain root or a nested path.
The frontend and static assets are always available. The former `production`
toggle was removed because disabling embedded assets conflicts with ushort's
self-contained deployment model. Old configuration files containing that key
remain loadable; its value is ignored.
The nginx examples apply `no-store` to the HTML entry point and `no-cache` to
static assets. Embedded static requests do not consume the API rate-limit
quota.
Automated tests compare every embedded asset with its source file byte for
byte. See [Development, testing, and releases](development.md).
+22
View File
@@ -0,0 +1,22 @@
# 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.
- Client identity uses `X-Real-IP`, then the first `X-Forwarded-For` value,
then the TCP peer. Bind the container port to loopback and let only a trusted
reverse proxy overwrite those headers.
- CORS preflight uses `OPTIONS` and does not consume rate-limit quota.
- The runtime Compose definitions use a read-only root filesystem, drop Linux
capabilities, enable `no-new-privileges`, and run as a numeric non-root user.
- The runtime image contains no shell, package manager, Python runtime, or
external static files.
- `SIGINT` and `SIGTERM` cleanly unblock the server for prompt shutdown.
Keep `api_key` out of Git, image layers, command output, and monitoring labels.
The supplied nginx configurations forward trusted proxy headers and keep the
application port bound to `127.0.0.1`.
+132 -55
View File
@@ -30,7 +30,6 @@ struct RawConfig {
max_retention_days: i64,
rate_limit_requests: usize,
rate_limit_window: u64,
production: bool,
}
impl Default for RawConfig {
@@ -49,7 +48,6 @@ impl Default for RawConfig {
max_retention_days: 3650,
rate_limit_requests: 60,
rate_limit_window: 60,
production: false,
}
}
}
@@ -68,7 +66,6 @@ pub struct Config {
pub max_retention_days: i64,
pub rate_limit_requests: usize,
pub rate_limit_window: u64,
pub production: bool,
pub base_path: String,
}
@@ -137,6 +134,7 @@ impl Config {
} else {
format!("/{trimmed_path}")
};
let base_url = base_url.trim_end_matches('/').to_string();
Ok(Self {
base_url,
@@ -151,7 +149,6 @@ impl Config {
max_retention_days: raw.max_retention_days,
rate_limit_requests: raw.rate_limit_requests,
rate_limit_window: raw.rate_limit_window,
production: raw.production,
base_path,
})
}
@@ -371,10 +368,7 @@ impl App {
let raw_path = target_path(&request.target);
let bare_base = !self.config.base_path.is_empty() && raw_path == self.config.base_path;
if matches!(request.method.as_str(), "GET" | "HEAD")
&& !self.config.production
&& !bare_base
{
if matches!(request.method.as_str(), "GET" | "HEAD") && !bare_base {
if let Some(path) = local_path(raw_path, &self.config.base_path) {
match path.as_str() {
"/" | "/static" => return self.serve_static("index.html"),
@@ -431,32 +425,14 @@ impl App {
fn handle_get(&self, path: &str, request: &RequestData) -> ResponseData {
match path {
"/" => {
if self.config.production {
health_response()
} else {
self.serve_static("index.html")
}
}
"/" => self.serve_static("index.html"),
"/api/health" => health_response(),
"/api/shorten" => self.handle_shorten(request),
"/api/urls" => self.handle_list_urls(request),
"/api/lookup" => self.handle_lookup(request),
"/static" => {
if self.config.production {
ResponseData::empty(404)
} else {
self.serve_static("index.html")
}
}
"/static" => self.serve_static("index.html"),
_ if path.starts_with("/api/urls/") => self.handle_get_url(&path["/api/urls/".len()..]),
_ if path.starts_with("/static/") => {
if self.config.production {
ResponseData::empty(404)
} else {
self.serve_static(&path["/static/".len()..])
}
}
_ if path.starts_with("/static/") => self.serve_static(&path["/static/".len()..]),
_ => self.handle_redirect(path.trim_start_matches('/')),
}
}
@@ -1001,22 +977,18 @@ mod tests {
}
fn config(db_path: PathBuf) -> Config {
Config {
base_url: "https://example.test/s".into(),
api_key: "secret".into(),
host: "127.0.0.1".into(),
port: 8080,
db_path,
retention_days: 0,
min_short_length: 6,
max_short_length: 32,
max_url_length: 2048,
max_retention_days: 3650,
rate_limit_requests: 1_000,
rate_limit_window: 60,
production: false,
base_path: "/s".into(),
config_for_base(db_path, "https://example.test/s")
}
fn config_for_base(db_path: PathBuf, base_url: &str) -> Config {
Config::from_raw(RawConfig {
base_url: Some(base_url.into()),
api_key: Some("secret".into()),
db_path,
rate_limit_requests: 1_000,
..RawConfig::default()
})
.unwrap()
}
fn json(response: &ResponseData) -> Value {
@@ -1032,6 +1004,98 @@ mod tests {
assert_eq!(local_path("/api/health/", ""), Some("/api/health".into()));
}
#[test]
fn every_endpoint_accepts_trailing_slashes_at_root_and_under_a_path() {
for (label, base_url, prefix) in [
("subdomain", "https://s.example.test/", ""),
("subpath", "https://example.test/s/", "/s"),
] {
let path = temp_path(label);
let app = App::new(config_for_base(path.clone(), base_url)).unwrap();
let target = |route: &str| format!("{prefix}{route}");
assert_eq!(app.config.base_path, prefix);
assert_eq!(app.handle(RequestData::new("GET", target("/"))).status, 200);
assert_eq!(
app.handle(RequestData::new("GET", target("///"))).status,
200
);
assert_eq!(
app.handle(RequestData::new("GET", target("/static/")))
.status,
200
);
assert_eq!(
app.handle(RequestData::new("GET", target("/static/app.js/")))
.status,
200
);
assert_eq!(
app.handle(RequestData::new("GET", target("/api/health/")))
.status,
200
);
assert_eq!(
app.handle(RequestData::new("OPTIONS", target("/api/health/")))
.status,
200
);
let mut create = RequestData::new("POST", target("/api/shorten/"));
create.body = br#"{"url":"https://destination.example/one"}"#.to_vec();
let created = app.handle(create);
assert_eq!(created.status, 201);
let short_url = String::from_utf8(created.body).unwrap();
let expected_base = base_url.trim_end_matches('/');
assert_eq!(app.config.base_url, expected_base);
assert!(short_url.starts_with(&format!("{expected_base}/")));
let code = short_url.rsplit('/').next().unwrap();
let get_create = app.handle(RequestData::new(
"GET",
target("/api/shorten/?url=https%3A%2F%2Fdestination.example%2Ftwo"),
));
assert_eq!(get_create.status, 201);
let lookup = app.handle(RequestData::new(
"GET",
target("/api/lookup/?url=https%3A%2F%2Fdestination.example%2Fone"),
));
assert_eq!(lookup.status, 200);
assert_eq!(json(&lookup)["short_code"], code);
assert_eq!(json(&lookup)["short_url"], short_url);
let metadata = app.handle(RequestData::new(
"GET",
target(&format!("/api/urls/{code}/")),
));
assert_eq!(metadata.status, 200);
let listing = app.handle(RequestData::new("GET", target("/api/urls/?api_key=secret")));
assert_eq!(listing.status, 200);
assert_eq!(json(&listing)["count"], 2);
let redirect = app.handle(RequestData::new("GET", target(&format!("/{code}/"))));
assert_eq!(redirect.status, 302);
assert!(redirect
.headers
.contains(&("Location".into(), "https://destination.example/one".into())));
let deleted = app.handle(RequestData::new(
"DELETE",
target(&format!("/api/urls/{code}/?api_key=secret")),
));
assert_eq!(deleted.status, 204);
let missing = app.handle(RequestData::new(
"GET",
target(&format!("/api/urls/{code}/")),
));
assert_eq!(missing.status, 404);
let _ = fs::remove_file(path);
}
}
#[test]
fn existing_database_without_retention_column_is_migrated() {
let path = temp_path("schema");
@@ -1388,19 +1452,32 @@ mod tests {
}
#[test]
fn production_flag_keeps_legacy_backend_static_behavior() {
let path = temp_path("production");
let mut cfg = config(path.clone());
cfg.production = true;
let app = App::new(cfg).unwrap();
fn deprecated_production_key_is_ignored_and_frontend_remains_enabled() {
let path = temp_path("deprecated-production");
let config_path = std::env::temp_dir().join(format!(
"ushort-deprecated-production-{}-{}.toml",
std::process::id(),
rand::random::<u64>()
));
fs::write(
&config_path,
format!(
"base_url = \"https://example.test/s\"\n\
api_key = \"secret\"\n\
db_path = \"{}\"\n\
production = true\n",
path.display()
),
)
.unwrap();
let app = App::new(Config::load(&config_path).unwrap()).unwrap();
let root = app.handle(RequestData::new("GET", "/s/"));
assert_eq!(
root.body,
br#"{"status": "ok", "service": "url-shortener"}"#
);
assert_eq!(root.status, 200);
assert_eq!(root.body, include_bytes!("../static/index.html"));
let asset = app.handle(RequestData::new("GET", "/s/static/app.js"));
assert_eq!(asset.status, 404);
assert!(asset.body.is_empty());
assert_eq!(asset.status, 200);
assert_eq!(asset.body, include_bytes!("../static/app.js"));
let _ = fs::remove_file(config_path);
let _ = fs::remove_file(path);
}
-2
View File
@@ -49,8 +49,6 @@ fn main() {
"Rate limit : {} req/{}s per IP",
app.config.rate_limit_requests, app.config.rate_limit_window
);
eprintln!("Production : {}", app.config.production);
for mut request in server.incoming_requests() {
let maximum_body = app.max_request_body_bytes();
let declared_too_large = request
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
set -eu
if [ "$#" -ne 2 ]; then
echo "usage: $0 BASE_URL API_KEY" >&2
exit 2
fi
base=${1%/}
api_key=$2
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT HUP INT TERM
request() {
expected=$1
method=$2
url=$3
output=$4
shift 4
actual=$(curl --path-as-is -sS -o "$output" -w '%{http_code}' -X "$method" "$url" "$@")
if [ "$actual" != "$expected" ]; then
echo "$method $url: expected HTTP $expected, got $actual" >&2
sed -n '1,20p' "$output" >&2
exit 1
fi
}
request 200 GET "$base/" "$work/root"
request 200 GET "$base///" "$work/root-slashes"
request 200 GET "$base/static/" "$work/static-index"
request 200 GET "$base/static/app.js/" "$work/app"
request 200 GET "$base/api/health/" "$work/health"
grep -q '"service": "url-shortener"' "$work/health"
request 200 OPTIONS "$base/api/health/" "$work/options"
destination="https://destination.example/trailing-slash-smoke"
encoded_destination="https%3A%2F%2Fdestination.example%2Ftrailing-slash-smoke"
request 201 POST "$base/api/shorten/" "$work/created" \
-H 'Content-Type: application/json' \
--data "{\"url\":\"$destination\"}"
short_url=$(sed -n '1p' "$work/created")
code=${short_url##*/}
case "$short_url" in
"$base"/*) ;;
*) echo "unexpected generated short URL: $short_url" >&2; exit 1 ;;
esac
request 200 GET "$base/api/lookup/?url=$encoded_destination" "$work/lookup"
grep -q "\"short_code\": \"$code\"" "$work/lookup"
request 200 GET "$base/api/urls/$code/" "$work/metadata"
request 200 GET "$base/api/urls/?api_key=$api_key" "$work/list"
request 302 GET "$base/$code/" "$work/redirect"
request 204 DELETE "$base/api/urls/$code/?api_key=$api_key" "$work/delete"
request 404 GET "$base/api/urls/$code/" "$work/missing"
echo "trailing-slash smoke test passed: $base"