Files
brk/crates/brk_website/src/website.rs
2026-03-04 17:10:15 +01:00

275 lines
8.4 KiB
Rust

use std::{
fs,
hash::{DefaultHasher, Hash, Hasher},
path::{Path, PathBuf},
str::FromStr,
sync::OnceLock,
};
use importmap::ImportMap;
use include_dir::{Dir, include_dir};
use serde::{Deserialize, Serialize};
use tracing::{error, info};
use crate::{Error, Result};
/// Embedded website assets
pub static EMBEDDED_WEBSITE: Dir = include_dir!("$CARGO_MANIFEST_DIR/website");
struct CachedIndex {
html: Vec<u8>,
etag: String,
}
/// Cached index.html with importmap injected
static INDEX_HTML: OnceLock<CachedIndex> = OnceLock::new();
/// Website configuration:
/// - `true` or omitted: serve embedded website
/// - `false`: disable website serving
/// - `"/path/to/website"`: serve custom website from path
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum Website {
Disabled,
#[default]
Default,
Filesystem(PathBuf),
}
impl Website {
pub fn is_enabled(&self) -> bool {
!matches!(self, Self::Disabled)
}
/// Returns the cached index.html etag (None in debug mode or before first request)
pub fn index_etag(&self) -> Option<&str> {
if cfg!(debug_assertions) {
return None;
}
INDEX_HTML.get().map(|cached| cached.etag.as_str())
}
/// Returns the filesystem path if available, None means use embedded
pub fn filesystem_path(&self) -> Option<PathBuf> {
match self {
Self::Disabled => None,
Self::Default => {
if cfg!(debug_assertions) {
let local = PathBuf::from("./website");
local.exists().then_some(local)
} else {
None
}
}
Self::Filesystem(p) => Some(p.clone()),
}
}
/// Get file content by path (handles hash-stripping, SPA fallback, importmap)
///
/// Returns an error if the website is disabled.
pub fn get_file(&self, path: &str) -> Result<Vec<u8>> {
if !self.is_enabled() {
return Err(Error::not_found("Website is disabled"));
}
match self.filesystem_path() {
None => self.get_embedded(path),
Some(base) => self.get_filesystem(&base, path),
}
}
/// Log which website source is being used (call once at startup)
pub fn log(&self) {
match self {
Self::Disabled => info!("website: disabled"),
Self::Default => {
if let Some(p) = self.filesystem_path() {
info!("website: filesystem ({})", p.display());
} else {
info!("website: embedded");
}
}
Self::Filesystem(p) => info!("website: filesystem ({})", p.display()),
}
}
fn get_index(&self) -> Result<Vec<u8>> {
// Debug mode: no importmap, no cache
if cfg!(debug_assertions) {
return match self.filesystem_path() {
Some(base) => {
fs::read(base.join("index.html")).map_err(|e| Error::not_found(e.to_string()))
}
None => {
let file = EMBEDDED_WEBSITE
.get_file("index.html")
.expect("index.html must exist in embedded website");
Ok(file.contents().to_vec())
}
};
}
// Release mode: cache with importmap
let cached = INDEX_HTML.get_or_init(|| {
let html = match self.filesystem_path() {
None => {
let file = EMBEDDED_WEBSITE
.get_file("index.html")
.expect("index.html must exist in embedded website");
let html = std::str::from_utf8(file.contents())
.expect("index.html must be valid UTF-8");
let importmap = ImportMap::scan_embedded(&EMBEDDED_WEBSITE, "");
importmap
.transform_html(html)
.unwrap_or_else(|| html.to_string())
}
Some(base) => {
let html =
fs::read_to_string(base.join("index.html")).expect("index.html must exist");
match ImportMap::scan(&base, "") {
Ok(importmap) => importmap.transform_html(&html).unwrap_or(html),
Err(e) => {
error!("Failed to scan for importmap: {e}");
html
}
}
}
};
let mut hasher = DefaultHasher::new();
html.hash(&mut hasher);
let etag = format!("\"{}\"", hasher.finish());
CachedIndex {
html: html.into_bytes(),
etag,
}
});
Ok(cached.html.clone())
}
fn get_embedded(&self, path: &str) -> Result<Vec<u8>> {
// Index.html
if path.is_empty() || path == "index.html" {
return self.get_index();
}
// Try direct lookup, then with hash stripped
let file = EMBEDDED_WEBSITE.get_file(path).or_else(|| {
ImportMap::strip_hash(Path::new(path))
.and_then(|unhashed| EMBEDDED_WEBSITE.get_file(unhashed.to_str()?))
});
if let Some(file) = file {
return Ok(file.contents().to_vec());
}
// SPA fallback: no extension -> index.html
if Path::new(path).extension().is_none() {
return self.get_index();
}
Err(Error::not_found("File not found"))
}
fn get_filesystem(&self, base: &Path, path: &str) -> Result<Vec<u8>> {
// Index.html
if path.is_empty() {
return self.get_index();
}
let mut file_path = base.join(path);
// Try with hash stripped
if !file_path.exists()
&& let Some(unhashed) = ImportMap::strip_hash(&file_path)
&& unhashed.exists()
{
file_path = unhashed;
}
// SPA fallback or missing file
if !file_path.exists() || file_path.is_dir() {
if file_path.extension().is_some() {
return Err(Error::not_found("File not found"));
}
return self.get_index();
}
// Explicit index.html request
if file_path.file_name().is_some_and(|n| n == "index.html") {
return self.get_index();
}
fs::read(&file_path).map_err(|e| {
error!("{e}");
Error::not_found("File not found")
})
}
}
impl FromStr for Website {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(match s.to_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Self::Default,
"false" | "0" | "no" | "off" => Self::Disabled,
_ => Self::Filesystem(PathBuf::from(s)),
})
}
}
impl Serialize for Website {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
match self {
Self::Disabled => serializer.serialize_bool(false),
Self::Default => serializer.serialize_bool(true),
Self::Filesystem(p) => p.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for Website {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
use serde::de::{self, Visitor};
struct WebsiteVisitor;
impl<'de> Visitor<'de> for WebsiteVisitor {
type Value = Website;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a boolean or a path string")
}
fn visit_bool<E: de::Error>(self, v: bool) -> std::result::Result<Self::Value, E> {
Ok(if v {
Website::Default
} else {
Website::Disabled
})
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
Ok(Website::Filesystem(PathBuf::from(v)))
}
fn visit_string<E: de::Error>(self, v: String) -> std::result::Result<Self::Value, E> {
Ok(Website::Filesystem(PathBuf::from(v)))
}
}
deserializer.deserialize_any(WebsiteVisitor)
}
}