Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3356200a8 | ||
|
|
1be698a802 | ||
|
|
ff2290f0f1 | ||
|
|
47795011b6 |
Generated
+1
-1
@@ -709,7 +709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ushort"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ctrlc",
|
||||
"include_dir",
|
||||
|
||||
+1
-1
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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`.
|
||||
+131
-54
@@ -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,
|
||||
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,
|
||||
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(),
|
||||
}
|
||||
..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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user