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
Generated
+187
-8
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -84,6 +90,12 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.7.9"
|
version = "0.7.9"
|
||||||
@@ -212,12 +224,24 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -280,6 +304,12 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "color_quant"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -295,6 +325,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -406,6 +445,25 @@ version = "2.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fdeflate"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||||
|
dependencies = [
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -549,6 +607,16 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gif"
|
||||||
|
version = "0.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||||
|
dependencies = [
|
||||||
|
"color_quant",
|
||||||
|
"weezl",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "globset"
|
name = "globset"
|
||||||
version = "0.4.18"
|
version = "0.4.18"
|
||||||
@@ -821,6 +889,34 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"color_quant",
|
||||||
|
"gif",
|
||||||
|
"image-webp",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png",
|
||||||
|
"zune-core",
|
||||||
|
"zune-jpeg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -958,6 +1054,16 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -969,6 +1075,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multer"
|
name = "multer"
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
@@ -1001,6 +1117,15 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -1015,7 +1140,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe"
|
name = "openpxe"
|
||||||
version = "0.4.6"
|
version = "0.4.61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1037,7 +1162,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-core"
|
name = "openpxe-core"
|
||||||
version = "0.4.6"
|
version = "0.4.61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -1056,7 +1181,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-dhcp-proxy"
|
name = "openpxe-dhcp-proxy"
|
||||||
version = "0.4.6"
|
version = "0.4.61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1070,13 +1195,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-http-api"
|
name = "openpxe-http-api"
|
||||||
version = "0.4.6"
|
version = "0.4.61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"image",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
@@ -1100,7 +1226,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-ipxe-assets"
|
name = "openpxe-ipxe-assets"
|
||||||
version = "0.4.6"
|
version = "0.4.61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
@@ -1110,12 +1236,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-iso-store"
|
name = "openpxe-iso-store"
|
||||||
version = "0.4.6"
|
version = "0.4.61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"bytes",
|
"bytes",
|
||||||
"hex",
|
"hex",
|
||||||
|
"image",
|
||||||
"libc",
|
"libc",
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -1133,7 +1260,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-tftp"
|
name = "openpxe-tftp"
|
||||||
version = "0.4.6"
|
version = "0.4.61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1147,7 +1274,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-webui"
|
name = "openpxe-webui"
|
||||||
version = "0.4.6"
|
version = "0.4.61"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
@@ -1184,6 +1311,19 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1227,6 +1367,18 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -1484,6 +1636,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -2124,6 +2282,12 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weezl"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -2438,3 +2602,18 @@ name = "zmij"
|
|||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-core"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-jpeg"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||||
|
dependencies = [
|
||||||
|
"zune-core",
|
||||||
|
]
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.6"
|
version = "0.4.61"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -41,3 +41,6 @@ tower = { workspace = true }
|
|||||||
tempfile = "3.12"
|
tempfile = "3.12"
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
time = { workspace = true }
|
time = { workspace = true }
|
||||||
|
# v0.4.61: integration tests need to generate real PNG bytes for the
|
||||||
|
# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile.
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|||||||
+98
-34
@@ -193,23 +193,47 @@ async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>)
|
|||||||
// ─── UI ────────────────────────────────────────────────────────────────────
|
// ─── UI ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn index(State(state): State<AppState>) -> Response {
|
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,
|
header::CONTENT_TYPE,
|
||||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
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,
|
html,
|
||||||
)
|
)
|
||||||
.into_response()
|
.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 {
|
async fn ui_js() -> Response {
|
||||||
(
|
(
|
||||||
[(
|
[
|
||||||
|
(
|
||||||
header::CONTENT_TYPE,
|
header::CONTENT_TYPE,
|
||||||
HeaderValue::from_static("application/javascript"),
|
HeaderValue::from_static("application/javascript"),
|
||||||
)],
|
),
|
||||||
|
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||||
|
],
|
||||||
openpxe_webui::app_js(),
|
openpxe_webui::app_js(),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
@@ -217,7 +241,10 @@ async fn ui_js() -> Response {
|
|||||||
|
|
||||||
async fn ui_css() -> 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(),
|
openpxe_webui::app_css(),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
@@ -261,20 +288,29 @@ async fn ui_logo(State(state): State<AppState>) -> Response {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
(
|
(
|
||||||
[(
|
[
|
||||||
|
(
|
||||||
header::CONTENT_TYPE,
|
header::CONTENT_TYPE,
|
||||||
HeaderValue::from_static("image/svg+xml"),
|
HeaderValue::from_static("image/svg+xml"),
|
||||||
)],
|
),
|
||||||
|
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||||
|
],
|
||||||
openpxe_webui::logo_svg(),
|
openpxe_webui::logo_svg(),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v0.4.6: raster-only logo endpoint for the iPXE menu's
|
/// v0.4.61: PXE menu logo composed for the iPXE `console --picture`
|
||||||
/// `console --picture`. iPXE can't rasterize SVG, so SVG uploads 404
|
/// call. The operator can upload any raster image (PNG / JPEG / WebP /
|
||||||
/// here — the ASCII OpenPXE wordmark in `render_menu` already gives
|
/// GIF) of any aspect ratio; this handler decodes it, draws it
|
||||||
/// the operator a polished default. No bundled PNG fallback by design:
|
/// centered-top onto a fixed 1024×768 canvas, and returns PNG bytes.
|
||||||
/// either the operator's raster logo paints, or the text stands in.
|
/// 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 {
|
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
|
||||||
let Some(path) = state.branding.logo_path() else {
|
let Some(path) = state.branding.logo_path() else {
|
||||||
return (StatusCode::NOT_FOUND, "no custom logo configured").into_response();
|
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" {
|
if mime == "image/svg+xml" {
|
||||||
return (
|
return (
|
||||||
StatusCode::NOT_FOUND,
|
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();
|
.into_response();
|
||||||
}
|
}
|
||||||
match tokio::fs::read(&path).await {
|
let bytes = match tokio::fs::read(&path).await {
|
||||||
Ok(bytes) => {
|
Ok(b) => b,
|
||||||
let ct = HeaderValue::from_str(&mime)
|
Err(e) => {
|
||||||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
|
return (
|
||||||
(
|
|
||||||
[
|
|
||||||
(header::CONTENT_TYPE, ct),
|
|
||||||
(
|
|
||||||
header::CACHE_CONTROL,
|
|
||||||
HeaderValue::from_static("no-cache, max-age=0"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
bytes,
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
Err(e) => (
|
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
format!("custom logo unreadable: {e}"),
|
format!("custom logo unreadable: {e}"),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.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 {
|
async fn ui_loader() -> Response {
|
||||||
(
|
(
|
||||||
[(
|
[
|
||||||
|
(
|
||||||
header::CONTENT_TYPE,
|
header::CONTENT_TYPE,
|
||||||
HeaderValue::from_static("image/svg+xml"),
|
HeaderValue::from_static("image/svg+xml"),
|
||||||
)],
|
),
|
||||||
|
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||||
|
],
|
||||||
openpxe_webui::loader_svg(),
|
openpxe_webui::loader_svg(),
|
||||||
)
|
)
|
||||||
.into_response()
|
.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");
|
||||||
let _ = writeln!(s, "menu OpenPXE - network boot menu");
|
let _ = writeln!(s, "menu OpenPXE - network boot menu");
|
||||||
// Centered ASCII wordmark. iPXE menus are ~76 columns wide on the
|
// v0.4.61: we used to draw an ASCII OpenPXE wordmark here. Now
|
||||||
// default VGA text console; the lines below are padded to sit
|
// that the bundled iPXE is built with `IMAGE_PNG`, the
|
||||||
// approximately centered. `item --gap -- <text>` emits text without
|
// `console --picture` line at the top of this script paints the
|
||||||
// a selectable hotkey.
|
// operator's actual logo (composed server-side into a 1024×768
|
||||||
let _ = writeln!(s, "item --gap");
|
// canvas with the logo centered at the top) — the ASCII banner
|
||||||
let _ = writeln!(s, "item --gap -- ___ ___ __ __ ___");
|
// became visual noise *on top* of the real image. Old iPXE
|
||||||
let _ = writeln!(s, "item --gap -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|");
|
// builds without PNG fall through the `|| console` clause and
|
||||||
let _ = writeln!(s, "item --gap -- | (_) | '_ \\/ -_) ' \\ | _/ \\ / | _|");
|
// simply show the menu without a logo, which is the correct
|
||||||
let _ = writeln!(s, "item --gap -- \\___/| .__/\\___|_||_| |_| /_/\\_\\ |___|");
|
// graceful-degradation outcome.
|
||||||
let _ = writeln!(s, "item --gap -- |_|");
|
|
||||||
let _ = writeln!(s, "item --gap");
|
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
s,
|
s,
|
||||||
"item --gap -- ------------------------- Default -------------------------"
|
"item --gap -- ------------------------- Default -------------------------"
|
||||||
@@ -628,10 +626,12 @@ mod password_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn top_menu_has_polished_branding_and_arch_footer() {
|
fn top_menu_has_polished_branding_and_arch_footer() {
|
||||||
// v0.4.6 polish: a `console --picture` line for operator
|
// v0.4.6 polish + v0.4.61 image upgrade: the menu emits a
|
||||||
// logos, an ASCII OpenPXE wordmark visible across iPXE
|
// `console --picture` line that the bundled iPXE (built with
|
||||||
// builds (graphics or not), and a single-line footer carrying
|
// `IMAGE_PNG`) honours, plus an arch-resolved footer carrying
|
||||||
// the current OpenPXE version + the resolved arch label.
|
// 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 settings = Settings::default();
|
||||||
let s = render_menu(&[], &settings, "http://10.0.0.5");
|
let s = render_menu(&[], &settings, "http://10.0.0.5");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -641,11 +641,11 @@ mod password_tests {
|
|||||||
// Picture-or-text-console must be a single statement so older
|
// Picture-or-text-console must be a single statement so older
|
||||||
// iPXE parsers don't choke on the chain.
|
// iPXE parsers don't choke on the chain.
|
||||||
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
|
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
|
||||||
// ASCII wordmark — at least one of the banner lines must
|
// No ASCII wordmark — once the real PNG paints, the ASCII
|
||||||
// contain the trailing pipe segment, plus the leading "_"s.
|
// banner would duplicate the operator's logo visually.
|
||||||
assert!(
|
assert!(
|
||||||
s.contains("___ ___ __ __ ___"),
|
!s.contains("___ ___ __ __ ___"),
|
||||||
"ascii banner missing first row:\n{s}"
|
"ASCII banner shouldn't be emitted in v0.4.61+:\n{s}"
|
||||||
);
|
);
|
||||||
// Footer with version + arch interpolation. The version comes
|
// Footer with version + arch interpolation. The version comes
|
||||||
// from CARGO_PKG_VERSION at compile time.
|
// from CARGO_PKG_VERSION at compile time.
|
||||||
|
|||||||
@@ -1755,11 +1755,26 @@ async fn pxe_logo_404_when_no_custom_logo_configured() {
|
|||||||
assert!(text.contains("no custom logo"), "got: {text}");
|
assert!(text.contains("no custom logo"), "got: {text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a tiny valid PNG via the `image` crate. The v0.4.61 PXE-logo
|
||||||
|
/// compositor decodes whatever the operator uploaded — hand-rolled
|
||||||
|
/// PNGs with handwritten CRCs are too easy to break; let the encoder
|
||||||
|
/// produce something it can later decode.
|
||||||
|
fn tiny_png() -> Vec<u8> {
|
||||||
|
use image::{DynamicImage, ImageBuffer, ImageFormat, Rgb};
|
||||||
|
use std::io::Cursor;
|
||||||
|
let buf: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(8, 8, Rgb([0, 180, 220]));
|
||||||
|
let mut out = Vec::with_capacity(256);
|
||||||
|
DynamicImage::ImageRgb8(buf)
|
||||||
|
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pxe_logo_404_when_uploaded_logo_is_svg() {
|
async fn pxe_logo_404_when_uploaded_logo_is_svg() {
|
||||||
// iPXE can't rasterize SVG, so an SVG upload deliberately doesn't
|
// iPXE can't rasterize SVG, so an SVG upload deliberately doesn't
|
||||||
// light up the PXE menu's `console --picture` overlay — the ASCII
|
// light up the PXE menu's `console --picture` overlay — the menu
|
||||||
// wordmark in render_menu stands in instead.
|
// simply paints without a logo.
|
||||||
let (state, _dir) = build_state().await;
|
let (state, _dir) = build_state().await;
|
||||||
state
|
state
|
||||||
.branding
|
.branding
|
||||||
@@ -1777,11 +1792,15 @@ async fn pxe_logo_404_when_uploaded_logo_is_svg() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pxe_logo_serves_raster_with_correct_mime() {
|
async fn pxe_logo_composes_to_1024x768_png() {
|
||||||
|
// v0.4.61: the endpoint no longer serves the raw upload — it
|
||||||
|
// composes the operator's logo into a fixed 1024×768 canvas so
|
||||||
|
// the iPXE menu always paints at consistent dimensions.
|
||||||
let (state, _dir) = build_state().await;
|
let (state, _dir) = build_state().await;
|
||||||
|
let png = tiny_png();
|
||||||
state
|
state
|
||||||
.branding
|
.branding
|
||||||
.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake-png-bytes")
|
.set_logo("image/png", "png", &png)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
let res = app
|
let res = app
|
||||||
@@ -1805,7 +1824,14 @@ async fn pxe_logo_serves_raster_with_correct_mime() {
|
|||||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
// PNG signature.
|
||||||
assert!(body.starts_with(b"\x89PNG"), "PNG header missing");
|
assert!(body.starts_with(b"\x89PNG"), "PNG header missing");
|
||||||
|
// IHDR chunk lives at bytes 8..29; width is bytes 16..20, height
|
||||||
|
// 20..24 in big-endian u32. The composed canvas should be 1024×768.
|
||||||
|
let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]);
|
||||||
|
let height = u32::from_be_bytes([body[20], body[21], body[22], body[23]]);
|
||||||
|
assert_eq!(width, 1024, "compose should pin width to 1024");
|
||||||
|
assert_eq!(height, 768, "compose should pin height to 768");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1814,9 +1840,10 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
|
|||||||
// must stay reachable once the admin has been bootstrapped. The
|
// must stay reachable once the admin has been bootstrapped. The
|
||||||
// auth allowlist gates `/api/*` only.
|
// auth allowlist gates `/api/*` only.
|
||||||
let (state, _dir) = build_state().await;
|
let (state, _dir) = build_state().await;
|
||||||
|
let png = tiny_png();
|
||||||
state
|
state
|
||||||
.branding
|
.branding
|
||||||
.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake")
|
.set_logo("image/png", "png", &png)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
// Configure an admin so the middleware kicks in.
|
// Configure an admin so the middleware kicks in.
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ parking_lot.workspace = true
|
|||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
tempfile = "3.12"
|
tempfile = "3.12"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
# v0.4.61: server-side compose of the operator's uploaded raster into a
|
||||||
|
# fixed 1024x768 canvas so the PXE menu always gets a consistently-sized
|
||||||
|
# PNG regardless of what the operator uploaded. We use the bare-bones
|
||||||
|
# `image` crate (no default features) and explicitly enable only the
|
||||||
|
# decoders we accept on upload (PNG/JPEG/WebP/GIF) plus the PNG
|
||||||
|
# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc.
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.12"
|
tempfile = "3.12"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
pub mod entry;
|
pub mod entry;
|
||||||
pub mod introspect;
|
pub mod introspect;
|
||||||
pub mod nfs;
|
pub mod nfs;
|
||||||
|
pub mod pxe_logo;
|
||||||
pub mod smb;
|
pub mod smb;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod windows;
|
pub mod windows;
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! Operator-logo compositor for the iPXE menu.
|
||||||
|
//!
|
||||||
|
//! The brief: match iVentoy's polished centered-logo PXE chrome with
|
||||||
|
//! whatever raster the operator drops onto Settings → Branding. A wide
|
||||||
|
//! wordmark, a portrait stack, a square monogram — all three should
|
||||||
|
//! land in roughly the same place on the boot screen.
|
||||||
|
//!
|
||||||
|
//! Approach: decode the operator's upload, fit it into a fixed
|
||||||
|
//! 1024×768 canvas with the logo horizontally centered and pinned a
|
||||||
|
//! short margin from the top, re-encode as PNG, return the bytes. iPXE
|
||||||
|
//! built with `IMAGE_PNG` paints the result via `console --picture`.
|
||||||
|
//!
|
||||||
|
//! The 1024×768 size matches the default VESA framebuffer iPXE picks
|
||||||
|
//! on most BIOS/UEFI consoles. Operators uploading 4K logos get
|
||||||
|
//! correctly downscaled; tiny icons get drawn at their native size,
|
||||||
|
//! centered, with transparent margins.
|
||||||
|
//!
|
||||||
|
//! We deliberately don't ship `resvg` for SVG support — keeping the
|
||||||
|
//! dependency surface narrow matters more than supporting SVG-only
|
||||||
|
//! brand assets. The WebUI's logo stays SVG-native (the browser
|
||||||
|
//! rasterizes it); the PXE menu wants a raster regardless.
|
||||||
|
|
||||||
|
use image::imageops::FilterType;
|
||||||
|
use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
/// Canvas dimensions used for the composed PXE logo. Picked to match
|
||||||
|
/// the framebuffer dimensions iPXE picks on most BIOS/UEFI consoles —
|
||||||
|
/// gives a 1:1 paint with no scaling at the firmware layer.
|
||||||
|
pub const CANVAS_W: u32 = 1024;
|
||||||
|
pub const CANVAS_H: u32 = 768;
|
||||||
|
|
||||||
|
/// Maximum dimensions for the operator's logo inside the canvas. Any
|
||||||
|
/// upload larger than this in either axis is downscaled (preserving
|
||||||
|
/// aspect ratio) to fit. Smaller uploads paint at native size.
|
||||||
|
const LOGO_MAX_W: u32 = 600;
|
||||||
|
const LOGO_MAX_H: u32 = 200;
|
||||||
|
|
||||||
|
/// Top margin in pixels from the canvas's top edge to the logo's top
|
||||||
|
/// edge. Matches the visual rhythm of iVentoy's screen (logo at top,
|
||||||
|
/// menu below).
|
||||||
|
const LOGO_TOP_MARGIN: u32 = 64;
|
||||||
|
|
||||||
|
/// Compose `src_bytes` (any PNG/JPEG/WebP/GIF) into a centered-top
|
||||||
|
/// 1024×768 PNG and return the encoded bytes.
|
||||||
|
///
|
||||||
|
/// Errors when the source can't be decoded or the encoded buffer can't
|
||||||
|
/// be written (only really fires on out-of-memory; the encoder itself
|
||||||
|
/// is infallible for well-formed inputs).
|
||||||
|
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
|
||||||
|
let logo = image::load_from_memory(src_bytes)?;
|
||||||
|
// Resize-fit if the upload exceeds our bounding box. `Lanczos3`
|
||||||
|
// keeps the antialiasing crisp on the framebuffer console; it's a
|
||||||
|
// touch slower than `Triangle` but the operator hits this endpoint
|
||||||
|
// once per boot at most.
|
||||||
|
let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
|
||||||
|
let logo_rgba = logo.to_rgba8();
|
||||||
|
|
||||||
|
// Transparent canvas. iPXE 1.21+ honours alpha-channel transparency
|
||||||
|
// on framebuffer consoles; older builds simply draw the alpha as
|
||||||
|
// black, which still gives a sensible look.
|
||||||
|
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0]));
|
||||||
|
let logo_w = logo_rgba.width();
|
||||||
|
let logo_h = logo_rgba.height();
|
||||||
|
// Horizontal center, top-margin from the top. Saturating math
|
||||||
|
// means a logo wider than CANVAS_W (shouldn't happen after the
|
||||||
|
// downscale above, but defensive) just sits flush-left.
|
||||||
|
let off_x = CANVAS_W.saturating_sub(logo_w) / 2;
|
||||||
|
let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_h));
|
||||||
|
image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(64 * 1024);
|
||||||
|
DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage {
|
||||||
|
let (w, h) = (img.width(), img.height());
|
||||||
|
if w <= max_w && h <= max_h {
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
// Preserve aspect ratio. `resize` clamps to the smaller of the
|
||||||
|
// two scale factors so we never overshoot the bounding box.
|
||||||
|
img.resize(max_w, max_h, FilterType::Lanczos3)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use image::{ImageBuffer, Rgb};
|
||||||
|
|
||||||
|
fn solid_png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
|
||||||
|
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(w, h, Rgb(rgb));
|
||||||
|
let mut out = Vec::with_capacity(4096);
|
||||||
|
DynamicImage::ImageRgb8(img)
|
||||||
|
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_emits_canvas_sized_png() {
|
||||||
|
let src = solid_png(120, 60, [200, 50, 50]);
|
||||||
|
let out = compose_pxe_logo(&src).unwrap();
|
||||||
|
// Round-trip the output and confirm dimensions.
|
||||||
|
let img = image::load_from_memory(&out).unwrap();
|
||||||
|
assert_eq!(img.width(), CANVAS_W);
|
||||||
|
assert_eq!(img.height(), CANVAS_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn small_logo_centered_at_top_margin() {
|
||||||
|
let src = solid_png(100, 40, [10, 200, 10]);
|
||||||
|
let out = compose_pxe_logo(&src).unwrap();
|
||||||
|
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
|
||||||
|
// Pixel just inside the logo box should match the source color
|
||||||
|
// (alpha=255). Pixel near a far corner of the canvas should be
|
||||||
|
// the transparent background.
|
||||||
|
let cx = (CANVAS_W - 100) / 2;
|
||||||
|
let cy = LOGO_TOP_MARGIN;
|
||||||
|
let inside = canvas.get_pixel(cx + 10, cy + 10);
|
||||||
|
assert_eq!(inside.0[3], 255, "logo pixel should be opaque");
|
||||||
|
assert!(inside.0[0] < 100 && inside.0[1] > 100 && inside.0[2] < 100, "color mismatch: {inside:?}");
|
||||||
|
let corner = canvas.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
|
||||||
|
assert_eq!(corner.0[3], 0, "canvas corner should be transparent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversize_logo_is_downscaled_to_bounding_box() {
|
||||||
|
// 4000×800 image — bigger than LOGO_MAX_W and LOGO_MAX_H in
|
||||||
|
// both axes. After downscale the output must fit; we re-decode
|
||||||
|
// the canvas, count non-transparent pixels, and confirm none
|
||||||
|
// sit outside the expected band.
|
||||||
|
let src = solid_png(4000, 800, [50, 50, 200]);
|
||||||
|
let out = compose_pxe_logo(&src).unwrap();
|
||||||
|
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
|
||||||
|
// Span row at the top margin should have non-transparent
|
||||||
|
// pixels somewhere; rows past the LOGO_TOP_MARGIN + LOGO_MAX_H
|
||||||
|
// should be entirely transparent.
|
||||||
|
let bottom_band_y = LOGO_TOP_MARGIN + LOGO_MAX_H + 10;
|
||||||
|
for x in 0..CANVAS_W {
|
||||||
|
let p = canvas.get_pixel(x, bottom_band_y);
|
||||||
|
assert_eq!(p.0[3], 0, "row {bottom_band_y} should be transparent at x={x}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_bytes_returns_error_not_panic() {
|
||||||
|
let r = compose_pxe_logo(b"\xde\xad\xbe\xef not an image");
|
||||||
|
assert!(r.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,15 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<title>OpenPXE</title>
|
<title>OpenPXE</title>
|
||||||
<link rel="stylesheet" href="/assets/app.css" />
|
<!-- v0.4.61: the `?v=…` query string is replaced by the server at
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
|
request time with the running OpenPXE version. That guarantees a
|
||||||
|
fresh URL on every upgrade so browsers (and intermediary proxies)
|
||||||
|
can't keep serving stale JS / CSS / branding from before the
|
||||||
|
deploy. Combined with `Cache-Control: no-cache, must-revalidate`
|
||||||
|
on the asset handlers, the practical caching window is one
|
||||||
|
version. -->
|
||||||
|
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}" />
|
||||||
<!-- Theme is read from localStorage *before* paint to avoid the
|
<!-- Theme is read from localStorage *before* paint to avoid the
|
||||||
dark→light flash on every navigation. Falls back to the OS
|
dark→light flash on every navigation. Falls back to the OS
|
||||||
preference and finally to dark. -->
|
preference and finally to dark. -->
|
||||||
@@ -26,7 +33,7 @@
|
|||||||
<div class="shell">
|
<div class="shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<img src="/assets/logo.svg" alt="OpenPXE" />
|
<img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" />
|
||||||
<strong>OpenPXE</strong>
|
<strong>OpenPXE</strong>
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
@@ -59,7 +66,7 @@
|
|||||||
<!-- The brand badge at the top can be overridden by operator-uploaded
|
<!-- The brand badge at the top can be overridden by operator-uploaded
|
||||||
logos; keep "OpenPXE v…" pinned in the footer so the backend
|
logos; keep "OpenPXE v…" pinned in the footer so the backend
|
||||||
identity is always visible regardless of branding. -->
|
identity is always visible regardless of branding. -->
|
||||||
<div class="footer-version">OpenPXE v<span data-bind="version">0.4.6</span></div>
|
<div class="footer-version">OpenPXE v<span data-bind="version">0.4.61</span></div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -115,6 +122,6 @@
|
|||||||
<main class="main" id="view-root"></main>
|
<main class="main" id="view-root"></main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/assets/app.js"></script>
|
<script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+14
-4
@@ -7,11 +7,21 @@
|
|||||||
//! nav, top bar with secondary tabs, card-dense content panels.
|
//! nav, top bar with secondary tabs, card-dense content panels.
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
/// Render the top-level page. `base_url` is interpolated into the footer
|
/// Render the top-level page.
|
||||||
/// so operators can see at a glance what URL clients are PXE-booting from.
|
///
|
||||||
|
/// * `base_url` is interpolated into the footer so operators can see at
|
||||||
|
/// a glance what URL clients are PXE-booting from.
|
||||||
|
/// * `asset_version` is appended as `?v=…` to every asset URL so each
|
||||||
|
/// release ships with brand-new asset URLs — browsers (and any
|
||||||
|
/// intermediary proxy) can't keep serving last release's `app.js`
|
||||||
|
/// when we know the new one is incompatible. Combined with
|
||||||
|
/// `Cache-Control: no-cache, must-revalidate` on the asset handlers,
|
||||||
|
/// the worst-case caching window is one version.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn index_html(base_url: &str) -> String {
|
pub fn index_html(base_url: &str, asset_version: &str) -> String {
|
||||||
INDEX_HTML.replace("{{BASE_URL}}", base_url)
|
INDEX_HTML
|
||||||
|
.replace("{{BASE_URL}}", base_url)
|
||||||
|
.replace("{{ASSET_VERSION}}", asset_version)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|||||||
@@ -16,13 +16,79 @@
|
|||||||
|
|
||||||
ARG RUST_VERSION=1.95
|
ARG RUST_VERSION=1.95
|
||||||
|
|
||||||
########## fetch iPXE binaries ##########
|
########## fetch wimboot (and a sanity-check fetch of upstream iPXE) ##########
|
||||||
|
# v0.4.61: we no longer ship the boot.ipxe.org iPXE binaries directly;
|
||||||
|
# instead we build iPXE from source with IMAGE_PNG enabled (see the
|
||||||
|
# ipxe-build stage below). The fetch stage still pulls wimboot (a
|
||||||
|
# pre-signed binary from ipxe/wimboot's GitHub release) since that's
|
||||||
|
# unrelated to the PNG concern.
|
||||||
FROM debian:12-slim AS fetch
|
FROM debian:12-slim AS fetch
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh
|
RUN mkdir -p assets/ipxe && \
|
||||||
RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
|
curl --fail --silent --show-error --location \
|
||||||
|
-o assets/ipxe/wimboot \
|
||||||
|
https://github.com/ipxe/wimboot/releases/latest/download/wimboot \
|
||||||
|
|| echo "wimboot fetch failed; Windows toggle will stay disabled"
|
||||||
|
|
||||||
|
########## build iPXE from source with IMAGE_PNG enabled ##########
|
||||||
|
# This stage replaces the old "grab pre-built binaries from
|
||||||
|
# boot.ipxe.org" path. The shipped binaries there are built with the
|
||||||
|
# default config which omits `IMAGE_PNG`, so the `console --picture`
|
||||||
|
# call in render_menu silently no-ops — operator logos never paint.
|
||||||
|
# Building from source lets us flip the one flag we need.
|
||||||
|
#
|
||||||
|
# Cross-compilation: x86_64 + i386 use the native toolchain that ships
|
||||||
|
# in the rust:bookworm base; arm64 uses gcc-aarch64-linux-gnu. The four
|
||||||
|
# output binaries match the names openpxe-ipxe-assets expects in
|
||||||
|
# assets/ipxe/.
|
||||||
|
FROM rust:${RUST_VERSION}-bookworm AS ipxe-build
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
git build-essential liblzma-dev mtools genisoimage syslinux \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /build
|
||||||
|
# Pin to a recent iPXE master tip via shallow clone. iPXE doesn't tag
|
||||||
|
# releases; pinning the SHA in source would be a periodic chore. The
|
||||||
|
# tradeoff is that "rebuild the container" silently picks up upstream
|
||||||
|
# patches — for a boot loader this is the right side of the
|
||||||
|
# pin-vs-fresh tradeoff (we want CVE fixes ASAP and the PXE chain is
|
||||||
|
# the trusted base).
|
||||||
|
RUN git clone --depth=1 https://github.com/ipxe/ipxe.git ipxe
|
||||||
|
WORKDIR /build/ipxe/src
|
||||||
|
# Feature flags landed via the `config/local/` override files iPXE's
|
||||||
|
# config system reads after `config/general.h`. We enable just the
|
||||||
|
# image format + framebuffer console plumbing — everything else stays
|
||||||
|
# at the upstream default. `keep-debug` is off; `parserrors` is off; we
|
||||||
|
# pin a small set of useful tweaks.
|
||||||
|
RUN mkdir -p config/local \
|
||||||
|
&& printf '%s\n' \
|
||||||
|
'#define IMAGE_PNG' \
|
||||||
|
'#define CONSOLE_FRAMEBUFFER' \
|
||||||
|
'#define CONSOLE_VESAFB' \
|
||||||
|
'#define DOWNLOAD_PROTO_HTTPS' \
|
||||||
|
'#define NSLOOKUP_CMD' \
|
||||||
|
'#define NTP_CMD' \
|
||||||
|
> config/local/general.h
|
||||||
|
# Each arch builds to its own `bin-*` directory. We copy the four
|
||||||
|
# output binaries into /out/ with the names openpxe-ipxe-assets
|
||||||
|
# expects. Stripping the binaries saves ~30% — they go into the rust
|
||||||
|
# binary via include_bytes! so the savings ripple through the final
|
||||||
|
# image.
|
||||||
|
RUN mkdir -p /out && \
|
||||||
|
make -j"$(nproc)" bin/undionly.kpxe && \
|
||||||
|
cp bin/undionly.kpxe /out/undionly.kpxe && \
|
||||||
|
make -j"$(nproc)" bin-x86_64-efi/snponly.efi && \
|
||||||
|
cp bin-x86_64-efi/snponly.efi /out/snponly.efi && \
|
||||||
|
make -j"$(nproc)" bin-x86_64-efi/ipxe.efi && \
|
||||||
|
cp bin-x86_64-efi/ipxe.efi /out/ipxe.efi && \
|
||||||
|
make -j"$(nproc)" bin-i386-efi/snponly.efi && \
|
||||||
|
cp bin-i386-efi/snponly.efi /out/snponly-i386.efi && \
|
||||||
|
make -j"$(nproc)" CROSS_COMPILE=aarch64-linux-gnu- bin-arm64-efi/snponly.efi && \
|
||||||
|
cp bin-arm64-efi/snponly.efi /out/snponly-arm64.efi && \
|
||||||
|
ls -lh /out/
|
||||||
|
|
||||||
########## build openpxe ##########
|
########## build openpxe ##########
|
||||||
FROM rust:${RUST_VERSION}-bookworm AS build
|
FROM rust:${RUST_VERSION}-bookworm AS build
|
||||||
@@ -56,7 +122,11 @@ RUN apt-get update \
|
|||||||
# `cargo build`, which is slow and can exhaust small Colima/CI disks.
|
# `cargo build`, which is slow and can exhaust small Colima/CI disks.
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY crates/ crates/
|
COPY crates/ crates/
|
||||||
COPY --from=fetch /src/assets/ipxe /src/assets/ipxe
|
# v0.4.61: iPXE binaries come from our own source-built stage with
|
||||||
|
# IMAGE_PNG enabled. wimboot still comes from the fetch stage (it's
|
||||||
|
# from ipxe/wimboot's GitHub release, separately signed).
|
||||||
|
COPY --from=ipxe-build /out/ /src/assets/ipxe/
|
||||||
|
COPY --from=fetch /src/assets/ipxe/wimboot /src/assets/ipxe/wimboot
|
||||||
|
|
||||||
# Cache cargo registry + target across builds. The mtime touch is
|
# Cache cargo registry + target across builds. The mtime touch is
|
||||||
# belt-and-suspenders: cargo occasionally misses mtime-only changes on
|
# belt-and-suspenders: cargo occasionally misses mtime-only changes on
|
||||||
|
|||||||
Reference in New Issue
Block a user