diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md deleted file mode 100644 index bb987e1..0000000 --- a/DEPLOYMENT.md +++ /dev/null @@ -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. diff --git a/README.md b/README.md index 474e09e..13975dc 100644 --- a/README.md +++ b/README.md @@ -1,234 +1,46 @@ # 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_UID="$(id -u)" USHORT_GID="$(id -g)" \ + docker compose -f deploy/docker-compose.build.yml up --build -d ``` -The root `docker-compose.yml` is the local-development definition and builds -the checked-out source. `deploy/compose.production.yml` is the source-free -server definition and pulls a versioned image; copy it to the deployment host -as `docker-compose.yml`. +The example starts ushort at and stores its SQLite +database in `./data/urlshort.db`. -The service listens on `127.0.0.1:18082` on the host. Its SQLite database is -stored at `./data/urlshort.db` and survives container replacement. - -The container runs as UID/GID `1001:1001` by default. Override -`USHORT_UID`/`USHORT_GID` if needed, and ensure `./data` is writable by that -identity. - -## Configuration - -TOML is the preferred format; see `config.example.toml`. Existing JSON files -remain supported, including the old `short_length` alias for -`min_short_length`. +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.build.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. | +For a production server, use the published image through +`deploy/docker-compose.prod.yml`; it does not build or require a source +checkout. -The same binary supports both common reverse-proxy layouts: +## Documentation -- `base_url = "https://s.example.com"` serves the frontend, API, and short - codes at the subdomain root, producing `https://s.example.com/aB3xYz`. -- `base_url = "https://example.com/go/links"` limits routing to `/go/links` - and produces `https://example.com/go/links/aB3xYz`. Requests outside that - prefix return 404. - -A trailing slash on `base_url` is accepted in either layout and is normalized -when short URLs are generated. - -Real API keys belong only in the deployment's ignored `config.toml`, never in -the repository or container image. - -## API - -All paths below are relative to the path in `base_url`. Trailing slashes are -accepted by every frontend, API, metadata, deletion, and short-code route, -including when a query string follows the slash. Every response includes the -legacy CORS and security headers. - -### Health and frontend - -- `GET /` serves the byte-identical embedded frontend when `production=false`. -- `GET /api/health` returns HTTP 200: - -```json -{"status":"ok","service":"url-shortener"} -``` - -When the configured base path is non-empty, a GET of the bare path (for -example `/s`) redirects to `/s/`. - -### Create a short URL - -`POST /api/shorten` and `GET /api/shorten` are both supported and do not -require an API key. Fields may be supplied as query parameters or in a JSON -body; query parameters take precedence. - -| Field | Required | Description | -|---|---:|---| -| `url` | yes | Absolute `http://` or `https://` URL. | -| `retention_days` | no | Per-URL lifetime, or zero to keep forever. | - -Success is HTTP 201 with only the short URL as UTF-8 plain text: - -```text -https://example.com/s/aB3xYz -``` - -Codes begin at `min_short_length`. After ten collisions at one length the -service tries the next length, through `max_short_length`. - -### List URLs - -`GET /api/urls?api_key=` 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/` returns one metadata object without authentication. -- `GET /api/lookup?url=` returns the newest matching metadata - object without authentication. -- A missing record returns HTTP 404 with an empty body. - -### Delete - -`DELETE /api/urls/?api_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 /` atomically increments `visit_count` and responds with HTTP 302 to -the stored original URL. - -## Data compatibility - -The existing database can be mounted directly; no export/import is required. -The schema remains: - -```sql -CREATE TABLE urls ( - short_code TEXT PRIMARY KEY, - original_url TEXT NOT NULL, - created_at INTEGER NOT NULL, - visit_count INTEGER NOT NULL DEFAULT 0, - retention_days INTEGER NOT NULL DEFAULT 0 -); -``` - -Older databases missing `retention_days` are upgraded in place with a default -of zero. Expired records are removed on startup and before each GET, POST, or -DELETE request, using the original strict expiry boundary. - -Before a production cutover, make a filesystem-consistent backup of the -database (or use SQLite's backup command) and test the copy with the new image. - -## Frontend - -The existing `index.html`, JavaScript, CSS, locally hosted fonts, sorting, -pagination, theme selection, URL lookup, copy/delete actions, and admin flow -are unchanged. Compile-time embedding makes those files part of the standalone -artifact; no bind mount or separate web root is needed. - -For a path deployment such as the existing `/s`, place the location blocks -from `nginx.conf` in the public vhost. For a dedicated subdomain, use -`deploy/nginx-subdomain.conf` in its `server` block. Both examples proxy the -frontend, API, and redirects while retaining `no-store` HTML and `no-cache` -asset policies. - -## Build and test - -The repository intentionally does not require a host Rust installation. - -```bash -docker build -t ushort:local . -docker compose up -d -``` - -Run the Rust test suite in an isolated build stage: - -```bash -docker build --target tester . -``` - -Run the real HTTP trailing-slash smoke test against any disposable deployment: - -```bash -sh tests/smoke.sh https://s.example.com "$API_KEY" -sh tests/smoke.sh https://example.com/s "$API_KEY" -``` - -The Dockerfile builds against musl and uses `scratch` for the runtime image. -Docker Buildx can publish both required architectures from the same source: - -```bash -docker buildx build \ - --platform linux/amd64,linux/arm64 \ - -t sodium/ushort:latest \ - --push . -``` - -## Security model - -- SQL statements use bound parameters. -- Short codes are restricted to ASCII alphanumerics. -- URL, retention, code, and request-body sizes are bounded. -- API-key comparison is constant-time; protected operations return no body on - authentication failure. -- Per-IP sliding-window throttling applies to API calls and redirects; embedded - frontend files do not consume API quota. The app uses `X-Real-IP`, then the - first `X-Forwarded-For` value, then the TCP peer. Keep the container port - bound to loopback and let the trusted reverse proxy overwrite those headers. -- CORS preflight uses `OPTIONS`; it does not consume rate-limit quota. -- The runtime image is read-only, drops Linux capabilities, and runs as a - numeric non-root user through Compose. -- `SIGINT`/`SIGTERM` cleanly unblock the server for prompt container shutdown. +- [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) diff --git a/config.example.toml b/config.example.toml index 7c85ffc..a5049b7 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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" diff --git a/docker-compose.yml b/deploy/docker-compose.build.yml similarity index 68% rename from docker-compose.yml rename to deploy/docker-compose.build.yml index 9ea4029..410f692 100644 --- a/docker-compose.yml +++ b/deploy/docker-compose.build.yml @@ -1,14 +1,14 @@ services: ushort: - image: sodium/ushort:latest + image: ushort:local build: - context: . + context: .. command: ["/app/config.toml"] ports: - "127.0.0.1:18082:8080" volumes: - - "${USHORT_CONFIG:-./config.toml}:/app/config.toml:ro" - - ./data:/app/data + - "${USHORT_CONFIG:-../config.toml}:/app/config.toml:ro" + - ../data:/app/data user: "${USHORT_UID:-1001}:${USHORT_GID:-1001}" read_only: true security_opt: @@ -16,4 +16,3 @@ services: cap_drop: - ALL restart: unless-stopped - diff --git a/deploy/compose.production.yml b/deploy/docker-compose.prod.yml similarity index 100% rename from deploy/compose.production.yml rename to deploy/docker-compose.prod.yml diff --git a/nginx.conf b/deploy/nginx.path.conf similarity index 78% rename from nginx.conf rename to deploy/nginx.path.conf index a348c42..b2b59c9 100644 --- a/nginx.conf +++ b/deploy/nginx.path.conf @@ -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; } - diff --git a/deploy/nginx-subdomain.conf b/deploy/nginx.subdomain.conf similarity index 79% rename from deploy/nginx-subdomain.conf rename to deploy/nginx.subdomain.conf index 0057d7d..3dd3e3f 100644 --- a/deploy/nginx-subdomain.conf +++ b/deploy/nginx.subdomain.conf @@ -1,8 +1,7 @@ -# URL Shortener — dedicated subdomain location blocks -# Use inside the server {} block for a host such as s.example.com. Configure -# base_url = "https://s.example.com" in config.toml. +# URL Shortener — dedicated-subdomain deployment +# Use inside a server {} block with base_url configured for that host. -# Preserve the no-store policy for the HTML entry point. +# HTML entry point. location = / { proxy_pass http://127.0.0.1:18082; proxy_http_version 1.1; @@ -16,7 +15,7 @@ location = / { add_header Cache-Control "no-store"; } -# Preserve the revalidation policy for CSS, JavaScript, and fonts. +# CSS, JavaScript, and fonts. location ^~ /static/ { proxy_pass http://127.0.0.1:18082; proxy_http_version 1.1; diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..7f10ed2 --- /dev/null +++ b/docs/api.md @@ -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 when `production=false`. +- `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=` 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/` returns one metadata object without authentication. +- `GET /api/lookup?url=` returns the newest matching metadata + object without authentication. +- A missing record returns HTTP 404 with an empty body. + +## Delete + +`DELETE /api/urls/?api_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 /` atomically increments `visit_count` and responds with HTTP 302 to +the stored original URL. + +Every response includes the compatibility CORS and security headers. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..0946d77 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,44 @@ +# 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. | +| `production` | no | `false` | Disable embedded frontend routes when a separate server provides them. | + +The legacy `short_length` option remains an alias for `min_short_length`. + +## 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. diff --git a/docs/data.md b/docs/data.md new file mode 100644 index 0000000..c04e3af --- /dev/null +++ b/docs/data.md @@ -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. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..86599c5 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,90 @@ +# 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.1@sha256: +``` + +Set `db_path = "data/urlshort.db"` and normally keep `production = false` so +the executable 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. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..93bf8f2 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,58 @@ +# 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. + +## 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. diff --git a/docs/frontend.md b/docs/frontend.md new file mode 100644 index 0000000..bb1e7f3 --- /dev/null +++ b/docs/frontend.md @@ -0,0 +1,20 @@ +# 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. + +Set `production = false` for the normal self-contained deployment. Setting it +to `true` disables the embedded frontend routes for installations that serve +those files separately. + +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). diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..965253b --- /dev/null +++ b/docs/security.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`.