v0.4.61: asset cache fix, PNG-enabled iPXE, composed PXE logo
Two real issues v0.4.6 left on the table: Asset caching: - index.html now interpolates the running OpenPXE version into every asset URL as `?v=<version>` (app.css, app.js, logo.svg). Combined with `Cache-Control: no-cache, must-revalidate` on the asset handlers, browsers and intermediary proxies are forced to fetch fresh on every upgrade. Without this, last release's bundled JS kept serving the old UI even after the operator pulled the new image — invisible to anyone who only checks the version chip in the footer (which is dynamic). - The Cache-Control header is also applied to logo.svg and loader.svg so a logo upload reflects immediately rather than after a hard refresh. Real-image PXE menu logo (matches iVentoy now): - New Dockerfile stage `ipxe-build` clones the iPXE source and compiles all four binaries (undionly.kpxe, snponly.efi for x86_64/i386, snponly.efi for arm64 via gcc-aarch64-linux-gnu) with IMAGE_PNG + CONSOLE_FRAMEBUFFER + CONSOLE_VESAFB enabled. Replaces the boot.ipxe.org fetch — those binaries are built without PNG support, which is why v0.4.6's `console --picture` line silently no-op'd. - `iso-store::pxe_logo::compose_pxe_logo` decodes any operator upload (PNG / JPEG / WebP / GIF), downscales-to-fit if larger than 600×200, and pastes it onto a transparent 1024×768 canvas centered horizontally with a 64-pixel top margin. iPXE paints the result at 1:1 on the typical VESA framebuffer, giving the iVentoy-style centered-logo look regardless of the operator's source dimensions. - GET /branding/pxe-logo now returns the composed PNG. wimboot still fetches from ipxe/wimboot's GitHub release (separately signed). - Dropped the ASCII OpenPXE wordmark from render_menu — once the real image paints, the banner would duplicate it visually. iPXE builds without PNG (none of ours after this release, but a third- party undionly might) simply show the menu without a logo, which is the right graceful-degradation outcome. Quality: - 142 tests passing (was 138 in v0.4.6): +4 pxe_logo unit tests covering canvas dimensions, centered-top placement, oversize downscale, and unsupported-bytes error handling; existing integration tests updated to verify the 1024×768 IHDR header from the composed PNG instead of round-tripping the raw upload. - cargo clippy --workspace --all-targets clean. - Image dependency: `image = "0.25"` with only `png/jpeg/webp/gif` features enabled. No new transitive C deps. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
55f4765a20
commit
1419309a2d
+108
-44
@@ -193,23 +193,47 @@ async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>)
|
||||
// ─── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn index(State(state): State<AppState>) -> Response {
|
||||
let html = openpxe_webui::index_html(&state.public_base_url);
|
||||
// The asset version pin in index.html (`?v=…`) is what makes
|
||||
// browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE
|
||||
// binary version — every release ships a new value, every release
|
||||
// forces a fresh URL on each asset.
|
||||
let html = openpxe_webui::index_html(&state.public_base_url, env!("CARGO_PKG_VERSION"));
|
||||
(
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
)],
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
),
|
||||
// index.html itself must never be cached — that's how the
|
||||
// browser learns about a new `?v=…` value for the assets.
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, must-revalidate"),
|
||||
),
|
||||
],
|
||||
html,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Cache-Control header value used for the bundled JS/CSS/SVG assets.
|
||||
/// We pin a 1-day TTL so a long-lived deployment doesn't re-fetch the
|
||||
/// same bytes on every page-load, but require revalidation — combined
|
||||
/// with the `?v=<version>` query string in index.html, the practical
|
||||
/// upper bound on caching across an upgrade is "until the operator
|
||||
/// reloads".
|
||||
const ASSET_CACHE_CONTROL: HeaderValue =
|
||||
HeaderValue::from_static("no-cache, must-revalidate");
|
||||
|
||||
async fn ui_js() -> Response {
|
||||
(
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/javascript"),
|
||||
)],
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/javascript"),
|
||||
),
|
||||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||
],
|
||||
openpxe_webui::app_js(),
|
||||
)
|
||||
.into_response()
|
||||
@@ -217,7 +241,10 @@ async fn ui_js() -> Response {
|
||||
|
||||
async fn ui_css() -> Response {
|
||||
(
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
|
||||
[
|
||||
(header::CONTENT_TYPE, HeaderValue::from_static("text/css")),
|
||||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||
],
|
||||
openpxe_webui::app_css(),
|
||||
)
|
||||
.into_response()
|
||||
@@ -261,20 +288,29 @@ async fn ui_logo(State(state): State<AppState>) -> Response {
|
||||
}
|
||||
}
|
||||
(
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("image/svg+xml"),
|
||||
)],
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("image/svg+xml"),
|
||||
),
|
||||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||
],
|
||||
openpxe_webui::logo_svg(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// v0.4.6: raster-only logo endpoint for the iPXE menu's
|
||||
/// `console --picture`. iPXE can't rasterize SVG, so SVG uploads 404
|
||||
/// here — the ASCII OpenPXE wordmark in `render_menu` already gives
|
||||
/// the operator a polished default. No bundled PNG fallback by design:
|
||||
/// either the operator's raster logo paints, or the text stands in.
|
||||
/// v0.4.61: PXE menu logo composed for the iPXE `console --picture`
|
||||
/// call. The operator can upload any raster image (PNG / JPEG / WebP /
|
||||
/// GIF) of any aspect ratio; this handler decodes it, draws it
|
||||
/// centered-top onto a fixed 1024×768 canvas, and returns PNG bytes.
|
||||
/// That gives the same look as iVentoy regardless of what the operator
|
||||
/// uploaded — a portrait logo, a wide wordmark, a square monogram all
|
||||
/// land in the same place on the boot screen.
|
||||
///
|
||||
/// SVG uploads still 404 here — iPXE can't rasterize SVG, and rather
|
||||
/// than haul in `resvg` we ask the operator to provide a raster when
|
||||
/// they want a custom PXE-side logo. (The WebUI keeps using the SVG.)
|
||||
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
|
||||
let Some(path) = state.branding.logo_path() else {
|
||||
return (StatusCode::NOT_FOUND, "no custom logo configured").into_response();
|
||||
@@ -285,40 +321,68 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
|
||||
if mime == "image/svg+xml" {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
"operator-uploaded logo is SVG; iPXE menu falls back to the bundled ASCII wordmark",
|
||||
"operator-uploaded logo is SVG; PXE menu requires a raster (PNG / JPEG / WebP / GIF)",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => {
|
||||
let ct = HeaderValue::from_str(&mime)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, ct),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, max-age=0"),
|
||||
),
|
||||
],
|
||||
bytes,
|
||||
let bytes = match tokio::fs::read(&path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("custom logo unreadable: {e}"),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("custom logo unreadable: {e}"),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
};
|
||||
// Compose to a fixed 1024×768 PNG so the PXE menu paints the logo
|
||||
// centered-top regardless of the operator's source dimensions. The
|
||||
// `image` crate is pure-Rust + sync; offload to a blocking task
|
||||
// because Lanczos resampling on a 4K source can take tens of
|
||||
// milliseconds and we don't want to block the executor.
|
||||
let composed =
|
||||
match tokio::task::spawn_blocking(move || openpxe_iso_store::pxe_logo::compose_pxe_logo(&bytes))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(png)) => png,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
target: "openpxe::http::branding",
|
||||
error = %e, "failed to compose PXE logo PNG"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to compose PXE logo: {e}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("pxe-logo task failed: {e}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
|
||||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||
],
|
||||
composed,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn ui_loader() -> Response {
|
||||
(
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("image/svg+xml"),
|
||||
)],
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("image/svg+xml"),
|
||||
),
|
||||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||
],
|
||||
openpxe_webui::loader_svg(),
|
||||
)
|
||||
.into_response()
|
||||
|
||||
@@ -77,17 +77,15 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
|
||||
);
|
||||
let _ = writeln!(s, ":menu");
|
||||
let _ = writeln!(s, "menu OpenPXE - network boot menu");
|
||||
// Centered ASCII wordmark. iPXE menus are ~76 columns wide on the
|
||||
// default VGA text console; the lines below are padded to sit
|
||||
// approximately centered. `item --gap -- <text>` emits text without
|
||||
// a selectable hotkey.
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(s, "item --gap -- ___ ___ __ __ ___");
|
||||
let _ = writeln!(s, "item --gap -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|");
|
||||
let _ = writeln!(s, "item --gap -- | (_) | '_ \\/ -_) ' \\ | _/ \\ / | _|");
|
||||
let _ = writeln!(s, "item --gap -- \\___/| .__/\\___|_||_| |_| /_/\\_\\ |___|");
|
||||
let _ = writeln!(s, "item --gap -- |_|");
|
||||
let _ = writeln!(s, "item --gap");
|
||||
// v0.4.61: we used to draw an ASCII OpenPXE wordmark here. Now
|
||||
// that the bundled iPXE is built with `IMAGE_PNG`, the
|
||||
// `console --picture` line at the top of this script paints the
|
||||
// operator's actual logo (composed server-side into a 1024×768
|
||||
// canvas with the logo centered at the top) — the ASCII banner
|
||||
// became visual noise *on top* of the real image. Old iPXE
|
||||
// builds without PNG fall through the `|| console` clause and
|
||||
// simply show the menu without a logo, which is the correct
|
||||
// graceful-degradation outcome.
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"item --gap -- ------------------------- Default -------------------------"
|
||||
@@ -628,10 +626,12 @@ mod password_tests {
|
||||
|
||||
#[test]
|
||||
fn top_menu_has_polished_branding_and_arch_footer() {
|
||||
// v0.4.6 polish: a `console --picture` line for operator
|
||||
// logos, an ASCII OpenPXE wordmark visible across iPXE
|
||||
// builds (graphics or not), and a single-line footer carrying
|
||||
// the current OpenPXE version + the resolved arch label.
|
||||
// v0.4.6 polish + v0.4.61 image upgrade: the menu emits a
|
||||
// `console --picture` line that the bundled iPXE (built with
|
||||
// `IMAGE_PNG`) honours, plus an arch-resolved footer carrying
|
||||
// the current OpenPXE version. The ASCII wordmark that used
|
||||
// to live here was dropped in v0.4.61 — it duplicated the now-
|
||||
// working image.
|
||||
let settings = Settings::default();
|
||||
let s = render_menu(&[], &settings, "http://10.0.0.5");
|
||||
assert!(
|
||||
@@ -641,11 +641,11 @@ mod password_tests {
|
||||
// Picture-or-text-console must be a single statement so older
|
||||
// iPXE parsers don't choke on the chain.
|
||||
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
|
||||
// ASCII wordmark — at least one of the banner lines must
|
||||
// contain the trailing pipe segment, plus the leading "_"s.
|
||||
// No ASCII wordmark — once the real PNG paints, the ASCII
|
||||
// banner would duplicate the operator's logo visually.
|
||||
assert!(
|
||||
s.contains("___ ___ __ __ ___"),
|
||||
"ascii banner missing first row:\n{s}"
|
||||
!s.contains("___ ___ __ __ ___"),
|
||||
"ASCII banner shouldn't be emitted in v0.4.61+:\n{s}"
|
||||
);
|
||||
// Footer with version + arch interpolation. The version comes
|
||||
// from CARGO_PKG_VERSION at compile time.
|
||||
|
||||
Reference in New Issue
Block a user