Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47795011b6 |
Generated
+1
-1
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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