always serve embedded frontend

This commit is contained in:
2026-07-10 15:04:29 +00:00
parent 1be698a802
commit 57545565ea
10 changed files with 42 additions and 59 deletions
Generated
+1 -1
View File
@@ -709,7 +709,7 @@ dependencies = [
[[package]] [[package]]
name = "ushort" name = "ushort"
version = "0.1.1" version = "0.1.2"
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.1" version = "0.1.2"
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"
-4
View File
@@ -16,7 +16,3 @@ max_url_length = 2048
max_retention_days = 3650 max_retention_days = 3650
rate_limit_requests = 60 rate_limit_requests = 60
rate_limit_window = 60 rate_limit_window = 60
# Keep false when the embedded frontend should be served by ushort.
# Set true only when a separate web server serves the frontend files.
production = false
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
ushort: ushort:
image: sodium/ushort:0.1.1 image: sodium/ushort:0.1.2
container_name: ushort container_name: ushort
command: ["/app/config.toml"] command: ["/app/config.toml"]
ports: ports:
+1 -1
View File
@@ -6,7 +6,7 @@ when a query string follows the slash.
## Health and frontend ## Health and frontend
- `GET /` serves the embedded frontend when `production=false`. - `GET /` serves the embedded frontend.
- `GET /api/health` returns HTTP 200: - `GET /api/health` returns HTTP 200:
```json ```json
+2 -1
View File
@@ -24,9 +24,10 @@ ushort legacy-config.json
| `max_retention_days` | no | `3650` | Maximum per-URL retention value. | | `max_retention_days` | no | `3650` | Maximum per-URL retention value. |
| `rate_limit_requests` | no | `60` | Requests allowed per client/window. | | `rate_limit_requests` | no | `60` | Requests allowed per client/window. |
| `rate_limit_window` | no | `60` | Sliding-window length in seconds. | | `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`. The legacy `short_length` option remains an alias for `min_short_length`.
The removed legacy `production` key is ignored when present; ushort always
serves its embedded frontend and static assets.
## Public URL layouts ## Public URL layouts
+4 -5
View File
@@ -19,13 +19,12 @@ the server as `docker-compose.yml`. For repeatable releases, replace its image
tag with the verified multi-architecture digest: tag with the verified multi-architecture digest:
```yaml ```yaml
image: docker.io/sodium/ushort:0.1.1@sha256:<verified-index-digest> image: docker.io/sodium/ushort:0.1.2@sha256:<verified-index-digest>
``` ```
Set `db_path = "data/urlshort.db"` and normally keep `production = false` so Set `db_path = "data/urlshort.db"`. The executable always serves its embedded
the executable serves its embedded frontend. Keep `config.toml` owned by the frontend. Keep `config.toml` owned by the container identity (`1001:1001`) with
container identity (`1001:1001`) with mode `0400`; the `data` directory must be mode `0400`; the `data` directory must be writable by the same identity.
writable by the same identity.
## Start and validate ## Start and validate
+4 -3
View File
@@ -8,9 +8,10 @@ actions, API-key administration, sorting, pagination, theme selection, and
locally hosted fonts. Relative asset URLs and the browser-derived API prefix 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. 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 The frontend and static assets are always available. The former `production`
to `true` disables the embedded frontend routes for installations that serve toggle was removed because disabling embedded assets conflicts with ushort's
those files separately. self-contained deployment model. Old configuration files containing that key
remain loadable; its value is ignored.
The nginx examples apply `no-store` to the HTML entry point and `no-cache` to 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 static assets. Embedded static requests do not consume the API rate-limit
+28 -40
View File
@@ -30,7 +30,6 @@ struct RawConfig {
max_retention_days: i64, max_retention_days: i64,
rate_limit_requests: usize, rate_limit_requests: usize,
rate_limit_window: u64, rate_limit_window: u64,
production: bool,
} }
impl Default for RawConfig { impl Default for RawConfig {
@@ -49,7 +48,6 @@ impl Default for RawConfig {
max_retention_days: 3650, max_retention_days: 3650,
rate_limit_requests: 60, rate_limit_requests: 60,
rate_limit_window: 60, rate_limit_window: 60,
production: false,
} }
} }
} }
@@ -68,7 +66,6 @@ pub struct Config {
pub max_retention_days: i64, pub max_retention_days: i64,
pub rate_limit_requests: usize, pub rate_limit_requests: usize,
pub rate_limit_window: u64, pub rate_limit_window: u64,
pub production: bool,
pub base_path: String, pub base_path: String,
} }
@@ -152,7 +149,6 @@ impl Config {
max_retention_days: raw.max_retention_days, max_retention_days: raw.max_retention_days,
rate_limit_requests: raw.rate_limit_requests, rate_limit_requests: raw.rate_limit_requests,
rate_limit_window: raw.rate_limit_window, rate_limit_window: raw.rate_limit_window,
production: raw.production,
base_path, base_path,
}) })
} }
@@ -372,10 +368,7 @@ impl App {
let raw_path = target_path(&request.target); let raw_path = target_path(&request.target);
let bare_base = !self.config.base_path.is_empty() && raw_path == self.config.base_path; let bare_base = !self.config.base_path.is_empty() && raw_path == self.config.base_path;
if matches!(request.method.as_str(), "GET" | "HEAD") if matches!(request.method.as_str(), "GET" | "HEAD") && !bare_base {
&& !self.config.production
&& !bare_base
{
if let Some(path) = local_path(raw_path, &self.config.base_path) { if let Some(path) = local_path(raw_path, &self.config.base_path) {
match path.as_str() { match path.as_str() {
"/" | "/static" => return self.serve_static("index.html"), "/" | "/static" => return self.serve_static("index.html"),
@@ -432,32 +425,14 @@ impl App {
fn handle_get(&self, path: &str, request: &RequestData) -> ResponseData { fn handle_get(&self, path: &str, request: &RequestData) -> ResponseData {
match path { match path {
"/" => { "/" => self.serve_static("index.html"),
if self.config.production {
health_response()
} else {
self.serve_static("index.html")
}
}
"/api/health" => health_response(), "/api/health" => health_response(),
"/api/shorten" => self.handle_shorten(request), "/api/shorten" => self.handle_shorten(request),
"/api/urls" => self.handle_list_urls(request), "/api/urls" => self.handle_list_urls(request),
"/api/lookup" => self.handle_lookup(request), "/api/lookup" => self.handle_lookup(request),
"/static" => { "/static" => self.serve_static("index.html"),
if self.config.production {
ResponseData::empty(404)
} else {
self.serve_static("index.html")
}
}
_ if path.starts_with("/api/urls/") => self.handle_get_url(&path["/api/urls/".len()..]), _ if path.starts_with("/api/urls/") => self.handle_get_url(&path["/api/urls/".len()..]),
_ if path.starts_with("/static/") => { _ if path.starts_with("/static/") => self.serve_static(&path["/static/".len()..]),
if self.config.production {
ResponseData::empty(404)
} else {
self.serve_static(&path["/static/".len()..])
}
}
_ => self.handle_redirect(path.trim_start_matches('/')), _ => self.handle_redirect(path.trim_start_matches('/')),
} }
} }
@@ -1477,19 +1452,32 @@ mod tests {
} }
#[test] #[test]
fn production_flag_keeps_legacy_backend_static_behavior() { fn deprecated_production_key_is_ignored_and_frontend_remains_enabled() {
let path = temp_path("production"); let path = temp_path("deprecated-production");
let mut cfg = config(path.clone()); let config_path = std::env::temp_dir().join(format!(
cfg.production = true; "ushort-deprecated-production-{}-{}.toml",
let app = App::new(cfg).unwrap(); std::process::id(),
rand::random::<u64>()
));
fs::write(
&config_path,
format!(
"base_url = \"https://example.test/s\"\n\
api_key = \"secret\"\n\
db_path = \"{}\"\n\
production = true\n",
path.display()
),
)
.unwrap();
let app = App::new(Config::load(&config_path).unwrap()).unwrap();
let root = app.handle(RequestData::new("GET", "/s/")); let root = app.handle(RequestData::new("GET", "/s/"));
assert_eq!( assert_eq!(root.status, 200);
root.body, assert_eq!(root.body, include_bytes!("../static/index.html"));
br#"{"status": "ok", "service": "url-shortener"}"#
);
let asset = app.handle(RequestData::new("GET", "/s/static/app.js")); let asset = app.handle(RequestData::new("GET", "/s/static/app.js"));
assert_eq!(asset.status, 404); assert_eq!(asset.status, 200);
assert!(asset.body.is_empty()); assert_eq!(asset.body, include_bytes!("../static/app.js"));
let _ = fs::remove_file(config_path);
let _ = fs::remove_file(path); let _ = fs::remove_file(path);
} }
-2
View File
@@ -49,8 +49,6 @@ fn main() {
"Rate limit : {} req/{}s per IP", "Rate limit : {} req/{}s per IP",
app.config.rate_limit_requests, app.config.rate_limit_window app.config.rate_limit_requests, app.config.rate_limit_window
); );
eprintln!("Production : {}", app.config.production);
for mut request in server.incoming_requests() { for mut request in server.incoming_requests() {
let maximum_body = app.max_request_body_bytes(); let maximum_body = app.max_request_body_bytes();
let declared_too_large = request let declared_too_large = request