1 Commits
Author SHA1 Message Date
cabbage 47795011b6 support root and path deployments explicitly 2026-07-10 08:47:59 +00:00
8 changed files with 237 additions and 26 deletions
Generated
+1 -1
View File
@@ -709,7 +709,7 @@ dependencies = [
[[package]] [[package]]
name = "ushort" name = "ushort"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"ctrlc", "ctrlc",
"include_dir", "include_dir",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ushort" name = "ushort"
version = "0.1.0" version = "0.1.1"
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"
+3 -2
View File
@@ -19,7 +19,8 @@ Create this layout on titan:
└── data/ └── 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 settings to TOML using `config.example.toml` as the reference, with these
cutover-specific rules: cutover-specific rules:
@@ -39,7 +40,7 @@ cd /root/compose/ushort
chown 1001:1001 config.toml chown 1001:1001 config.toml
chmod 0400 config.toml chmod 0400 config.toml
docker-compose config docker-compose config
docker pull sodium/ushort:0.1.0 docker pull sodium/ushort:0.1.1
``` ```
## 2. Take a consistent database copy ## 2. Take a consistent database copy
+30 -7
View File
@@ -17,6 +17,11 @@ cp config.example.toml config.toml
docker compose up --build -d 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 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. 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. | | `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. | | `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 The same binary supports both common reverse-proxy layouts:
`/go/links` and produces URLs such as
`https://example.com/go/links/aB3xYz`. Requests outside that prefix return 404. - `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 Real API keys belong only in the deployment's ignored `config.toml`, never in
the repository or container image. the repository or container image.
@@ -61,7 +73,9 @@ the repository or container image.
## API ## API
All paths below are relative to the path in `base_url`. Trailing slashes are 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 ### 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 are unchanged. Compile-time embedding makes those files part of the standalone
artifact; no bind mount or separate web root is needed. artifact; no bind mount or separate web root is needed.
For the existing `/s` deployment, place the location blocks from `nginx.conf` For a path deployment such as the existing `/s`, place the location blocks
in the public vhost. They proxy all traffic to the binary while retaining the from `nginx.conf` in the public vhost. For a dedicated subdomain, use
current `no-store` HTML and `no-cache` asset policies. `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 ## Build and test
@@ -184,6 +200,13 @@ Run the Rust test suite in an isolated build stage:
docker build --target tester . 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. The Dockerfile builds against musl and uses `scratch` for the runtime image.
Docker Buildx can publish both required architectures from the same source: Docker Buildx can publish both required architectures from the same source:
@@ -1,6 +1,6 @@
services: services:
ushort: ushort:
image: sodium/ushort:0.1.0 image: sodium/ushort:0.1.1
container_name: ushort container_name: ushort
command: ["/app/config.toml"] command: ["/app/config.toml"]
ports: ports:
+42
View File
@@ -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;
}
+103 -14
View File
@@ -137,6 +137,7 @@ impl Config {
} else { } else {
format!("/{trimmed_path}") format!("/{trimmed_path}")
}; };
let base_url = base_url.trim_end_matches('/').to_string();
Ok(Self { Ok(Self {
base_url, base_url,
@@ -1001,22 +1002,18 @@ mod tests {
} }
fn config(db_path: PathBuf) -> Config { fn config(db_path: PathBuf) -> Config {
Config { config_for_base(db_path, "https://example.test/s")
base_url: "https://example.test/s".into(), }
api_key: "secret".into(),
host: "127.0.0.1".into(), fn config_for_base(db_path: PathBuf, base_url: &str) -> Config {
port: 8080, Config::from_raw(RawConfig {
base_url: Some(base_url.into()),
api_key: Some("secret".into()),
db_path, 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_requests: 1_000,
rate_limit_window: 60, ..RawConfig::default()
production: false, })
base_path: "/s".into(), .unwrap()
}
} }
fn json(response: &ResponseData) -> Value { fn json(response: &ResponseData) -> Value {
@@ -1032,6 +1029,98 @@ mod tests {
assert_eq!(local_path("/api/health/", ""), Some("/api/health".into())); 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] #[test]
fn existing_database_without_retention_column_is_migrated() { fn existing_database_without_retention_column_is_migrated() {
let path = temp_path("schema"); let path = temp_path("schema");
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
set -eu
if [ "$#" -ne 2 ]; then
echo "usage: $0 BASE_URL API_KEY" >&2
exit 2
fi
base=${1%/}
api_key=$2
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT HUP INT TERM
request() {
expected=$1
method=$2
url=$3
output=$4
shift 4
actual=$(curl --path-as-is -sS -o "$output" -w '%{http_code}' -X "$method" "$url" "$@")
if [ "$actual" != "$expected" ]; then
echo "$method $url: expected HTTP $expected, got $actual" >&2
sed -n '1,20p' "$output" >&2
exit 1
fi
}
request 200 GET "$base/" "$work/root"
request 200 GET "$base///" "$work/root-slashes"
request 200 GET "$base/static/" "$work/static-index"
request 200 GET "$base/static/app.js/" "$work/app"
request 200 GET "$base/api/health/" "$work/health"
grep -q '"service": "url-shortener"' "$work/health"
request 200 OPTIONS "$base/api/health/" "$work/options"
destination="https://destination.example/trailing-slash-smoke"
encoded_destination="https%3A%2F%2Fdestination.example%2Ftrailing-slash-smoke"
request 201 POST "$base/api/shorten/" "$work/created" \
-H 'Content-Type: application/json' \
--data "{\"url\":\"$destination\"}"
short_url=$(sed -n '1p' "$work/created")
code=${short_url##*/}
case "$short_url" in
"$base"/*) ;;
*) echo "unexpected generated short URL: $short_url" >&2; exit 1 ;;
esac
request 200 GET "$base/api/lookup/?url=$encoded_destination" "$work/lookup"
grep -q "\"short_code\": \"$code\"" "$work/lookup"
request 200 GET "$base/api/urls/$code/" "$work/metadata"
request 200 GET "$base/api/urls/?api_key=$api_key" "$work/list"
request 302 GET "$base/$code/" "$work/redirect"
request 204 DELETE "$base/api/urls/$code/?api_key=$api_key" "$work/delete"
request 404 GET "$base/api/urls/$code/" "$work/missing"
echo "trailing-slash smoke test passed: $base"