Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47795011b6 |
Generated
+1
-1
@@ -709,7 +709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ushort"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"ctrlc",
|
||||
"include_dir",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
}
|
||||
+104
-15
@@ -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,
|
||||
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(),
|
||||
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,
|
||||
rate_limit_requests: 1_000,
|
||||
..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");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "usage: $0 BASE_URL API_KEY" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
base=${1%/}
|
||||
api_key=$2
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT HUP INT TERM
|
||||
|
||||
request() {
|
||||
expected=$1
|
||||
method=$2
|
||||
url=$3
|
||||
output=$4
|
||||
shift 4
|
||||
actual=$(curl --path-as-is -sS -o "$output" -w '%{http_code}' -X "$method" "$url" "$@")
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
echo "$method $url: expected HTTP $expected, got $actual" >&2
|
||||
sed -n '1,20p' "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
request 200 GET "$base/" "$work/root"
|
||||
request 200 GET "$base///" "$work/root-slashes"
|
||||
request 200 GET "$base/static/" "$work/static-index"
|
||||
request 200 GET "$base/static/app.js/" "$work/app"
|
||||
request 200 GET "$base/api/health/" "$work/health"
|
||||
grep -q '"service": "url-shortener"' "$work/health"
|
||||
request 200 OPTIONS "$base/api/health/" "$work/options"
|
||||
|
||||
destination="https://destination.example/trailing-slash-smoke"
|
||||
encoded_destination="https%3A%2F%2Fdestination.example%2Ftrailing-slash-smoke"
|
||||
request 201 POST "$base/api/shorten/" "$work/created" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "{\"url\":\"$destination\"}"
|
||||
short_url=$(sed -n '1p' "$work/created")
|
||||
code=${short_url##*/}
|
||||
case "$short_url" in
|
||||
"$base"/*) ;;
|
||||
*) echo "unexpected generated short URL: $short_url" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
request 200 GET "$base/api/lookup/?url=$encoded_destination" "$work/lookup"
|
||||
grep -q "\"short_code\": \"$code\"" "$work/lookup"
|
||||
request 200 GET "$base/api/urls/$code/" "$work/metadata"
|
||||
request 200 GET "$base/api/urls/?api_key=$api_key" "$work/list"
|
||||
request 302 GET "$base/$code/" "$work/redirect"
|
||||
request 204 DELETE "$base/api/urls/$code/?api_key=$api_key" "$work/delete"
|
||||
request 404 GET "$base/api/urls/$code/" "$work/missing"
|
||||
|
||||
echo "trailing-slash smoke test passed: $base"
|
||||
Reference in New Issue
Block a user