mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
refactor: bundle fonts (#1569)
This commit is contained in:
@@ -34,4 +34,5 @@ openapiv3 = "2.2.0"
|
||||
prettyplease = "0.2"
|
||||
progenitor = { version = "0.14.0", default-features = false }
|
||||
serde_json = "1"
|
||||
sha2 = "0.11.0"
|
||||
syn = "2"
|
||||
|
||||
@@ -24,6 +24,11 @@ RUN TAILWIND_OXIDE_VERSION="4.2.1" \
|
||||
|
||||
COPY Cargo.lock Cargo.lock
|
||||
COPY fluxer_admin fluxer_admin
|
||||
COPY packages/fonts/manifest.json packages/fonts/manifest.json
|
||||
COPY packages/fonts/NOTICE.md packages/fonts/NOTICE.md
|
||||
COPY packages/fonts/LICENSE-IBM-PLEX.txt packages/fonts/LICENSE-IBM-PLEX.txt
|
||||
COPY packages/fonts/files/FluxerSans packages/fonts/files/FluxerSans
|
||||
COPY packages/fonts/files/FluxerMono packages/fonts/files/FluxerMono
|
||||
RUN printf '%s\n' \
|
||||
'[workspace]' \
|
||||
'members = ["fluxer_admin"]' \
|
||||
@@ -42,6 +47,10 @@ RUN cargo build --release -p fluxer_admin \
|
||||
RUN test -s target/release/build/fluxer_admin-*/out/static/app.css \
|
||||
&& echo "Tailwind CSS compiled successfully"
|
||||
|
||||
RUN test "$(ls target/release/build/fluxer_admin-*/out/static/fonts/*.woff2 | wc -l)" -eq 12 \
|
||||
&& ls target/release/build/fluxer_admin-*/out/static/fonts/fonts.*.css \
|
||||
&& echo "Latin-core fonts bundled successfully"
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
ARG BUILD_VERSION=""
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const BUNDLED_FAMILIES: &[&str] = &["FluxerSans", "FluxerMono"];
|
||||
|
||||
const BUNDLED_WEIGHTS: &[u64] = &[400, 500, 600, 700];
|
||||
|
||||
const EXPECTED_FACE_COUNT: usize = 16;
|
||||
|
||||
fn main() {
|
||||
let manifest_dir =
|
||||
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR missing"));
|
||||
@@ -15,6 +22,7 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=openapi-admin.json");
|
||||
|
||||
generate_admin_api(&manifest_dir, &out_dir);
|
||||
build_fonts(&manifest_dir, &out_dir);
|
||||
build_tailwind(&manifest_dir, &out_dir);
|
||||
}
|
||||
|
||||
@@ -46,6 +54,213 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
|
||||
fs::write(&output_path, content).expect("failed to write generated API code");
|
||||
}
|
||||
|
||||
struct Face {
|
||||
css_family: String,
|
||||
weight: u64,
|
||||
style: String,
|
||||
source: String,
|
||||
}
|
||||
|
||||
struct Asset {
|
||||
name: String,
|
||||
content_type: &'static str,
|
||||
}
|
||||
|
||||
fn build_fonts(manifest_dir: &Path, out_dir: &Path) {
|
||||
println!("cargo:rerun-if-changed=../packages/fonts/manifest.json");
|
||||
println!("cargo:rerun-if-changed=../packages/fonts/files/FluxerSans");
|
||||
println!("cargo:rerun-if-changed=../packages/fonts/files/FluxerMono");
|
||||
println!("cargo:rerun-if-changed=../packages/fonts/NOTICE.md");
|
||||
println!("cargo:rerun-if-changed=../packages/fonts/LICENSE-IBM-PLEX.txt");
|
||||
|
||||
let package_dir = manifest_dir.join("../packages/fonts");
|
||||
let fonts_dir = out_dir.join("static").join("fonts");
|
||||
let _ = fs::remove_dir_all(&fonts_dir);
|
||||
fs::create_dir_all(&fonts_dir).expect("failed to create generated font dir");
|
||||
|
||||
let mut assets = Vec::new();
|
||||
let notice = emit_asset(
|
||||
&fonts_dir,
|
||||
&package_dir.join("NOTICE.md"),
|
||||
"text/plain; charset=utf-8",
|
||||
&mut assets,
|
||||
);
|
||||
let plex_license = emit_asset(
|
||||
&fonts_dir,
|
||||
&package_dir.join("LICENSE-IBM-PLEX.txt"),
|
||||
"text/plain; charset=utf-8",
|
||||
&mut assets,
|
||||
);
|
||||
|
||||
let faces = select_faces(&package_dir);
|
||||
assert_eq!(
|
||||
faces.len(),
|
||||
EXPECTED_FACE_COUNT,
|
||||
"packages/fonts no longer offers the {} Latin-core faces fluxer_admin renders; \
|
||||
reconcile BUNDLED_FAMILIES/BUNDLED_WEIGHTS with the manifest",
|
||||
EXPECTED_FACE_COUNT
|
||||
);
|
||||
|
||||
let mut stylesheet = String::from("/* SPDX-License-Identifier: AGPL-3.0-or-later */\n");
|
||||
stylesheet.push_str(
|
||||
"/* @generated by fluxer_admin/build.rs from packages/fonts. Do not edit by hand. */\n\n",
|
||||
);
|
||||
stylesheet.push_str(&format!(
|
||||
"/*\n\
|
||||
\x20* IBM Plex is licensed under the SIL Open Font License 1.1.\n\
|
||||
\x20* The IBM Plex faces below are Modified Versions renamed to \"Fluxer Sans\", so OFL\n\
|
||||
\x20* clause 3 requires the disclosure to travel with them. It is served beside them:\n\
|
||||
\x20* ./{notice}\n\
|
||||
\x20* ./{plex_license}\n\
|
||||
\x20*\n\
|
||||
\x20* Every url() below is relative, so it resolves against this stylesheet's own\n\
|
||||
\x20* directory. That keeps the sheet correct under any FLUXER_ADMIN_BASE_PATH without\n\
|
||||
\x20* the build having to know the runtime base path.\n\
|
||||
\x20*/\n"
|
||||
));
|
||||
for face in &faces {
|
||||
let source = package_dir.join("files").join(&face.source);
|
||||
let file = emit_asset(&fonts_dir, &source, "font/woff2", &mut assets);
|
||||
stylesheet.push_str(&format!(
|
||||
"@font-face {{\n\
|
||||
\tfont-family: '{}';\n\
|
||||
\tsrc: url('{file}') format('woff2');\n\
|
||||
\tfont-weight: {};\n\
|
||||
\tfont-style: {};\n\
|
||||
\tfont-display: swap;\n\
|
||||
}}\n",
|
||||
face.css_family, face.weight, face.style
|
||||
));
|
||||
}
|
||||
|
||||
let stylesheet_name = write_hashed(
|
||||
&fonts_dir,
|
||||
"fonts.css",
|
||||
stylesheet.as_bytes(),
|
||||
"text/css; charset=utf-8",
|
||||
&mut assets,
|
||||
);
|
||||
|
||||
write_font_asset_table(out_dir, &stylesheet_name, &assets);
|
||||
}
|
||||
|
||||
fn select_faces(package_dir: &Path) -> Vec<Face> {
|
||||
let manifest_path = package_dir.join("manifest.json");
|
||||
let raw = fs::read_to_string(&manifest_path).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read {}: {err}. packages/fonts is generated by \
|
||||
`python3 tools/fonts/build_fonts.py`.",
|
||||
manifest_path.display()
|
||||
)
|
||||
});
|
||||
let manifest: serde_json::Value =
|
||||
serde_json::from_str(&raw).expect("failed to parse packages/fonts/manifest.json");
|
||||
let families = manifest["families"]
|
||||
.as_array()
|
||||
.expect("packages/fonts/manifest.json has no families array");
|
||||
|
||||
let mut faces = Vec::new();
|
||||
for wanted in BUNDLED_FAMILIES {
|
||||
let family = families
|
||||
.iter()
|
||||
.find(|family| family["id"].as_str() == Some(wanted))
|
||||
.unwrap_or_else(|| panic!("packages/fonts/manifest.json has no family {wanted}"));
|
||||
assert_eq!(
|
||||
family["latinCore"].as_bool(),
|
||||
Some(true),
|
||||
"{wanted} is no longer a Latin-core family; it would need unicode-range gating"
|
||||
);
|
||||
let css_family = family["cssFamily"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{wanted} has no cssFamily"))
|
||||
.to_owned();
|
||||
for face in family["faces"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("{wanted} has no faces array"))
|
||||
{
|
||||
let weight = face["weight"].as_u64().expect("face has no weight");
|
||||
if !BUNDLED_WEIGHTS.contains(&weight) {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
face["unicodeRange"].is_null(),
|
||||
"{wanted} face {} carries a unicode-range; Latin-core faces must not",
|
||||
face["file"]
|
||||
);
|
||||
faces.push(Face {
|
||||
css_family: css_family.clone(),
|
||||
weight,
|
||||
style: face["style"]
|
||||
.as_str()
|
||||
.expect("face has no style")
|
||||
.to_owned(),
|
||||
source: face["file"].as_str().expect("face has no file").to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
faces
|
||||
}
|
||||
|
||||
fn emit_asset(
|
||||
fonts_dir: &Path,
|
||||
source: &Path,
|
||||
content_type: &'static str,
|
||||
assets: &mut Vec<Asset>,
|
||||
) -> String {
|
||||
let bytes =
|
||||
fs::read(source).unwrap_or_else(|err| panic!("failed to read {}: {err}", source.display()));
|
||||
let file_name = source
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_else(|| panic!("{} has no file name", source.display()));
|
||||
write_hashed(fonts_dir, file_name, &bytes, content_type, assets)
|
||||
}
|
||||
|
||||
fn write_hashed(
|
||||
fonts_dir: &Path,
|
||||
file_name: &str,
|
||||
bytes: &[u8],
|
||||
content_type: &'static str,
|
||||
assets: &mut Vec<Asset>,
|
||||
) -> String {
|
||||
let (stem, extension) = file_name
|
||||
.rsplit_once('.')
|
||||
.unwrap_or_else(|| panic!("{file_name} has no extension to hash around"));
|
||||
let digest = Sha256::digest(bytes);
|
||||
let hash: String = digest
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
let name = format!("{stem}.{hash}.{extension}");
|
||||
fs::write(fonts_dir.join(&name), bytes)
|
||||
.unwrap_or_else(|err| panic!("failed to write {name}: {err}"));
|
||||
assets.push(Asset {
|
||||
name: name.clone(),
|
||||
content_type,
|
||||
});
|
||||
name
|
||||
}
|
||||
|
||||
fn write_font_asset_table(out_dir: &Path, stylesheet_name: &str, assets: &[Asset]) {
|
||||
let mut generated = String::from(
|
||||
"// @generated by fluxer_admin/build.rs from packages/fonts. Do not edit by hand.\n\n",
|
||||
);
|
||||
generated.push_str(&format!(
|
||||
"/// File name of the content-hashed `@font-face` stylesheet, relative to `/static/fonts/`.\npub const STYLESHEET_FILE_NAME: &str = {stylesheet_name:?};\n\n"
|
||||
));
|
||||
generated.push_str("/// `(file name, content type, bytes)` for everything served under `/static/fonts/`.\npub static ASSETS: &[(&str, &str, &[u8])] = &[\n");
|
||||
for asset in assets {
|
||||
generated.push_str(&format!(
|
||||
" ({:?}, {:?}, include_bytes!(concat!(env!(\"OUT_DIR\"), \"/static/fonts/{}\"))),\n",
|
||||
asset.name, asset.content_type, asset.name
|
||||
));
|
||||
}
|
||||
generated.push_str("];\n");
|
||||
fs::write(out_dir.join("static").join("fonts.rs"), generated)
|
||||
.expect("failed to write generated font asset table");
|
||||
}
|
||||
|
||||
fn build_tailwind(manifest_dir: &Path, out_dir: &Path) {
|
||||
let output_dir = out_dir.join("static");
|
||||
fs::create_dir_all(&output_dir).expect("failed to create generated static dir");
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/static/fonts.rs"));
|
||||
|
||||
pub fn asset(file_name: &str) -> Option<(&'static str, &'static [u8])> {
|
||||
ASSETS
|
||||
.iter()
|
||||
.find(|(name, _, _)| *name == file_name)
|
||||
.map(|(_, content_type, bytes)| (*content_type, *bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stylesheet_is_served_and_content_hashed() {
|
||||
let (content_type, bytes) =
|
||||
asset(STYLESHEET_FILE_NAME).expect("the generated stylesheet must be servable");
|
||||
assert_eq!(content_type, "text/css; charset=utf-8");
|
||||
let css = std::str::from_utf8(bytes).expect("stylesheet must be UTF-8");
|
||||
assert!(css.contains("font-family: 'Fluxer Sans'"));
|
||||
assert!(css.contains("font-family: 'Fluxer Mono'"));
|
||||
assert!(
|
||||
!css.contains("?v="),
|
||||
"content hashing replaces cache-bust tokens"
|
||||
);
|
||||
assert!(
|
||||
!css.contains("fluxerstatic"),
|
||||
"fonts must not be fetched from the static CDN"
|
||||
);
|
||||
assert!(
|
||||
STYLESHEET_FILE_NAME.starts_with("fonts.") && STYLESHEET_FILE_NAME.ends_with(".css"),
|
||||
"unexpected stylesheet name {STYLESHEET_FILE_NAME}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_face_the_stylesheet_references_is_served() {
|
||||
let (_, bytes) = asset(STYLESHEET_FILE_NAME).expect("stylesheet");
|
||||
let css = std::str::from_utf8(bytes).expect("stylesheet must be UTF-8");
|
||||
let mut referenced = 0;
|
||||
for fragment in css.split("url('").skip(1) {
|
||||
let file_name = fragment.split('\'').next().expect("unterminated url()");
|
||||
let (content_type, _) = asset(file_name)
|
||||
.unwrap_or_else(|| panic!("stylesheet references unserved font {file_name}"));
|
||||
assert_eq!(content_type, "font/woff2");
|
||||
referenced += 1;
|
||||
}
|
||||
assert_eq!(referenced, 16, "expected the 16 bundled Latin-core faces");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ofl_attribution_ships_with_the_binaries() {
|
||||
let notice = ASSETS
|
||||
.iter()
|
||||
.find(|(name, _, _)| name.starts_with("NOTICE.") && name.ends_with(".md"))
|
||||
.expect("the OFL modification disclosure must ship with the modified fonts");
|
||||
assert!(!notice.2.is_empty());
|
||||
assert!(
|
||||
ASSETS
|
||||
.iter()
|
||||
.any(|(name, _, _)| name.starts_with("LICENSE-IBM-PLEX."))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_files_are_not_served() {
|
||||
assert!(asset("fonts.css").is_none());
|
||||
assert!(asset("../../../etc/passwd").is_none());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod acl;
|
||||
pub mod admin_flags;
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod fonts;
|
||||
pub mod middleware;
|
||||
pub mod oauth2;
|
||||
pub mod routes;
|
||||
|
||||
@@ -30,17 +30,25 @@ pub struct ActionQuery {
|
||||
}
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Request, State},
|
||||
extract::{Path, Request, State},
|
||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
|
||||
middleware::{Next, from_fn, from_fn_with_state},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
use tower_http::{
|
||||
compression::{
|
||||
CompressionLayer,
|
||||
predicate::{DefaultPredicate, NotForContentType, Predicate},
|
||||
},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
const APP_CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/static/app.css"));
|
||||
const HTMX_JS: &str = include_str!("../../static/htmx.min.js");
|
||||
|
||||
const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
|
||||
|
||||
const STRICT_TRANSPORT_SECURITY_VALUE: &str = "max-age=31536000; includeSubDomains; preload";
|
||||
const REFERRER_POLICY_VALUE: &str = "strict-origin-when-cross-origin";
|
||||
const X_FRAME_OPTIONS_VALUE: &str = "DENY";
|
||||
@@ -76,6 +84,7 @@ pub fn build_router(config: AdminConfig) -> Router {
|
||||
.route("/_health", get(health))
|
||||
.route("/robots.txt", get(robots_txt))
|
||||
.route("/static/app.css", get(app_css))
|
||||
.route("/static/fonts/{file_name}", get(font_asset))
|
||||
.route("/static/htmx.min.js", get(htmx_js))
|
||||
.merge(auth::router())
|
||||
.merge(protected)
|
||||
@@ -85,7 +94,10 @@ pub fn build_router(config: AdminConfig) -> Router {
|
||||
security_headers_middleware,
|
||||
))
|
||||
.layer(from_fn(cache_headers_middleware))
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(
|
||||
CompressionLayer::new()
|
||||
.compress_when(DefaultPredicate::new().and(NotForContentType::const_new("font/"))),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -129,9 +141,9 @@ fn build_admin_csp(config: &AdminConfig) -> String {
|
||||
[
|
||||
"default-src 'self'".to_owned(),
|
||||
"script-src 'self' 'unsafe-inline'".to_owned(),
|
||||
format!("style-src 'self' 'unsafe-inline' {static_cdn}"),
|
||||
"style-src 'self' 'unsafe-inline'".to_owned(),
|
||||
format!("img-src 'self' data: blob: {static_cdn} {media} https://fluxer-reports.ewr1.vultrobjects.com"),
|
||||
format!("font-src 'self' data: {static_cdn}"),
|
||||
"font-src 'self'".to_owned(),
|
||||
"connect-src 'self'".to_owned(),
|
||||
"object-src 'none'".to_owned(),
|
||||
"frame-src 'none'".to_owned(),
|
||||
@@ -193,6 +205,20 @@ async fn app_css() -> impl IntoResponse {
|
||||
([(header::CONTENT_TYPE, "text/css; charset=utf-8")], APP_CSS)
|
||||
}
|
||||
|
||||
async fn font_asset(Path(file_name): Path<String>) -> Response {
|
||||
match crate::fonts::asset(&file_name) {
|
||||
Some((content_type, bytes)) => (
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CACHE_CONTROL, IMMUTABLE_CACHE_CONTROL),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response(),
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn htmx_js() -> impl IntoResponse {
|
||||
(
|
||||
[(
|
||||
@@ -232,6 +258,7 @@ async fn not_found(State(state): State<AppState>) -> impl IntoResponse {
|
||||
meta charset="utf-8";
|
||||
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||
title { "404 - Not Found" }
|
||||
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
|
||||
link rel="stylesheet" href={(base) "/static/app.css"};
|
||||
}
|
||||
body class="min-h-screen bg-neutral-50 flex items-center justify-center" {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
@theme {
|
||||
--font-sans: "Fluxer Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: "Fluxer Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
--color-brand-primary: hsl(242 70% 55%);
|
||||
--color-brand-primary-dark: hsl(242 70% 47%);
|
||||
--color-brand-primary-rgb: 93 79 192;
|
||||
@@ -13,7 +13,6 @@
|
||||
|
||||
:root {
|
||||
--brand-primary: hsl(242 70% 55%);
|
||||
--font-bricolage: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif;
|
||||
--focus-ring-color: hsl(242 70% 55% / 0.45);
|
||||
--focus-ring-offset: #ffffff;
|
||||
}
|
||||
|
||||
@@ -70,8 +70,7 @@ pub fn admin_layout_ext(
|
||||
meta http-equiv="refresh" content="3";
|
||||
}
|
||||
title { (title) " ~ Fluxer Admin" }
|
||||
link rel="stylesheet" href={(config.static_cdn_endpoint) "/fonts/ibm-plex.css?v=3"};
|
||||
link rel="stylesheet" href={(config.static_cdn_endpoint) "/fonts/bricolage.css?v=3"};
|
||||
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
|
||||
link rel="stylesheet" href=(cache_busted_asset(base, asset_version, "/static/app.css"));
|
||||
link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"};
|
||||
link rel="apple-touch-icon" href={(config.static_cdn_endpoint) "/web/apple-touch-icon.png"};
|
||||
|
||||
@@ -12,8 +12,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
|
||||
meta charset="UTF-8";
|
||||
meta name="viewport" content="width=device-width, initial-scale=1.0";
|
||||
title { "Login ~ Fluxer Admin" }
|
||||
link rel="stylesheet" href={(config.static_cdn_endpoint) "/fonts/ibm-plex.css?v=3"};
|
||||
link rel="stylesheet" href={(config.static_cdn_endpoint) "/fonts/bricolage.css?v=3"};
|
||||
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
|
||||
link rel="stylesheet" href={(base) "/static/app.css"};
|
||||
link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"};
|
||||
}
|
||||
|
||||
@@ -573,6 +573,84 @@ async fn creating_registration_url_swaps_copyable_url_list_fragment() {
|
||||
assert!(toast.contains("Registration URL created"), "{toast}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fonts_are_served_locally_content_hashed_and_immutable() {
|
||||
let app = setup().await;
|
||||
|
||||
let stylesheet_path = format!(
|
||||
"/static/fonts/{}",
|
||||
fluxer_admin::fonts::STYLESHEET_FILE_NAME
|
||||
);
|
||||
let (headers, css) = get_with_headers(&app, &stylesheet_path, &[]).await;
|
||||
assert_eq!(
|
||||
headers.get(header::CACHE_CONTROL).unwrap(),
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
|
||||
for fragment in css.split("url('").skip(1) {
|
||||
let file_name = fragment.split('\'').next().unwrap();
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/static/fonts/{file_name}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"missing font {file_name}"
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers().get(header::CONTENT_TYPE).unwrap(),
|
||||
"font/woff2"
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers().get(header::CACHE_CONTROL).unwrap(),
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
}
|
||||
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/static/fonts/does-not-exist.woff2")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rendered_heads_never_reference_the_static_cdn_for_fonts() {
|
||||
let app = setup().await;
|
||||
|
||||
let (headers, page) = get_with_headers(&app, "/users", &[]).await;
|
||||
assert!(
|
||||
!page.contains("/fonts/ibm-plex.css"),
|
||||
"the admin layout still links the CDN font stylesheets"
|
||||
);
|
||||
assert!(page.contains("/static/fonts/"), "{page}");
|
||||
|
||||
let csp = headers
|
||||
.get(header::CONTENT_SECURITY_POLICY)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("missing CSP");
|
||||
assert!(csp.contains("font-src 'self';"), "font-src was {csp}");
|
||||
assert!(
|
||||
csp.contains("style-src 'self' 'unsafe-inline';"),
|
||||
"style-src was {csp}"
|
||||
);
|
||||
}
|
||||
|
||||
struct SearchCase {
|
||||
path: &'static str,
|
||||
result_target: &'static str,
|
||||
|
||||
Reference in New Issue
Block a user