From 47795011b622bff5db93a49173779a58d7eda971 Mon Sep 17 00:00:00 2001 From: cabbage Date: Fri, 10 Jul 2026 08:47:59 +0000 Subject: [PATCH] support root and path deployments explicitly --- Cargo.lock | 2 +- Cargo.toml | 2 +- DEPLOYMENT.md | 5 +- README.md | 37 ++++-- ...ker-compose.yml => compose.production.yml} | 2 +- deploy/nginx-subdomain.conf | 42 +++++++ src/lib.rs | 117 +++++++++++++++--- tests/smoke.sh | 56 +++++++++ 8 files changed, 237 insertions(+), 26 deletions(-) rename deploy/{docker-compose.yml => compose.production.yml} (91%) create mode 100644 deploy/nginx-subdomain.conf create mode 100644 tests/smoke.sh diff --git a/Cargo.lock b/Cargo.lock index 4b27759..9eded7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -709,7 +709,7 @@ dependencies = [ [[package]] name = "ushort" -version = "0.1.0" +version = "0.1.1" dependencies = [ "ctrlc", "include_dir", diff --git a/Cargo.toml b/Cargo.toml index e133c20..ac52d24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ushort" -version = "0.1.0" +version = "0.1.1" edition = "2021" rust-version = "1.86" description = "A compact, self-contained URL shortener" diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 56b006b..bb987e1 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -19,7 +19,8 @@ Create this layout on titan: └── data/ ``` -Copy `deploy/docker-compose.yml` as the Compose file. Convert the live JSON +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: @@ -39,7 +40,7 @@ cd /root/compose/ushort chown 1001:1001 config.toml chmod 0400 config.toml docker-compose config -docker pull sodium/ushort:0.1.0 +docker pull sodium/ushort:0.1.1 ``` ## 2. Take a consistent database copy diff --git a/README.md b/README.md index 62df511..474e09e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ cp config.example.toml config.toml docker compose 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 service listens on `127.0.0.1:18082` on the host. Its SQLite database is stored at `./data/urlshort.db` and survives container replacement. @@ -51,9 +56,16 @@ ushort legacy-config.json | `rate_limit_window` | no | `60` | Sliding-window length in seconds. | | `production` | no | `false` | When true, disable embedded frontend routes for compatibility with deployments that serve static files separately. | -For example, `base_url = "https://example.com/go/links"` limits routing to -`/go/links` and produces URLs such as -`https://example.com/go/links/aB3xYz`. Requests outside that prefix return 404. +The same binary supports both common reverse-proxy layouts: + +- `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. @@ -61,7 +73,9 @@ 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. +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 @@ -165,9 +179,11 @@ 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. +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 @@ -184,6 +200,13 @@ Run the Rust test suite in an isolated build stage: 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: diff --git a/deploy/docker-compose.yml b/deploy/compose.production.yml similarity index 91% rename from deploy/docker-compose.yml rename to deploy/compose.production.yml index f107e5b..3dbf4ac 100644 --- a/deploy/docker-compose.yml +++ b/deploy/compose.production.yml @@ -1,6 +1,6 @@ services: ushort: - image: sodium/ushort:0.1.0 + image: sodium/ushort:0.1.1 container_name: ushort command: ["/app/config.toml"] ports: diff --git a/deploy/nginx-subdomain.conf b/deploy/nginx-subdomain.conf new file mode 100644 index 0000000..0057d7d --- /dev/null +++ b/deploy/nginx-subdomain.conf @@ -0,0 +1,42 @@ +# 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. + +# Preserve the no-store policy for the 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"; +} + +# Preserve the revalidation policy for 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; +} diff --git a/src/lib.rs b/src/lib.rs index 7c93e5c..118e7ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -137,6 +137,7 @@ impl Config { } else { format!("/{trimmed_path}") }; + let base_url = base_url.trim_end_matches('/').to_string(); Ok(Self { base_url, @@ -1001,22 +1002,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 +1029,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"); diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100644 index 0000000..02f3da0 --- /dev/null +++ b/tests/smoke.sh @@ -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"