This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// A single static asset: its embedded bytes and a content hash used to
|
||||||
|
/// version its URL.
|
||||||
|
pub struct StaticAsset {
|
||||||
|
pub data: &'static [u8],
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registry of the app's static assets, keyed by filename. Each asset's
|
||||||
|
/// content hash is computed once on first use and cached, so the versioned URL
|
||||||
|
/// changes automatically whenever the underlying file changes.
|
||||||
|
pub struct StaticAssetStore {
|
||||||
|
assets: HashMap<&'static str, StaticAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StaticAssetStore {
|
||||||
|
fn new(entries: &[(&'static str, &'static [u8])]) -> Self {
|
||||||
|
let assets = entries
|
||||||
|
.iter()
|
||||||
|
.map(|(name, data)| {
|
||||||
|
let hash = content_hash(data);
|
||||||
|
(*name, StaticAsset { data, hash })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Self { assets }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Looks up an asset by its filename (e.g. `"style.css"`).
|
||||||
|
pub fn get(&self, name: &str) -> Option<&StaticAsset> {
|
||||||
|
self.assets.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the versioned URL for an asset, e.g. `/static/style.css?v=<hash>`.
|
||||||
|
pub fn url(&self, name: &str) -> Option<String> {
|
||||||
|
self.assets
|
||||||
|
.get(name)
|
||||||
|
.map(|asset| format!("/static/{name}?v={}", asset.hash))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_hash(data: &[u8]) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data);
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs a `StaticAssetStore` from `name => path` pairs, embedding each
|
||||||
|
/// file's bytes at compile time via `include_bytes!`.
|
||||||
|
macro_rules! static_assets {
|
||||||
|
($($name:literal => $path:literal),* $(,)?) => {
|
||||||
|
StaticAssetStore::new(&[
|
||||||
|
$(($name, include_bytes!($path))),*
|
||||||
|
])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's static assets, loaded once on first use.
|
||||||
|
pub static STORE: LazyLock<StaticAssetStore> = LazyLock::new(|| {
|
||||||
|
static_assets! {
|
||||||
|
"style.css" => "../static/style.css",
|
||||||
|
"passkey-login.js" => "../static/passkey-login.js",
|
||||||
|
"passkey-register.js" => "../static/passkey-register.js",
|
||||||
|
"password-toggle.js" => "../static/password-toggle.js",
|
||||||
|
"htmx.min.js" => "../static/htmx.min.js",
|
||||||
|
"htmx-ws.min.js" => "../static/htmx-ws.min.js",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Returns the versioned URL for a known asset, panicking if the name isn't
|
||||||
|
/// registered (a programmer error, since these are compile-time constants).
|
||||||
|
pub fn url(name: &str) -> String {
|
||||||
|
STORE
|
||||||
|
.url(name)
|
||||||
|
.unwrap_or_else(|| panic!("unknown static asset: {name}"))
|
||||||
|
}
|
||||||
+17
-14
@@ -20,19 +20,13 @@ use thiserror::Error;
|
|||||||
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
|
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
|
||||||
use tracing::{Level, error, warn};
|
use tracing::{Level, error, warn};
|
||||||
|
|
||||||
|
use crate::assets;
|
||||||
use crate::domain::{DomainError, SessionUser};
|
use crate::domain::{DomainError, SessionUser};
|
||||||
use crate::ports::{HubEvent, RealtimeNotifier};
|
use crate::ports::{HubEvent, RealtimeNotifier};
|
||||||
use crate::services::{AuthService, InvitationService, ListService, MealService};
|
use crate::services::{AuthService, InvitationService, ListService, MealService};
|
||||||
use crate::views;
|
use crate::views;
|
||||||
use crate::webauthn::WebAuthnService;
|
use crate::webauthn::WebAuthnService;
|
||||||
|
|
||||||
/// The contents of `static/`, embedded into the binary at compile time so the
|
|
||||||
/// app can be shipped as a single executable without a separate static directory.
|
|
||||||
const STYLE_CSS: &[u8] = include_bytes!("../static/style.css");
|
|
||||||
const PASSKEY_LOGIN_JS: &[u8] = include_bytes!("../static/passkey-login.js");
|
|
||||||
const PASSKEY_REGISTER_JS: &[u8] = include_bytes!("../static/passkey-register.js");
|
|
||||||
const PASSWORD_TOGGLE_JS: &[u8] = include_bytes!("../static/password-toggle.js");
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub auth: Arc<AuthService>,
|
pub auth: Arc<AuthService>,
|
||||||
@@ -340,15 +334,24 @@ async fn home() -> Redirect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Serves a file embedded in the binary from the `static/` folder.
|
/// Serves a file embedded in the binary from the `static/` folder.
|
||||||
|
///
|
||||||
|
/// Every asset is served with an immutable, long-lived cache header. Because
|
||||||
|
/// the HTML references each asset under a URL that is versioned by its content
|
||||||
|
/// hash, a changed file gets a new URL and the cache is never stale.
|
||||||
async fn static_asset(Path(path): Path<String>) -> Response {
|
async fn static_asset(Path(path): Path<String>) -> Response {
|
||||||
let (mime, data) = match path.as_str() {
|
let Some(asset) = assets::STORE.get(&path) else {
|
||||||
"style.css" => ("text/css", STYLE_CSS),
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
"passkey-login.js" => ("application/javascript", PASSKEY_LOGIN_JS),
|
|
||||||
"passkey-register.js" => ("application/javascript", PASSKEY_REGISTER_JS),
|
|
||||||
"password-toggle.js" => ("application/javascript", PASSWORD_TOGGLE_JS),
|
|
||||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
|
||||||
};
|
};
|
||||||
([(header::CONTENT_TYPE, mime)], data).into_response()
|
let mime = match path.as_str() {
|
||||||
|
"style.css" => "text/css",
|
||||||
|
_ => "application/javascript",
|
||||||
|
};
|
||||||
|
let mut response = ([(header::CONTENT_TYPE, mime)], asset.data).into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||||
|
);
|
||||||
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn log_response_status(request: Request, next: Next) -> Response {
|
async fn log_response_status(request: Request, next: Next) -> Response {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod assets;
|
||||||
mod domain;
|
mod domain;
|
||||||
mod http;
|
mod http;
|
||||||
mod hub;
|
mod hub;
|
||||||
|
|||||||
+8
-8
@@ -37,8 +37,8 @@ pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup {
|
|||||||
button id="passkey-login" class="button button-secondary" type="button" { "Sign in with a passkey" }
|
button id="passkey-login" class="button button-secondary" type="button" { "Sign in with a passkey" }
|
||||||
p class="auth-switch" { "Need an account? " a href="/register" { "Create one" } }
|
p class="auth-switch" { "Need an account? " a href="/register" { "Create one" } }
|
||||||
}
|
}
|
||||||
script src="/static/password-toggle.js" {}
|
script src=(crate::assets::url("password-toggle.js")) {}
|
||||||
script src="/static/passkey-login.js" {}
|
script src=(crate::assets::url("passkey-login.js")) {}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ pub fn register_page(error: Option<&str>, invite: Option<&str>) -> Markup {
|
|||||||
}
|
}
|
||||||
p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } }
|
p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } }
|
||||||
}
|
}
|
||||||
script src="/static/password-toggle.js" {}
|
script src=(crate::assets::url("password-toggle.js")) {}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -164,8 +164,8 @@ pub fn account_page(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
script src="/static/password-toggle.js" {}
|
script src=(crate::assets::url("password-toggle.js")) {}
|
||||||
script src="/static/passkey-register.js" {}
|
script src=(crate::assets::url("passkey-register.js")) {}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1308,9 +1308,9 @@ fn page(title: &str, user: Option<&User>, content: Markup) -> Markup {
|
|||||||
meta charset="utf-8";
|
meta charset="utf-8";
|
||||||
meta name="viewport" content="width=device-width, initial-scale=1";
|
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||||
title { (title) " · Sustenance" }
|
title { (title) " · Sustenance" }
|
||||||
link rel="stylesheet" href="/static/style.css";
|
link rel="stylesheet" href=(crate::assets::url("style.css"));
|
||||||
script src="https://unpkg.com/htmx.org@2.0.10" {}
|
script src=(crate::assets::url("htmx.min.js")) {}
|
||||||
script src="https://unpkg.com/htmx-ext-ws@2.0.4/ws.js" {}
|
script src=(crate::assets::url("htmx-ws.min.js")) {}
|
||||||
}
|
}
|
||||||
body hx-boost="true" {
|
body hx-boost="true" {
|
||||||
header class="site-header" {
|
header class="site-header" {
|
||||||
|
|||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user