Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3356200a8 | ||
|
|
1be698a802 | ||
|
|
ff2290f0f1 |
Generated
+1
-1
@@ -709,7 +709,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ushort"
|
name = "ushort"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ushort"
|
name = "ushort"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
description = "A compact, self-contained URL shortener"
|
description = "A compact, self-contained URL shortener"
|
||||||
|
|||||||
-141
@@ -1,141 +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/compose.production.yml` to the server as `docker-compose.yml`.
|
|
||||||
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.1
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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,234 +1,61 @@
|
|||||||
# ushort
|
# ushort
|
||||||
|
|
||||||
`ushort` is a compact URL shortener written in Rust and backed by SQLite. It is
|
`ushort` is a compact, self-contained URL shortener written in Rust and backed
|
||||||
a behavior-compatible replacement for the original stdlib-only Python service:
|
by SQLite. It serves an embedded web interface and JSON API from a single
|
||||||
the routes, base-path handling, response shapes, database schema, retention,
|
statically linked executable.
|
||||||
API-key checks, throttling, redirects, and frontend are preserved.
|
|
||||||
|
|
||||||
The frontend under `static/` is embedded into the executable at compile time.
|
It supports deployment at either a dedicated host such as `s.example.com` or
|
||||||
The final Linux image contains only one statically linked executable and runs
|
under a path such as `example.com/s`. Images are available for Linux AMD64 and
|
||||||
without Python, a shell, a package manager, or external static files.
|
ARM64.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
Requirements: Docker with the Compose plugin.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.example.toml config.toml
|
cp config.example.toml config.toml
|
||||||
# Edit base_url and api_key, then:
|
# Set a private api_key in config.toml.
|
||||||
docker compose up --build -d
|
|
||||||
|
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 root `docker-compose.yml` is the local-development definition and builds
|
The example starts ushort at <http://localhost:18082/s/> and stores its SQLite
|
||||||
the checked-out source. `deploy/compose.production.yml` is the source-free
|
database in `./data/urlshort.db`.
|
||||||
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
|
Check it and stop it with:
|
||||||
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
|
```bash
|
||||||
ushort config.toml
|
curl http://localhost:18082/s/api/health
|
||||||
ushort legacy-config.json
|
docker compose -f deploy/docker-compose.prod.yml down
|
||||||
```
|
```
|
||||||
|
|
||||||
| Key | Required | Default | Description |
|
This pulls the published image; it does not compile ushort or require a Rust
|
||||||
|---|---:|---:|---|
|
toolchain.
|
||||||
| `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:
|
## Build from source instead
|
||||||
|
|
||||||
- `base_url = "https://s.example.com"` serves the frontend, API, and short
|
To build the current checkout locally, use the alternative build definition:
|
||||||
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
|
```bash
|
||||||
docker build -t ushort:local .
|
mkdir -p data
|
||||||
docker compose up -d
|
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
|
## Documentation
|
||||||
docker build --target tester .
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the real HTTP trailing-slash smoke test against any disposable deployment:
|
- [Configuration](docs/configuration.md)
|
||||||
|
- [API reference](docs/api.md)
|
||||||
```bash
|
- [Embedded frontend](docs/frontend.md)
|
||||||
sh tests/smoke.sh https://s.example.com "$API_KEY"
|
- [Data and database compatibility](docs/data.md)
|
||||||
sh tests/smoke.sh https://example.com/s "$API_KEY"
|
- [Production deployment and rollback](docs/deployment.md)
|
||||||
```
|
- [Development, testing, and releases](docs/development.md)
|
||||||
|
- [Security model](docs/security.md)
|
||||||
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.
|
|
||||||
|
|||||||
+1
-5
@@ -1,5 +1,5 @@
|
|||||||
# Public URL, including the optional routing prefix.
|
# 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.
|
# Replace this in the deployment-only config.toml. Do not commit real secrets.
|
||||||
api_key = "change-this-secret-key"
|
api_key = "change-this-secret-key"
|
||||||
@@ -16,7 +16,3 @@ max_url_length = 2048
|
|||||||
max_retention_days = 3650
|
max_retention_days = 3650
|
||||||
rate_limit_requests = 60
|
rate_limit_requests = 60
|
||||||
rate_limit_window = 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:
|
services:
|
||||||
ushort:
|
ushort:
|
||||||
image: sodium/ushort:0.1.1
|
image: ushort:local
|
||||||
container_name: ushort
|
build:
|
||||||
|
context: ..
|
||||||
command: ["/app/config.toml"]
|
command: ["/app/config.toml"]
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:18082:8080"
|
- "127.0.0.1:18082:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.toml:/app/config.toml:ro
|
- "${USHORT_CONFIG:-../config.toml}:/app/config.toml:ro"
|
||||||
- ./data:/app/data
|
- ../data:/app/data
|
||||||
user: "1001:1001"
|
user: "${USHORT_UID:-1001}:${USHORT_GID:-1001}"
|
||||||
read_only: true
|
read_only: true
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
services:
|
services:
|
||||||
ushort:
|
ushort:
|
||||||
image: sodium/ushort:latest
|
image: sodium/ushort:0.1.2
|
||||||
build:
|
container_name: ushort
|
||||||
context: .
|
|
||||||
command: ["/app/config.toml"]
|
command: ["/app/config.toml"]
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:18082:8080"
|
- "127.0.0.1:18082:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- "${USHORT_CONFIG:-./config.toml}:/app/config.toml:ro"
|
- "${USHORT_CONFIG:-./config.toml}:/app/config.toml:ro"
|
||||||
- ./data:/app/data
|
- "${USHORT_DATA:-./data}:/app/data"
|
||||||
user: "${USHORT_UID:-1001}:${USHORT_GID:-1001}"
|
user: "${USHORT_UID:-1001}:${USHORT_GID:-1001}"
|
||||||
read_only: true
|
read_only: true
|
||||||
security_opt:
|
security_opt:
|
||||||
@@ -16,4 +15,3 @@ services:
|
|||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
# URL Shortener — nginx location blocks
|
# URL Shortener — path-prefix deployment
|
||||||
# Drop these into the existing server {} block. The Rust binary serves both
|
# Use inside a server {} block with base_url configured for /s.
|
||||||
# the API and its embedded, byte-identical frontend on 127.0.0.1:18082.
|
|
||||||
|
|
||||||
location = /s {
|
location = /s {
|
||||||
return 301 /s/;
|
return 301 /s/;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Preserve the existing no-store policy for the HTML entry point.
|
# HTML entry point.
|
||||||
location = /s/ {
|
location = /s/ {
|
||||||
proxy_pass http://127.0.0.1:18082;
|
proxy_pass http://127.0.0.1:18082;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -20,7 +19,7 @@ location = /s/ {
|
|||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Preserve the existing revalidation policy for CSS, JavaScript, and fonts.
|
# CSS, JavaScript, and fonts.
|
||||||
location ^~ /s/static/ {
|
location ^~ /s/static/ {
|
||||||
proxy_pass http://127.0.0.1:18082;
|
proxy_pass http://127.0.0.1:18082;
|
||||||
proxy_http_version 1.1;
|
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-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
# URL Shortener — dedicated subdomain location blocks
|
# URL Shortener — dedicated-subdomain deployment
|
||||||
# Use inside the server {} block for a host such as s.example.com. Configure
|
# Use inside a server {} block with base_url configured for that host.
|
||||||
# base_url = "https://s.example.com" in config.toml.
|
|
||||||
|
|
||||||
# Preserve the no-store policy for the HTML entry point.
|
# HTML entry point.
|
||||||
location = / {
|
location = / {
|
||||||
proxy_pass http://127.0.0.1:18082;
|
proxy_pass http://127.0.0.1:18082;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -16,7 +15,7 @@ location = / {
|
|||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Preserve the revalidation policy for CSS, JavaScript, and fonts.
|
# CSS, JavaScript, and fonts.
|
||||||
location ^~ /static/ {
|
location ^~ /static/ {
|
||||||
proxy_pass http://127.0.0.1:18082;
|
proxy_pass http://127.0.0.1:18082;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
+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`.
|
||||||
+28
-40
@@ -30,7 +30,6 @@ struct RawConfig {
|
|||||||
max_retention_days: i64,
|
max_retention_days: i64,
|
||||||
rate_limit_requests: usize,
|
rate_limit_requests: usize,
|
||||||
rate_limit_window: u64,
|
rate_limit_window: u64,
|
||||||
production: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RawConfig {
|
impl Default for RawConfig {
|
||||||
@@ -49,7 +48,6 @@ impl Default for RawConfig {
|
|||||||
max_retention_days: 3650,
|
max_retention_days: 3650,
|
||||||
rate_limit_requests: 60,
|
rate_limit_requests: 60,
|
||||||
rate_limit_window: 60,
|
rate_limit_window: 60,
|
||||||
production: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +66,6 @@ pub struct Config {
|
|||||||
pub max_retention_days: i64,
|
pub max_retention_days: i64,
|
||||||
pub rate_limit_requests: usize,
|
pub rate_limit_requests: usize,
|
||||||
pub rate_limit_window: u64,
|
pub rate_limit_window: u64,
|
||||||
pub production: bool,
|
|
||||||
pub base_path: String,
|
pub base_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +149,6 @@ impl Config {
|
|||||||
max_retention_days: raw.max_retention_days,
|
max_retention_days: raw.max_retention_days,
|
||||||
rate_limit_requests: raw.rate_limit_requests,
|
rate_limit_requests: raw.rate_limit_requests,
|
||||||
rate_limit_window: raw.rate_limit_window,
|
rate_limit_window: raw.rate_limit_window,
|
||||||
production: raw.production,
|
|
||||||
base_path,
|
base_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -372,10 +368,7 @@ impl App {
|
|||||||
|
|
||||||
let raw_path = target_path(&request.target);
|
let raw_path = target_path(&request.target);
|
||||||
let bare_base = !self.config.base_path.is_empty() && raw_path == self.config.base_path;
|
let bare_base = !self.config.base_path.is_empty() && raw_path == self.config.base_path;
|
||||||
if matches!(request.method.as_str(), "GET" | "HEAD")
|
if matches!(request.method.as_str(), "GET" | "HEAD") && !bare_base {
|
||||||
&& !self.config.production
|
|
||||||
&& !bare_base
|
|
||||||
{
|
|
||||||
if let Some(path) = local_path(raw_path, &self.config.base_path) {
|
if let Some(path) = local_path(raw_path, &self.config.base_path) {
|
||||||
match path.as_str() {
|
match path.as_str() {
|
||||||
"/" | "/static" => return self.serve_static("index.html"),
|
"/" | "/static" => return self.serve_static("index.html"),
|
||||||
@@ -432,32 +425,14 @@ impl App {
|
|||||||
|
|
||||||
fn handle_get(&self, path: &str, request: &RequestData) -> ResponseData {
|
fn handle_get(&self, path: &str, request: &RequestData) -> ResponseData {
|
||||||
match path {
|
match path {
|
||||||
"/" => {
|
"/" => self.serve_static("index.html"),
|
||||||
if self.config.production {
|
|
||||||
health_response()
|
|
||||||
} else {
|
|
||||||
self.serve_static("index.html")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"/api/health" => health_response(),
|
"/api/health" => health_response(),
|
||||||
"/api/shorten" => self.handle_shorten(request),
|
"/api/shorten" => self.handle_shorten(request),
|
||||||
"/api/urls" => self.handle_list_urls(request),
|
"/api/urls" => self.handle_list_urls(request),
|
||||||
"/api/lookup" => self.handle_lookup(request),
|
"/api/lookup" => self.handle_lookup(request),
|
||||||
"/static" => {
|
"/static" => self.serve_static("index.html"),
|
||||||
if self.config.production {
|
|
||||||
ResponseData::empty(404)
|
|
||||||
} else {
|
|
||||||
self.serve_static("index.html")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ if path.starts_with("/api/urls/") => self.handle_get_url(&path["/api/urls/".len()..]),
|
_ if path.starts_with("/api/urls/") => self.handle_get_url(&path["/api/urls/".len()..]),
|
||||||
_ if path.starts_with("/static/") => {
|
_ if path.starts_with("/static/") => self.serve_static(&path["/static/".len()..]),
|
||||||
if self.config.production {
|
|
||||||
ResponseData::empty(404)
|
|
||||||
} else {
|
|
||||||
self.serve_static(&path["/static/".len()..])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => self.handle_redirect(path.trim_start_matches('/')),
|
_ => self.handle_redirect(path.trim_start_matches('/')),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1477,19 +1452,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn production_flag_keeps_legacy_backend_static_behavior() {
|
fn deprecated_production_key_is_ignored_and_frontend_remains_enabled() {
|
||||||
let path = temp_path("production");
|
let path = temp_path("deprecated-production");
|
||||||
let mut cfg = config(path.clone());
|
let config_path = std::env::temp_dir().join(format!(
|
||||||
cfg.production = true;
|
"ushort-deprecated-production-{}-{}.toml",
|
||||||
let app = App::new(cfg).unwrap();
|
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/"));
|
let root = app.handle(RequestData::new("GET", "/s/"));
|
||||||
assert_eq!(
|
assert_eq!(root.status, 200);
|
||||||
root.body,
|
assert_eq!(root.body, include_bytes!("../static/index.html"));
|
||||||
br#"{"status": "ok", "service": "url-shortener"}"#
|
|
||||||
);
|
|
||||||
let asset = app.handle(RequestData::new("GET", "/s/static/app.js"));
|
let asset = app.handle(RequestData::new("GET", "/s/static/app.js"));
|
||||||
assert_eq!(asset.status, 404);
|
assert_eq!(asset.status, 200);
|
||||||
assert!(asset.body.is_empty());
|
assert_eq!(asset.body, include_bytes!("../static/app.js"));
|
||||||
|
let _ = fs::remove_file(config_path);
|
||||||
let _ = fs::remove_file(path);
|
let _ = fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ fn main() {
|
|||||||
"Rate limit : {} req/{}s per IP",
|
"Rate limit : {} req/{}s per IP",
|
||||||
app.config.rate_limit_requests, app.config.rate_limit_window
|
app.config.rate_limit_requests, app.config.rate_limit_window
|
||||||
);
|
);
|
||||||
eprintln!("Production : {}", app.config.production);
|
|
||||||
|
|
||||||
for mut request in server.incoming_requests() {
|
for mut request in server.incoming_requests() {
|
||||||
let maximum_body = app.max_request_body_bytes();
|
let maximum_body = app.max_request_body_bytes();
|
||||||
let declared_too_large = request
|
let declared_too_large = request
|
||||||
|
|||||||
Reference in New Issue
Block a user