use axum::{
Router,
body::Body,
extract::{self, OriginalUri, State, ws::WebSocketUpgrade},
http::{HeaderValue, StatusCode, header},
response::{IntoResponse, Json, Response},
routing::{get, post},
};
use futures_util::{SinkExt, StreamExt};
use percent_encoding::percent_decode_str;
use std::{net::SocketAddr, path::Path, path::PathBuf, sync::Arc};
use tokio::sync::broadcast;
use crate::config::{SortField, TagSource};
use crate::embedded_katex;
use crate::embedded_pico;
use crate::errors::{MbrError, ServerError};
use crate::link_grep::InboundLinkCache;
use crate::link_index::{LinkCache, resolve_outbound_links};
use crate::link_transform::LinkTransformConfig;
use crate::oembed_cache::OembedCache;
use crate::path_resolver::{PathResolverConfig, ResolvedPath, resolve_request_path};
use crate::repo::MarkdownInfo;
use crate::search::{SearchEngine, SearchQuery, search_other_files};
use crate::sorting::sort_files;
use crate::templates;
#[cfg(feature = "media-metadata")]
use crate::video_metadata_cache::VideoMetadataCache;
#[cfg(feature = "media-metadata")]
use crate::video_transcode_cache::HlsCache;
use crate::{markdown, repo::Repo};
use tower::ServiceExt;
use tower_http::{compression::CompressionLayer, services::ServeFile, trace::TraceLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[cfg(feature = "media-metadata")]
const DEFAULT_HLS_CACHE_SIZE: usize = 200 * 1024 * 1024;
const DEFAULT_LINK_CACHE_SIZE: usize = 2 * 1024 * 1024;
const DEFAULT_INBOUND_LINK_CACHE_SIZE: usize = 1024 * 1024;
const INBOUND_LINK_CACHE_TTL_SECS: u64 = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaViewerType {
Video,
Pdf,
Audio,
Image,
}
impl MediaViewerType {
#[must_use]
pub fn from_route(path: &str) -> Option<Self> {
match path {
"/.mbr/videos/" => Some(Self::Video),
"/.mbr/pdfs/" => Some(Self::Pdf),
"/.mbr/audio/" => Some(Self::Audio),
"/.mbr/images/" => Some(Self::Image),
_ => None,
}
}
#[must_use]
pub const fn template_name(&self) -> &'static str {
"media_viewer.html"
}
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::Video => "Video",
Self::Pdf => "PDF",
Self::Audio => "Audio",
Self::Image => "Image",
}
}
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Video => "video",
Self::Pdf => "pdf",
Self::Audio => "audio",
Self::Image => "image",
}
}
#[must_use]
pub fn from_extension(ext: &str) -> Option<Self> {
match ext.to_ascii_lowercase().as_str() {
"mp4" | "m4v" | "mov" | "webm" | "flv" | "mpg" | "mpeg" | "avi" | "3gp" | "wmv"
| "mkv" | "ts" | "mts" | "m2ts" | "vob" | "divx" | "xvid" | "asf" | "rm" | "rmvb"
| "f4v" | "ogv" => Some(Self::Video),
"mp3" | "wav" | "ogg" | "flac" | "aac" | "m4a" | "aiff" | "aif" | "oga" | "opus"
| "wma" => Some(Self::Audio),
"jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "tif" | "tiff" | "svg" => {
Some(Self::Image)
}
"pdf" => Some(Self::Pdf),
_ => None,
}
}
#[must_use]
pub fn from_path(path: &std::path::Path) -> Option<Self> {
path.extension()
.and_then(|ext| ext.to_str())
.and_then(Self::from_extension)
}
#[must_use]
pub const fn route_path(&self) -> &'static str {
match self {
Self::Video => "/.mbr/videos/",
Self::Pdf => "/.mbr/pdfs/",
Self::Audio => "/.mbr/audio/",
Self::Image => "/.mbr/images/",
}
}
}
#[derive(Debug, serde::Deserialize)]
pub struct MediaViewerQuery {
pub path: Option<String>,
}
pub fn validate_media_path(
path: &str,
repo_root: &Path,
static_folder: &str,
) -> Result<PathBuf, MbrError> {
let decoded = percent_decode_str(path)
.decode_utf8()
.map_err(|_| MbrError::InvalidMediaPath("Invalid UTF-8 in path".to_string()))?;
if decoded.contains("..") {
return Err(MbrError::DirectoryTraversal);
}
let clean_path = decoded.trim_start_matches('/');
let full_path = repo_root.join(clean_path);
let canonical_root = repo_root
.canonicalize()
.map_err(|_| MbrError::InvalidMediaPath("Repository root not found".to_string()))?;
if let Ok(canonical_path) = full_path.canonicalize()
&& canonical_path.starts_with(&canonical_root)
{
return Ok(canonical_path);
}
if !static_folder.is_empty() {
let static_root = repo_root.join(static_folder);
if let Ok(canonical_static_root) = static_root.canonicalize() {
let static_full_path = static_root.join(clean_path);
if let Ok(canonical_path) = static_full_path.canonicalize()
&& canonical_path.starts_with(&canonical_static_root)
{
return Ok(canonical_path);
}
}
}
Err(MbrError::InvalidMediaPath(format!(
"Path does not exist: {}",
decoded
)))
}
fn safe_join_asset(base_dir: &Path, relative_path: &str) -> Option<PathBuf> {
if relative_path.contains("..") {
tracing::warn!(
"Path traversal attempt blocked in MBR assets: {}",
relative_path
);
return None;
}
let clean_path = relative_path.trim_start_matches('/');
let candidate = base_dir.join(clean_path);
let canonical_base = base_dir.canonicalize().ok()?;
let canonical = candidate.canonicalize().ok()?;
if canonical.starts_with(&canonical_base) && canonical.is_file() {
Some(canonical)
} else {
None
}
}
#[cfg(feature = "media-metadata")]
fn validate_path_containment(file_path: &Path, base_dir: &Path) -> Option<PathBuf> {
let canonical_base = base_dir.canonicalize().ok()?;
let canonical_file = file_path.canonicalize().ok()?;
if canonical_file.starts_with(&canonical_base) && canonical_file.is_file() {
Some(canonical_file)
} else {
None
}
}
pub struct Server {
pub router: Router,
pub port: u16,
pub ip: [u8; 4],
_watcher_handle: Arc<std::sync::Mutex<Option<crate::watcher::FileWatcher>>>,
}
#[derive(Clone)]
pub struct ServerConfig {
pub ip: [u8; 4],
pub port: u16,
pub base_dir: std::path::PathBuf,
pub static_folder: String,
pub markdown_extensions: Vec<String>,
pub ignore_dirs: Vec<String>,
pub ignore_globs: Vec<String>,
pub watcher_ignore_dirs: Vec<String>,
pub index_file: String,
pub oembed_timeout_ms: u64,
pub oembed_cache_size: usize,
pub template_folder: Option<std::path::PathBuf>,
pub sort: Vec<SortField>,
pub gui_mode: bool,
pub theme: String,
pub log_filter: Option<String>,
pub link_tracking: bool,
pub tag_sources: Vec<TagSource>,
pub sidebar_style: String,
pub sidebar_max_items: usize,
pub title_prefix: String,
pub title_suffix: String,
pub mark_incomplete: bool,
pub incomplete_markers: Vec<String>,
#[cfg(feature = "media-metadata")]
pub transcode_enabled: bool,
}
impl ServerConfig {
#[must_use]
pub fn with_gui_mode(mut self, gui_mode: bool) -> Self {
self.gui_mode = gui_mode;
self
}
#[must_use]
pub fn with_log_filter(mut self, filter: Option<&str>) -> Self {
self.log_filter = filter.map(|s| s.to_string());
self
}
}
impl From<&crate::config::Config> for ServerConfig {
fn from(config: &crate::config::Config) -> Self {
Self {
ip: config.host.0,
port: config.port,
base_dir: config.root_dir.clone(),
static_folder: config.static_folder.clone(),
markdown_extensions: config.markdown_extensions.clone(),
ignore_dirs: config.ignore_dirs.clone(),
ignore_globs: config.ignore_globs.clone(),
watcher_ignore_dirs: config.watcher_ignore_dirs.clone(),
index_file: config.index_file.clone(),
oembed_timeout_ms: config.oembed_timeout_ms,
oembed_cache_size: config.oembed_cache_size,
template_folder: config.template_folder.clone(),
sort: config.sort.clone(),
gui_mode: false, theme: config.theme.clone(),
log_filter: None, link_tracking: config.link_tracking,
tag_sources: config.tag_sources.clone(),
sidebar_style: config.sidebar_style.clone(),
sidebar_max_items: config.sidebar_max_items,
title_prefix: config.title_prefix.clone(),
title_suffix: config.title_suffix.clone(),
mark_incomplete: config.mark_incomplete.unwrap_or(true),
incomplete_markers: config.incomplete_markers.clone(),
#[cfg(feature = "media-metadata")]
transcode_enabled: config.transcode,
}
}
}
#[derive(Clone)]
pub struct ServerState {
pub base_dir: std::path::PathBuf,
pub canonical_base_dir: Option<std::path::PathBuf>,
pub static_folder: String,
pub markdown_extensions: Vec<String>,
pub ignore_dirs: Vec<String>,
pub ignore_globs: Vec<String>,
pub index_file: String,
pub templates: crate::templates::Templates,
pub repo: Arc<Repo>,
pub oembed_timeout_ms: u64,
pub file_change_tx: Option<broadcast::Sender<crate::watcher::FileChangeEvent>>,
pub template_folder: Option<std::path::PathBuf>,
pub sort: Vec<SortField>,
pub gui_mode: bool,
pub theme: String,
pub oembed_cache: Arc<OembedCache>,
#[cfg(feature = "media-metadata")]
pub video_metadata_cache: Arc<VideoMetadataCache>,
#[cfg(feature = "media-metadata")]
pub transcode_enabled: bool,
#[cfg(feature = "media-metadata")]
pub hls_cache: Arc<HlsCache>,
pub link_tracking: bool,
pub link_cache: Arc<LinkCache>,
pub inbound_link_cache: Arc<InboundLinkCache>,
pub tag_sources: Vec<TagSource>,
pub sidebar_style: String,
pub sidebar_max_items: usize,
pub title_prefix: String,
pub title_suffix: String,
pub mark_incomplete: bool,
pub incomplete_markers: Vec<String>,
}
impl Server {
pub fn init(config: ServerConfig) -> Result<Self, ServerError> {
let ServerConfig {
ip,
port,
base_dir,
static_folder,
markdown_extensions,
ignore_dirs,
ignore_globs,
watcher_ignore_dirs,
index_file,
oembed_timeout_ms,
oembed_cache_size,
template_folder,
sort,
gui_mode,
theme,
log_filter,
link_tracking,
tag_sources,
sidebar_style,
sidebar_max_items,
title_prefix,
title_suffix,
mark_incomplete,
incomplete_markers,
#[cfg(feature = "media-metadata")]
transcode_enabled,
} = config;
let oembed_cache = Arc::new(OembedCache::new(oembed_cache_size));
#[cfg(feature = "media-metadata")]
let video_metadata_cache = Arc::new(VideoMetadataCache::new(oembed_cache_size));
#[cfg(feature = "media-metadata")]
let hls_cache = Arc::new(HlsCache::new(DEFAULT_HLS_CACHE_SIZE));
let default_filter = log_filter.as_deref().unwrap_or("mbr=warn,tower_http=warn");
let _ = tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| default_filter.into()),
)
.with(tracing_subscriber::fmt::layer())
.try_init();
let templates = templates::Templates::new(base_dir.as_path(), template_folder.as_deref())
.map_err(ServerError::TemplateInit)?;
let repo = Arc::new(Repo::init(
&base_dir,
&static_folder,
&markdown_extensions,
&ignore_dirs,
&ignore_globs,
&index_file,
&tag_sources,
));
let repo_for_scan = Arc::clone(&repo);
tokio::task::spawn_blocking(move || {
if let Err(e) = repo_for_scan.scan_all() {
tracing::error!("Background scan failed: {e}");
}
repo_for_scan.mark_scan_complete();
if let Err(e) = repo_for_scan.scan_static_folder() {
tracing::error!("Background static scan failed: {e}");
}
repo_for_scan.populate_basic_metadata();
repo_for_scan.populate_media_metadata();
repo_for_scan.notify_media_populated();
repo_for_scan.ensure_text_extracted();
});
let (file_change_tx, _rx) = tokio::sync::broadcast::channel::<
crate::watcher::FileChangeEvent,
>(crate::watcher::BROADCAST_CAPACITY);
let tx_for_watcher = file_change_tx.clone();
let base_dir_for_watcher = base_dir.clone();
let template_folder_for_watcher = template_folder.clone();
let watcher_ignore_dirs_for_watcher = watcher_ignore_dirs.clone();
let ignore_globs_for_watcher = ignore_globs.clone();
let watcher_handle: Arc<std::sync::Mutex<Option<crate::watcher::FileWatcher>>> =
Arc::new(std::sync::Mutex::new(None));
let watcher_handle_for_thread = Arc::clone(&watcher_handle);
std::thread::spawn(move || {
match crate::watcher::FileWatcher::new_with_sender(
&base_dir_for_watcher,
template_folder_for_watcher.as_deref(),
&watcher_ignore_dirs_for_watcher,
&ignore_globs_for_watcher,
tx_for_watcher,
) {
Ok(watcher) => {
tracing::info!("File watcher initialized successfully (background)");
if let Ok(mut guard) = watcher_handle_for_thread.lock() {
*guard = Some(watcher);
}
}
Err(e) => {
tracing::warn!(
"Failed to initialize file watcher: {}. Live reload disabled.",
e
);
}
}
});
let templates_for_reload = templates.clone();
let template_folder_for_reload = template_folder.clone();
let mut template_change_rx = file_change_tx.subscribe();
tokio::spawn(async move {
while let Ok(event) = template_change_rx.recv().await {
if !event.path.ends_with(".html") {
continue;
}
let should_reload = if let Some(ref tf) = template_folder_for_reload {
event.path.starts_with(&tf.to_string_lossy().to_string())
} else {
event.path.contains("/.mbr/")
};
if should_reload {
tracing::debug!("Template file changed: {}", event.path);
if let Err(e) = templates_for_reload.reload() {
tracing::error!("Failed to reload templates: {}", e);
}
}
}
});
let repo_for_invalidation = Arc::clone(&repo);
let base_dir_for_invalidation = base_dir.clone();
let markdown_extensions_for_invalidation = markdown_extensions.clone();
let mut repo_change_rx = file_change_tx.subscribe();
tokio::spawn(async move {
const DEBOUNCE_DURATION: std::time::Duration = std::time::Duration::from_secs(2);
const SURGICAL_THRESHOLD: usize = 50;
loop {
let first_event = match repo_change_rx.recv().await {
Ok(e) => e,
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
};
let mut pending_events = vec![first_event];
let deadline = tokio::time::Instant::now() + DEBOUNCE_DURATION;
loop {
tokio::select! {
result = repo_change_rx.recv() => {
match result {
Ok(event) => pending_events.push(event),
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(_)) => {
pending_events.clear();
pending_events.push(crate::watcher::FileChangeEvent {
path: String::new(),
relative_path: String::new(),
event: crate::watcher::ChangeEventType::Created,
});
for _ in 0..SURGICAL_THRESHOLD {
pending_events.push(crate::watcher::FileChangeEvent {
path: String::new(),
relative_path: String::new(),
event: crate::watcher::ChangeEventType::Created,
});
}
break;
}
}
}
_ = tokio::time::sleep_until(deadline) => break,
}
}
let relevant_events: Vec<_> = pending_events
.into_iter()
.filter(|event| match event.event {
crate::watcher::ChangeEventType::Created
| crate::watcher::ChangeEventType::Deleted => true,
crate::watcher::ChangeEventType::Modified => {
markdown_extensions_for_invalidation
.iter()
.any(|ext| event.relative_path.ends_with(&format!(".{}", ext)))
}
})
.collect();
if relevant_events.is_empty() {
continue;
}
let repo = Arc::clone(&repo_for_invalidation);
let base_dir = base_dir_for_invalidation.clone();
if relevant_events.len() <= SURGICAL_THRESHOLD {
tracing::debug!(
"Surgical invalidation for {} file(s)",
relevant_events.len()
);
let has_tag_changes = relevant_events.iter().any(|e| {
matches!(
e.event,
crate::watcher::ChangeEventType::Deleted
| crate::watcher::ChangeEventType::Modified
)
});
tokio::task::spawn_blocking(move || {
for event in &relevant_events {
let abs_path = if event.path.is_empty() {
continue;
} else {
PathBuf::from(&event.path)
};
repo.invalidate_file(&abs_path, &event.event);
}
if has_tag_changes {
repo.rebuild_tag_index();
}
})
.await
.ok();
} else {
tracing::info!(
"Full rescan triggered: {} file changes exceed threshold",
relevant_events.len()
);
tokio::task::spawn_blocking(move || {
repo.clear();
if let Err(e) = repo.scan_all() {
tracing::error!("Background rescan failed: {e}");
return;
}
if let Err(e) = repo.scan_static_folder() {
tracing::error!("Background static rescan failed: {e}");
}
repo.populate_basic_metadata();
repo.populate_media_metadata();
repo.notify_media_populated();
repo.ensure_text_extracted();
let _ = base_dir; })
.await
.ok();
}
}
});
let link_cache = Arc::new(LinkCache::new(DEFAULT_LINK_CACHE_SIZE));
let inbound_link_cache = Arc::new(InboundLinkCache::new(
DEFAULT_INBOUND_LINK_CACHE_SIZE,
INBOUND_LINK_CACHE_TTL_SECS,
));
let canonical_base_dir = base_dir.canonicalize().ok();
let state = ServerState {
base_dir,
canonical_base_dir,
static_folder,
markdown_extensions,
ignore_dirs,
ignore_globs,
index_file,
templates,
repo,
oembed_timeout_ms,
file_change_tx: Some(file_change_tx),
template_folder,
sort,
gui_mode,
theme,
oembed_cache,
#[cfg(feature = "media-metadata")]
video_metadata_cache,
#[cfg(feature = "media-metadata")]
transcode_enabled,
#[cfg(feature = "media-metadata")]
hls_cache,
link_tracking,
link_cache,
inbound_link_cache,
tag_sources,
sidebar_style,
sidebar_max_items,
title_prefix,
title_suffix,
mark_incomplete,
incomplete_markers,
};
let router = Router::new()
.route("/", get(Self::home_page))
.route("/.mbr/site.json", get(Self::get_site_info))
.route("/.mbr/media.json", get(Self::get_media_info))
.route("/.mbr/search", post(Self::search_handler))
.route("/.mbr/ws/changes", get(Self::websocket_handler))
.route("/.mbr/videos/", get(Self::serve_media_viewer))
.route("/.mbr/pdfs/", get(Self::serve_media_viewer))
.route("/.mbr/audio/", get(Self::serve_media_viewer))
.route("/.mbr/images/", get(Self::serve_media_viewer))
.route("/.mbr/{*path}", get(Self::serve_mbr_assets))
.route("/{*path}", get(Self::handle))
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
.with_state(state);
Ok(Server {
router,
ip,
port,
_watcher_handle: watcher_handle,
})
}
pub async fn start(&self) -> Result<(), ServerError> {
self.start_with_ready_signal(None).await
}
pub async fn start_with_ready_signal(
&self,
ready_tx: Option<tokio::sync::oneshot::Sender<()>>,
) -> Result<(), ServerError> {
let addr = SocketAddr::from((self.ip, self.port));
let listener =
tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| ServerError::BindFailed {
addr: addr.to_string(),
source: e,
})?;
let local_addr = listener
.local_addr()
.map_err(ServerError::LocalAddrFailed)?;
tracing::debug!("listening on {}", local_addr);
println!("Server running at http://{}/", local_addr);
if let Some(tx) = ready_tx
&& tx.send(()).is_err()
{
tracing::debug!("Ready signal receiver dropped (shutdown in progress)");
}
axum::serve(listener, self.router.clone())
.await
.map_err(ServerError::StartFailed)?;
Ok(())
}
pub async fn start_with_port_retry(
&mut self,
ready_tx: Option<tokio::sync::oneshot::Sender<u16>>,
max_retries: u16,
) -> Result<(), ServerError> {
let mut attempts = 0;
loop {
let addr = SocketAddr::from((self.ip, self.port));
match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => {
let local_addr = listener
.local_addr()
.map_err(ServerError::LocalAddrFailed)?;
tracing::debug!("listening on {}", local_addr);
println!("Server running at http://{}/", local_addr);
if let Some(tx) = ready_tx
&& tx.send(self.port).is_err()
{
tracing::debug!("Port signal receiver dropped (shutdown in progress)");
}
axum::serve(listener, self.router.clone())
.await
.map_err(ServerError::StartFailed)?;
return Ok(());
}
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && attempts < max_retries => {
let old_port = self.port;
if self.port == 65535 {
return Err(ServerError::BindFailed {
addr: "port range exhausted (reached port 65535)".into(),
source: e,
});
}
self.port += 1;
attempts += 1;
eprintln!(
"Warning: Port {} already in use, trying port {}",
old_port, self.port
);
tracing::warn!(
"Port {} already in use, trying port {}",
old_port,
self.port
);
}
Err(e) => {
return Err(ServerError::BindFailed {
addr: addr.to_string(),
source: e,
});
}
}
}
}
pub async fn websocket_handler(
ws: WebSocketUpgrade,
State(config): State<ServerState>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| Self::handle_websocket(socket, config))
}
async fn handle_websocket(socket: axum::extract::ws::WebSocket, config: ServerState) {
let (mut sender, mut receiver) = socket.split();
let Some(file_change_tx) = config.file_change_tx else {
tracing::warn!("WebSocket connection attempted but file watcher is disabled");
if let Err(e) = sender
.send(axum::extract::ws::Message::Text(
r#"{"error":"File watcher not available"}"#.into(),
))
.await
{
tracing::debug!("Failed to send error to WebSocket client: {e}");
}
return;
};
let mut rx = file_change_tx.subscribe();
tracing::info!("WebSocket client connected for live reload");
if sender
.send(axum::extract::ws::Message::Text(
r#"{"status":"connected"}"#.to_string().into(),
))
.await
.is_err()
{
return;
}
loop {
tokio::select! {
Ok(change_event) = rx.recv() => {
let json = match serde_json::to_string(&change_event) {
Ok(j) => j,
Err(e) => {
tracing::error!("Failed to serialize change event: {}", e);
continue;
}
};
if sender
.send(axum::extract::ws::Message::Text(json.into()))
.await
.is_err()
{
tracing::info!("WebSocket client disconnected");
break;
}
}
msg = receiver.next() => {
match msg {
Some(Ok(axum::extract::ws::Message::Close(_))) => {
tracing::info!("WebSocket client closed connection");
break;
}
#[allow(clippy::collapsible_match)]
Some(Ok(axum::extract::ws::Message::Ping(data))) => {
if sender
.send(axum::extract::ws::Message::Pong(data))
.await
.is_err()
{
break;
}
}
Some(Err(e)) => {
tracing::error!("WebSocket error: {}", e);
break;
}
None => {
tracing::info!("WebSocket stream ended");
break;
}
_ => {}
}
}
}
}
}
pub async fn get_site_info(
State(config): State<ServerState>,
) -> Result<impl IntoResponse, StatusCode> {
if !config.repo.is_scan_complete() {
tracing::debug!("get_site_info: waiting for background scan...");
config.repo.wait_for_scan().await;
}
let json_start = std::time::Instant::now();
let mut response = serde_json::to_value(&*config.repo)
.inspect_err(|e| tracing::error!("Error creating json: {e}"))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if let Some(obj) = response.as_object_mut() {
obj.remove("other_files");
obj.insert(
"sort".to_string(),
serde_json::to_value(&config.sort).unwrap_or(serde_json::Value::Array(vec![])),
);
obj.insert(
"sidebar_style".to_string(),
serde_json::Value::String(config.sidebar_style.clone()),
);
obj.insert(
"sidebar_max_items".to_string(),
serde_json::json!(config.sidebar_max_items),
);
}
let resp = Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(
serde_json::to_string(&response)
.inspect_err(|e| tracing::error!("Error serializing json: {e}"))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
)
.inspect_err(|e| tracing::error!("Error rendering site file: {e}"))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tracing::debug!(
"get_site_info JSON serialization: {:?}",
json_start.elapsed()
);
Ok(resp.into_response())
}
pub async fn get_media_info(
State(config): State<ServerState>,
) -> Result<impl IntoResponse, StatusCode> {
let start = std::time::Instant::now();
if !config.repo.is_scan_complete() {
tracing::debug!("get_media_info: waiting for background scan...");
config.repo.wait_for_scan().await;
}
if !config.repo.is_media_populated() {
tracing::debug!("get_media_info: waiting for media metadata...");
config.repo.wait_for_media().await;
}
let json_start = std::time::Instant::now();
let media_data = serde_json::json!({
"other_files": &config.repo.other_files,
});
let resp = Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(
serde_json::to_string(&media_data)
.inspect_err(|e| tracing::error!("Error serializing media json: {e}"))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
)
.inspect_err(|e| tracing::error!("Error rendering media file: {e}"))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tracing::debug!(
"get_media_info completed in {:?} (JSON: {:?})",
start.elapsed(),
json_start.elapsed()
);
Ok(resp.into_response())
}
pub async fn search_handler(
State(config): State<ServerState>,
Json(query): Json<SearchQuery>,
) -> impl IntoResponse {
tracing::debug!("Search request: q={:?}, scope={:?}", query.q, query.scope);
let scan_in_progress = !config.repo.is_scan_complete();
if config.repo.is_scan_complete()
&& (query.filetype.as_deref() == Some("all")
|| (query.filetype.is_some() && query.filetype.as_deref() != Some("markdown")))
{
config.repo.ensure_text_extracted();
}
let repo = config.repo.clone();
let base_dir = config.base_dir.clone();
let query_str = query.q.clone();
let search_result = tokio::task::spawn_blocking(move || {
let engine = SearchEngine::new(repo.clone(), base_dir);
let mut response = engine.search(&query)?;
if query.filetype.as_deref() == Some("all")
|| (query.filetype.is_some() && query.filetype.as_deref() != Some("markdown"))
{
let other_results = search_other_files(
&repo,
&query.q,
query.folder.as_deref(),
query.filetype.as_deref(),
query.limit,
);
response.results.extend(other_results);
response.results.sort_by_key(|r| std::cmp::Reverse(r.score));
response.results.truncate(query.limit);
response.total_matches = response.results.len();
}
Ok::<_, crate::errors::SearchError>(response)
})
.await;
let mut response = match search_result {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
tracing::error!("Search error: {e}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Search failed: {}", e),
"query": query_str,
"total_matches": 0,
"results": [],
"duration_ms": 0
})),
);
}
Err(e) => {
tracing::error!("Search task panicked: {e}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Search task failed",
"query": query_str,
"total_matches": 0,
"results": [],
"duration_ms": 0
})),
);
}
};
response.scan_in_progress = scan_in_progress;
tracing::debug!(
"Search completed: {} results in {}ms",
response.total_matches,
response.duration_ms
);
(
StatusCode::OK,
Json(serde_json::to_value(response).unwrap()),
)
}
pub async fn serve_media_viewer(
State(config): State<ServerState>,
OriginalUri(uri): OriginalUri,
extract::Query(query): extract::Query<MediaViewerQuery>,
) -> impl IntoResponse {
use serde_json::json;
let route_path = uri.path();
let media_type = match MediaViewerType::from_route(route_path) {
Some(mt) => mt,
None => {
tracing::error!("Invalid media viewer route: {}", route_path);
return Self::render_error_page(
&config.templates,
StatusCode::NOT_FOUND,
"Not Found",
Some("Invalid media viewer route"),
route_path,
config.gui_mode,
&config.sidebar_style,
config.sidebar_max_items,
);
}
};
let media_path = match &query.path {
Some(p) if !p.is_empty() => p,
_ => {
tracing::warn!("Media viewer called without path parameter");
return Self::render_error_page(
&config.templates,
StatusCode::BAD_REQUEST,
"Bad Request",
Some("Missing required 'path' query parameter"),
route_path,
config.gui_mode,
&config.sidebar_style,
config.sidebar_max_items,
);
}
};
let validated_path =
match validate_media_path(media_path, &config.base_dir, &config.static_folder) {
Ok(p) => p,
Err(MbrError::DirectoryTraversal) => {
tracing::warn!("Directory traversal attempt: {}", media_path);
return Self::render_error_page(
&config.templates,
StatusCode::FORBIDDEN,
"Forbidden",
Some("Access denied: Invalid path"),
route_path,
config.gui_mode,
&config.sidebar_style,
config.sidebar_max_items,
);
}
Err(MbrError::InvalidMediaPath(msg)) => {
tracing::warn!("Invalid media path: {} - {}", media_path, msg);
return Self::render_error_page(
&config.templates,
StatusCode::NOT_FOUND,
"Not Found",
Some(&format!("Media file not found: {}", msg)),
route_path,
config.gui_mode,
&config.sidebar_style,
config.sidebar_max_items,
);
}
Err(e) => {
tracing::error!("Unexpected error validating media path: {}", e);
return Self::render_error_page(
&config.templates,
StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error",
Some("Failed to validate media path"),
route_path,
config.gui_mode,
&config.sidebar_style,
config.sidebar_max_items,
);
}
};
let title = validated_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Media Viewer")
.to_string();
let url_path = std::path::Path::new(media_path);
let breadcrumbs =
generate_breadcrumbs(url_path.parent().unwrap_or(std::path::Path::new("")));
let breadcrumbs_json: Vec<_> = breadcrumbs
.iter()
.map(|b| json!({"name": b.name, "url": b.url}))
.collect();
let parent_path = url_path.parent().and_then(|p| p.to_str()).map(|p| {
if p.is_empty() || p == "/" {
"/".to_string()
} else {
let clean = p.trim_start_matches('/');
format!("/{}/", clean)
}
});
let mut context = std::collections::HashMap::new();
context.insert("media_type".to_string(), json!(media_type.as_str()));
context.insert("title".to_string(), json!(title));
context.insert("media_path".to_string(), json!(media_path));
context.insert("breadcrumbs".to_string(), json!(breadcrumbs_json));
if let Some(parent) = parent_path {
context.insert("parent_path".to_string(), json!(parent));
}
context.insert("server_mode".to_string(), json!(true));
context.insert("gui_mode".to_string(), json!(config.gui_mode));
context.insert("relative_base".to_string(), json!("/.mbr/"));
context.insert("sidebar_style".to_string(), json!(config.sidebar_style));
context.insert(
"sidebar_max_items".to_string(),
json!(config.sidebar_max_items),
);
context.insert("title_prefix".to_string(), json!(config.title_prefix));
context.insert("title_suffix".to_string(), json!(config.title_suffix));
match config.templates.render_media_viewer(context) {
Ok(html) => {
let etag = generate_etag(html.as_bytes());
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_STORE)
.header(header::ETAG, etag)
.body(Body::from(html))
.unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from("Internal Server Error"))
.unwrap()
})
}
Err(e) => {
tracing::error!("Failed to render media viewer template: {}", e);
Self::render_error_page(
&config.templates,
StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error",
Some("Failed to render media viewer"),
route_path,
config.gui_mode,
&config.sidebar_style,
config.sidebar_max_items,
)
}
}
}
pub async fn serve_mbr_assets(
extract::Path(path): extract::Path<String>,
State(config): State<ServerState>,
) -> Result<impl IntoResponse, StatusCode> {
tracing::debug!("serve_mbr_assets: {}", path);
let asset_path = if path.starts_with('/') {
path.clone()
} else {
format!("/{}", path)
};
if let Some(ref template_folder) = config.template_folder {
let relative_path = if asset_path.starts_with("/components/") {
let component_name = asset_path
.strip_prefix("/components/")
.unwrap_or(&asset_path);
format!("components-js/{}", component_name)
} else {
asset_path.trim_start_matches('/').to_string()
};
tracing::trace!("Checking template folder for: {}", relative_path);
if let Some(file_path) = safe_join_asset(template_folder, &relative_path) {
return Self::serve_file_from_path(&file_path).await;
}
}
let mbr_dir = config.base_dir.join(".mbr");
tracing::trace!("Checking .mbr dir for: {}", asset_path);
if let Some(file_path) = safe_join_asset(&mbr_dir, &asset_path) {
return Self::serve_file_from_path(&file_path).await;
}
if asset_path == "/pico.min.css" {
return Self::serve_themed_pico(&config.theme);
}
Self::serve_default_file(&asset_path)
}
async fn serve_file_from_path(path: &std::path::Path) -> Result<Response<Body>, StatusCode> {
let mime = Self::guess_mime_type(path);
let bytes = tokio::fs::read(path).await.map_err(|e| {
tracing::error!("Failed to read file {}: {}", path.display(), e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let etag = generate_etag(&bytes);
let last_modified = tokio::fs::metadata(path)
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| generate_last_modified(d.as_secs()));
let mut builder = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(header::ETAG, etag);
if let Some(lm) = last_modified {
builder = builder.header(header::LAST_MODIFIED, lm);
}
builder
.body(axum::body::Body::from(bytes))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
fn serve_themed_pico(theme: &str) -> Result<Response<Body>, StatusCode> {
match embedded_pico::get_pico_css(theme) {
Some(bytes) => {
let etag = generate_etag(bytes);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/css")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(header::ETAG, etag)
.body(Body::from(bytes.to_vec()))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
None => {
eprintln!(
"Warning: Invalid theme '{}'. Valid themes: {}",
theme,
embedded_pico::valid_themes_display()
);
Err(StatusCode::NOT_FOUND)
}
}
}
fn guess_mime_type(path: &std::path::Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("html") => "text/html",
Some("css") => "text/css",
Some("js") => "application/javascript",
Some("json") => "application/json",
Some("map") => "application/json",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("ttf") => "font/ttf",
Some("eot") => "application/vnd.ms-fontobject",
_ => "application/octet-stream",
}
}
#[allow(clippy::too_many_arguments)]
fn render_error_page(
templates: &templates::Templates,
status_code: StatusCode,
error_title: &str,
error_message: Option<&str>,
requested_url: &str,
gui_mode: bool,
sidebar_style: &str,
sidebar_max_items: usize,
) -> Response<Body> {
use std::collections::HashMap;
let mut context: HashMap<String, serde_json::Value> = HashMap::new();
context.insert(
"error_code".to_string(),
serde_json::Value::Number(status_code.as_u16().into()),
);
context.insert(
"error_title".to_string(),
serde_json::Value::String(error_title.to_string()),
);
if let Some(msg) = error_message {
context.insert(
"error_message".to_string(),
serde_json::Value::String(msg.to_string()),
);
}
context.insert(
"requested_url".to_string(),
serde_json::Value::String(requested_url.to_string()),
);
context.insert("server_mode".to_string(), serde_json::Value::Bool(true));
context.insert("gui_mode".to_string(), serde_json::Value::Bool(gui_mode));
context.insert(
"sidebar_style".to_string(),
serde_json::Value::String(sidebar_style.to_string()),
);
context.insert(
"sidebar_max_items".to_string(),
serde_json::Value::Number(sidebar_max_items.into()),
);
match templates.render_error(context) {
Ok(html) => Response::builder()
.status(status_code)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(html))
.unwrap_or_else(|_| {
Response::builder()
.status(status_code)
.body(Body::from(error_title.to_string()))
.unwrap()
}),
Err(e) => {
tracing::error!("Failed to render error page: {}", e);
Response::builder()
.status(status_code)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(format!(
"{} {}",
status_code.as_u16(),
error_title
)))
.unwrap()
}
}
}
fn serve_default_file(path: &str) -> Result<Response<Body>, StatusCode> {
let file = DEFAULT_FILES
.iter()
.find(|(name, _, _)| path == *name)
.or_else(|| {
embedded_katex::KATEX_FILES
.iter()
.find(|(name, _, _)| path == *name)
});
if let Some((_name, bytes, mime)) = file {
tracing::debug!("found default file");
let etag = generate_etag(bytes);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, *mime)
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(header::ETAG, etag)
.body(axum::body::Body::from(*bytes))
.inspect_err(|e| tracing::error!("Error rendering default file: {e}"))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
} else {
tracing::debug!("no default found for: {}", path);
Err(StatusCode::NOT_FOUND)
}
}
async fn handle(
extract::Path(path): extract::Path<String>,
State(config): State<ServerState>,
req: extract::Request<Body>,
) -> Result<impl IntoResponse, StatusCode> {
tracing::debug!("handle: {}", &path);
let tag_url_sources = crate::config::tag_sources_to_url_sources(&config.tag_sources);
let resolver_config = PathResolverConfig {
base_dir: config.base_dir.as_path(),
canonical_base_dir: config.canonical_base_dir.as_deref(),
static_folder: &config.static_folder,
markdown_extensions: &config.markdown_extensions,
index_file: &config.index_file,
tag_sources: &tag_url_sources,
};
match resolve_request_path(&resolver_config, &path) {
ResolvedPath::StaticFile(file_path) => {
#[cfg(feature = "media-metadata")]
if let Some(response) =
Self::try_serve_pdf_cover_sidecar(&path, &file_path, &config).await
{
return Ok(response);
}
tracing::debug!("serving static file: {:?}", &file_path);
Self::serve_static_file(file_path, req).await
}
ResolvedPath::MarkdownFile(md_path) => {
tracing::debug!("rendering markdown: {:?}", &md_path);
Self::markdown_to_html(&md_path, &config)
.await
.map_err(|e| {
tracing::error!("Error rendering markdown: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})
}
ResolvedPath::DirectoryListing(dir_path) => {
tracing::debug!("generating directory listing: {:?}", &dir_path);
Self::directory_to_html(
&dir_path,
&config.templates,
config.base_dir.as_path(),
&config,
)
.await
.map_err(|e| {
tracing::error!("Error generating directory listing: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})
}
ResolvedPath::TagPage { source, value } => {
tracing::debug!("generating tag page: source={}, value={}", source, value);
Self::tag_page_to_html(&source, &value, &config)
.await
.map_err(|e| {
tracing::error!("Error generating tag page: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})
}
ResolvedPath::TagSourceIndex { source } => {
tracing::debug!("generating tag source index: source={}", source);
Self::tag_source_index_to_html(&source, &config)
.await
.map_err(|e| {
tracing::error!("Error generating tag source index: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})
}
ResolvedPath::Redirect(canonical_url) => {
tracing::debug!("redirecting to canonical URL: {}", &canonical_url);
Ok(Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header(header::LOCATION, &canonical_url)
.body(Body::empty())
.unwrap())
}
ResolvedPath::NotFound => {
#[cfg(feature = "media-metadata")]
if config.transcode_enabled
&& let Some(response) = Self::try_serve_hls_content(&path, &config).await
{
return Ok(response);
}
#[cfg(feature = "media-metadata")]
if let Some(response) = Self::try_serve_video_metadata(&path, &config).await {
return Ok(response);
}
#[cfg(feature = "media-metadata")]
if let Some(response) = Self::try_serve_pdf_cover(&path, &config).await {
return Ok(response);
}
if let Some(response) = Self::try_serve_errors_json(&path, &config).await {
return Ok(response);
}
if let Some(response) = Self::try_serve_links_json(&path, &config).await {
return Ok(response);
}
tracing::debug!("resource not found: {}", &path);
let requested_url = format!("/{}", path);
Ok(Self::render_error_page(
&config.templates,
StatusCode::NOT_FOUND,
"Not Found",
Some("The requested page could not be found."),
&requested_url,
config.gui_mode,
&config.sidebar_style,
config.sidebar_max_items,
))
}
}
}
async fn serve_static_file(
file_path: std::path::PathBuf,
req: extract::Request<Body>,
) -> Result<Response, StatusCode> {
let static_service = ServeFile::new(file_path);
let mut response = static_service
.oneshot(req)
.await
.map(|r| r.into_response())
.map_err(|e| {
tracing::error!("Error serving static file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(CACHE_CONTROL_NO_CACHE),
);
Ok(response)
}
#[cfg(feature = "media-metadata")]
async fn try_serve_video_metadata(path: &str, config: &ServerState) -> Option<Response<Body>> {
use crate::video_metadata::{
MetadataType, extract_captions, extract_chapters, extract_cover, parse_metadata_request,
};
use crate::video_metadata_cache::{CachedMetadata, cache_key};
let (video_url_path, metadata_type) = parse_metadata_request(path)?;
let cache_type_str = match metadata_type {
MetadataType::Cover => "cover",
MetadataType::Chapters => "chapters",
MetadataType::Captions => "captions",
};
let key = cache_key(video_url_path, cache_type_str);
if let Some(cached) = config.video_metadata_cache.get(&key) {
return match cached {
CachedMetadata::Cover(bytes) => Some(Self::build_jpg_response(bytes)),
CachedMetadata::Chapters(vtt) | CachedMetadata::Captions(vtt) => {
Some(Self::build_vtt_response(vtt))
}
CachedMetadata::NotAvailable => None, };
}
let video_file = {
let direct = config.base_dir.join(video_url_path);
if let Some(validated) = validate_path_containment(&direct, &config.base_dir) {
validated
} else {
let static_dir = config.base_dir.join(&config.static_folder);
let with_static = static_dir.join(video_url_path);
if let Some(validated) = validate_path_containment(&with_static, &static_dir) {
validated
} else {
tracing::debug!(
"Video file not found for metadata generation: {}",
video_url_path
);
return None;
}
}
};
tracing::debug!(
"Generating {} for: {}",
cache_type_str,
video_file.display()
);
match metadata_type {
MetadataType::Cover => match extract_cover(&video_file) {
Ok(bytes) => {
config
.video_metadata_cache
.insert(key, CachedMetadata::Cover(bytes.clone()));
Some(Self::build_jpg_response(bytes))
}
Err(e) => {
tracing::debug!("Failed to extract cover: {}", e);
config
.video_metadata_cache
.insert(key, CachedMetadata::NotAvailable);
None
}
},
MetadataType::Chapters => match extract_chapters(&video_file) {
Ok(vtt) => {
config
.video_metadata_cache
.insert(key, CachedMetadata::Chapters(vtt.clone()));
Some(Self::build_vtt_response(vtt))
}
Err(e) => {
tracing::debug!("Failed to extract chapters: {}", e);
config
.video_metadata_cache
.insert(key, CachedMetadata::NotAvailable);
None
}
},
MetadataType::Captions => match extract_captions(&video_file) {
Ok(vtt) => {
config
.video_metadata_cache
.insert(key, CachedMetadata::Captions(vtt.clone()));
Some(Self::build_vtt_response(vtt))
}
Err(e) => {
tracing::debug!("Failed to extract captions: {}", e);
config
.video_metadata_cache
.insert(key, CachedMetadata::NotAvailable);
None
}
},
}
}
#[cfg(feature = "media-metadata")]
async fn try_serve_pdf_cover_sidecar(
url_path: &str,
sidecar_file_path: &std::path::Path,
config: &ServerState,
) -> Option<Response<Body>> {
use crate::pdf_metadata::parse_pdf_cover_request;
use crate::video_metadata_cache::{CachedMetadata, cache_key};
let pdf_url_path = parse_pdf_cover_request(url_path)?;
let key = cache_key(pdf_url_path, "pdf_cover");
if let Some(cached) = config.video_metadata_cache.get(&key) {
return match cached {
CachedMetadata::Cover(bytes) => Some(Self::build_jpg_response(bytes)),
CachedMetadata::NotAvailable => None, _ => None, };
}
let pdf_file = {
let sidecar_str = sidecar_file_path.to_str()?;
let pdf_path_str = sidecar_str.strip_suffix(".cover.jpg")?;
std::path::PathBuf::from(pdf_path_str)
};
if !pdf_file.is_file() {
if let Ok(bytes) = tokio::fs::read(sidecar_file_path).await {
tracing::debug!(
"Serving PDF cover sidecar (orphaned, no PDF): {}",
sidecar_file_path.display()
);
config
.video_metadata_cache
.insert(key, CachedMetadata::Cover(bytes.clone()));
return Some(Self::build_jpg_response(bytes));
}
return None;
}
let pdf_meta = tokio::fs::metadata(&pdf_file).await.ok()?;
let sidecar_meta = tokio::fs::metadata(sidecar_file_path).await.ok()?;
let pdf_mtime = pdf_meta.modified().ok()?;
let sidecar_mtime = sidecar_meta.modified().ok()?;
if pdf_mtime > sidecar_mtime {
tracing::debug!(
"Sidecar is stale (PDF modified after sidecar), regenerating: {}",
sidecar_file_path.display()
);
match crate::pdf_metadata::extract_cover_async(&pdf_file).await {
Ok(bytes) => {
config
.video_metadata_cache
.insert(key, CachedMetadata::Cover(bytes.clone()));
return Some(Self::build_jpg_response(bytes));
}
Err(e) => {
tracing::debug!("Failed to regenerate PDF cover: {}", e);
}
}
}
if let Ok(bytes) = tokio::fs::read(sidecar_file_path).await {
tracing::debug!(
"Serving PDF cover from fresh sidecar: {}",
sidecar_file_path.display()
);
config
.video_metadata_cache
.insert(key, CachedMetadata::Cover(bytes.clone()));
return Some(Self::build_jpg_response(bytes));
}
None
}
#[cfg(feature = "media-metadata")]
async fn try_serve_pdf_cover(path: &str, config: &ServerState) -> Option<Response<Body>> {
use crate::pdf_metadata::parse_pdf_cover_request;
use crate::video_metadata_cache::{CachedMetadata, cache_key};
let pdf_url_path = parse_pdf_cover_request(path)?;
let key = cache_key(pdf_url_path, "pdf_cover");
if let Some(cached) = config.video_metadata_cache.get(&key) {
return match cached {
CachedMetadata::Cover(bytes) => Some(Self::build_jpg_response(bytes)),
CachedMetadata::NotAvailable => None, _ => None, };
}
let pdf_file = {
let direct = config.base_dir.join(pdf_url_path);
if let Some(validated) = validate_path_containment(&direct, &config.base_dir) {
validated
} else {
let static_dir = config.base_dir.join(&config.static_folder);
let with_static = static_dir.join(pdf_url_path);
if let Some(validated) = validate_path_containment(&with_static, &static_dir) {
validated
} else {
tracing::debug!("PDF file not found for cover generation: {}", pdf_url_path);
return None;
}
}
};
let sidecar_path = {
let mut sidecar = pdf_file.clone();
let file_name = sidecar.file_name()?.to_str()?;
sidecar.set_file_name(format!("{}.cover.jpg", file_name));
sidecar
};
if let Some(bytes) = Self::try_serve_from_sidecar(&pdf_file, &sidecar_path).await {
tracing::debug!("Serving PDF cover from sidecar: {}", sidecar_path.display());
config
.video_metadata_cache
.insert(key, CachedMetadata::Cover(bytes.clone()));
return Some(Self::build_jpg_response(bytes));
}
tracing::debug!("Generating PDF cover for: {}", pdf_file.display());
match crate::pdf_metadata::extract_cover_async(&pdf_file).await {
Ok(bytes) => {
config
.video_metadata_cache
.insert(key, CachedMetadata::Cover(bytes.clone()));
Some(Self::build_jpg_response(bytes))
}
Err(crate::errors::PdfMetadataError::PasswordProtected { .. }) => {
tracing::debug!("PDF is password-protected: {}", pdf_file.display());
config
.video_metadata_cache
.insert(key, CachedMetadata::NotAvailable);
None
}
Err(e) => {
tracing::debug!("Failed to extract PDF cover: {}", e);
config
.video_metadata_cache
.insert(key, CachedMetadata::NotAvailable);
None
}
}
}
#[cfg(feature = "media-metadata")]
async fn try_serve_from_sidecar(
pdf_path: &std::path::Path,
sidecar_path: &std::path::Path,
) -> Option<Vec<u8>> {
if let Some(pdf_dir) = pdf_path.parent() {
if let Some(sidecar_dir) = sidecar_path.parent() {
if sidecar_dir != pdf_dir {
tracing::warn!(
"Sidecar path is not in the same directory as PDF; skipping sidecar. \
pdf_dir='{}', sidecar_dir='{}'",
pdf_dir.display(),
sidecar_dir.display()
);
return None;
}
} else {
tracing::warn!(
"Sidecar path has no parent directory; skipping sidecar: {}",
sidecar_path.display()
);
return None;
}
if validate_path_containment(sidecar_path, pdf_dir).is_none() {
tracing::warn!(
"Sidecar path failed containment validation: {}",
sidecar_path.display()
);
return None;
}
} else {
tracing::warn!(
"PDF path has no parent directory; skipping sidecar: {}",
pdf_path.display()
);
return None;
}
let sidecar_meta = tokio::fs::metadata(sidecar_path).await.ok()?;
let pdf_meta = tokio::fs::metadata(pdf_path).await.ok()?;
let pdf_mtime = pdf_meta.modified().ok()?;
let sidecar_mtime = sidecar_meta.modified().ok()?;
if pdf_mtime > sidecar_mtime {
tracing::debug!(
"Sidecar is stale (PDF modified after sidecar): {}",
sidecar_path.display()
);
return None;
}
tokio::fs::read(sidecar_path).await.ok()
}
async fn try_serve_links_json(path: &str, config: &ServerState) -> Option<Response<Body>> {
use crate::link_grep::find_inbound_links;
use crate::link_index::PageLinks;
if !path.ends_with("links.json") {
return None;
}
if !config.link_tracking {
tracing::debug!("links.json requested but link tracking is disabled");
return None;
}
let page_path = path.strip_suffix("links.json")?;
let page_url_path = if page_path.is_empty() || page_path == "/" {
"/".to_string()
} else {
let normalized = page_path.trim_end_matches('/');
format!("{}/", normalized)
};
tracing::debug!("links.json request for page: {}", page_url_path);
let outbound = if let Some(cached) = config.link_cache.get(&page_url_path) {
cached
} else {
let tag_url_sources = crate::config::tag_sources_to_url_sources(&config.tag_sources);
let resolver_config = PathResolverConfig {
base_dir: &config.base_dir,
canonical_base_dir: config.canonical_base_dir.as_deref(),
static_folder: &config.static_folder,
markdown_extensions: &config.markdown_extensions,
index_file: &config.index_file,
tag_sources: &tag_url_sources,
};
let request_path = page_url_path.trim_matches('/');
match resolve_request_path(&resolver_config, request_path) {
ResolvedPath::MarkdownFile(md_path) => {
tracing::debug!(
"links.json: rendering page to extract links: {:?}",
&md_path
);
let is_index_file = md_path
.file_name()
.and_then(|f| f.to_str())
.is_some_and(|f| f == config.index_file);
let link_transform_config = LinkTransformConfig {
markdown_extensions: config.markdown_extensions.clone(),
index_file: config.index_file.clone(),
is_index_file,
url_depth: None,
};
let valid_tag_sources = crate::config::tag_sources_to_set(&config.tag_sources);
match markdown::render_with_cache(
md_path,
&config.base_dir,
config.oembed_timeout_ms,
link_transform_config,
Some(config.oembed_cache.clone()),
true, false, valid_tag_sources,
false, &config.incomplete_markers,
)
.await
{
Ok(render_result) => {
let resolved_links = resolve_outbound_links(
&page_url_path,
render_result.outbound_links,
is_index_file,
);
config
.link_cache
.insert(page_url_path.clone(), resolved_links.clone());
resolved_links
}
Err(e) => {
tracing::error!("links.json: failed to render page: {}", e);
return None;
}
}
}
ResolvedPath::TagPage { source, value } => {
tracing::debug!(
"links.json: building tag page links for {}/{}",
source,
value
);
build_tag_page_outbound_links(
&source,
&value,
&config.repo.tag_index,
&config.tag_sources,
)
}
ResolvedPath::TagSourceIndex { source } => {
tracing::debug!("links.json: building tag index links for {}", source);
build_tag_index_outbound_links(&source, &config.repo.tag_index)
}
_ => {
tracing::debug!("links.json: page not found: {}", page_url_path);
return None;
}
}
};
let inbound = if let Some(cached) = config.inbound_link_cache.get(&page_url_path) {
cached
} else {
let links = find_inbound_links(
&page_url_path,
&config.base_dir,
&config.markdown_extensions,
&config.ignore_dirs,
&config.ignore_globs,
);
config
.inbound_link_cache
.insert(page_url_path.clone(), links.clone());
links
};
let page_links = PageLinks { inbound, outbound };
let json = match serde_json::to_string(&page_links) {
Ok(j) => j,
Err(e) => {
tracing::error!("Failed to serialize links.json: {}", e);
return None;
}
};
Some(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.body(Body::from(json))
.unwrap(),
)
}
async fn try_serve_errors_json(path: &str, config: &ServerState) -> Option<Response<Body>> {
use crate::page_errors::{
PageErrors, detect_unresolved_wikilinks, validate_internal_links,
validate_media_references,
};
if !path.ends_with("errors.json") {
return None;
}
if !config.link_tracking {
tracing::debug!("errors.json requested but link tracking is disabled");
return None;
}
let page_path = path.strip_suffix("errors.json")?;
let page_url_path = if page_path.is_empty() || page_path == "/" {
"/".to_string()
} else {
let normalized = page_path.trim_end_matches('/').trim_start_matches('/');
format!("/{}/", normalized)
};
tracing::debug!("errors.json request for page: {}", page_url_path);
let tag_url_sources = crate::config::tag_sources_to_url_sources(&config.tag_sources);
let resolver_config = PathResolverConfig {
base_dir: &config.base_dir,
canonical_base_dir: config.canonical_base_dir.as_deref(),
static_folder: &config.static_folder,
markdown_extensions: &config.markdown_extensions,
index_file: &config.index_file,
tag_sources: &tag_url_sources,
};
let request_path = page_url_path.trim_matches('/');
let (outbound_links, html_for_scan, markdown_dir): (
Vec<crate::link_index::OutboundLink>,
String,
PathBuf,
) = match resolve_request_path(&resolver_config, request_path) {
ResolvedPath::MarkdownFile(md_path) => {
let is_index_file = md_path
.file_name()
.and_then(|f| f.to_str())
.is_some_and(|f| f == config.index_file);
let link_transform_config = LinkTransformConfig {
markdown_extensions: config.markdown_extensions.clone(),
index_file: config.index_file.clone(),
is_index_file,
url_depth: None,
};
let valid_tag_sources = crate::config::tag_sources_to_set(&config.tag_sources);
let md_dir = md_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| config.base_dir.clone());
match markdown::render_with_cache(
md_path,
&config.base_dir,
config.oembed_timeout_ms,
link_transform_config,
Some(config.oembed_cache.clone()),
true, false, valid_tag_sources,
false, &config.incomplete_markers,
)
.await
{
Ok(render_result) => {
let resolved_links = resolve_outbound_links(
&page_url_path,
render_result.outbound_links,
is_index_file,
);
config
.link_cache
.insert(page_url_path.clone(), resolved_links.clone());
(resolved_links, render_result.html, md_dir)
}
Err(e) => {
tracing::error!("errors.json: failed to render page: {}", e);
return None;
}
}
}
ResolvedPath::TagPage { source, value } => {
let outbound = build_tag_page_outbound_links(
&source,
&value,
&config.repo.tag_index,
&config.tag_sources,
);
(outbound, String::new(), config.base_dir.clone())
}
ResolvedPath::TagSourceIndex { source } => {
let outbound = build_tag_index_outbound_links(&source, &config.repo.tag_index);
(outbound, String::new(), config.base_dir.clone())
}
_ => {
tracing::debug!("errors.json: page not found: {}", page_url_path);
return None;
}
};
let mut errors = Vec::new();
errors.extend(validate_internal_links(&outbound_links, &resolver_config));
if !html_for_scan.is_empty() {
errors.extend(validate_media_references(
&html_for_scan,
&resolver_config,
&markdown_dir,
));
errors.extend(detect_unresolved_wikilinks(&html_for_scan));
}
let payload = PageErrors {
page_url: page_url_path,
errors,
};
let json = match serde_json::to_string(&payload) {
Ok(j) => j,
Err(e) => {
tracing::error!("Failed to serialize errors.json: {}", e);
return None;
}
};
Some(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.body(Body::from(json))
.unwrap(),
)
}
#[cfg(feature = "media-metadata")]
fn build_jpg_response(bytes: Vec<u8>) -> Response<Body> {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.body(Body::from(bytes))
.unwrap()
}
#[cfg(feature = "media-metadata")]
fn build_vtt_response(vtt: String) -> Response<Body> {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/vtt; charset=utf-8")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.body(Body::from(vtt))
.unwrap()
}
#[cfg(feature = "media-metadata")]
fn build_hls_playlist_response(playlist: Arc<Vec<u8>>) -> Response<Body> {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl")
.header(header::CONTENT_LENGTH, playlist.len())
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.body(Body::from(playlist.as_ref().clone()))
.unwrap()
}
#[cfg(feature = "media-metadata")]
fn build_hls_segment_response(segment: Arc<Vec<u8>>) -> Response<Body> {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "video/mp2t")
.header(header::CONTENT_LENGTH, segment.len())
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.body(Body::from(segment.as_ref().clone()))
.unwrap()
}
#[cfg(feature = "media-metadata")]
async fn try_serve_hls_content(path: &str, config: &ServerState) -> Option<Response<Body>> {
use crate::video_transcode::{
HlsRequest, TranscodeError, generate_hls_playlist, parse_hls_request,
probe_video_resolution, should_transcode, transcode_segment,
};
use crate::video_transcode_cache::{HlsCacheKey, HlsCacheStartResult, HlsCacheState};
fn build_transcode_error_response(error: &TranscodeError) -> Option<Response<Body>> {
match error {
TranscodeError::SourceTooSmall {
source_height,
target_height,
} => Some(
Response::builder()
.status(StatusCode::UNPROCESSABLE_ENTITY)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(format!(
"Cannot transcode: source ({}p) not larger than target ({}p)",
source_height, target_height
)))
.unwrap(),
),
TranscodeError::SegmentOutOfRange {
segment_index,
video_duration,
} => Some(
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(format!(
"Segment {} is out of range (video duration: {:.1}s)",
segment_index, video_duration
)))
.unwrap(),
),
_ => None,
}
}
let hls_request = parse_hls_request(path)?;
let (video_path, target) = match &hls_request {
HlsRequest::Playlist { video_path, target } => (video_path.clone(), *target),
HlsRequest::Segment {
video_path, target, ..
} => (video_path.clone(), *target),
};
tracing::debug!("HLS request: {:?}", hls_request);
let video_file = {
let direct = config.base_dir.join(&video_path);
if direct.is_file() {
direct
} else {
let with_static = config
.base_dir
.join(&config.static_folder)
.join(&video_path);
if with_static.is_file() {
with_static
} else {
tracing::debug!("Original video file not found for HLS: {}", video_path);
return None;
}
}
};
let resolution = probe_video_resolution(&video_file).ok()?;
if !should_transcode(resolution.height, target) {
tracing::debug!(
"Video already at or below target resolution: {}x{} <= {}",
resolution.width,
resolution.height,
target.height()
);
return Some(
Response::builder()
.status(StatusCode::UNPROCESSABLE_ENTITY)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(format!(
"Cannot transcode: source ({}p) not larger than target ({}p)",
resolution.height,
target.height()
)))
.unwrap(),
);
}
match hls_request {
HlsRequest::Playlist { .. } => {
let cache_key = HlsCacheKey::playlist(video_file.clone(), target);
let base_name = std::path::Path::new(&video_path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("video");
match config.hls_cache.start_generation(cache_key.clone()) {
HlsCacheStartResult::Started(notify) => {
tracing::debug!("Generating HLS playlist for {:?}", video_file);
let video_file_clone = video_file.clone();
let base_name = base_name.to_string();
let result = tokio::task::spawn_blocking(move || {
generate_hls_playlist(&video_file_clone, target, &base_name)
})
.await;
match result {
Ok(Ok(playlist)) => {
config
.hls_cache
.complete_generation(cache_key.clone(), playlist.into_bytes());
notify.notify_waiters();
if let Some(HlsCacheState::Complete(data)) =
config.hls_cache.get_state(&cache_key)
{
return Some(Self::build_hls_playlist_response(data));
}
}
Ok(Err(e)) => {
tracing::warn!("Playlist generation failed: {}", e);
config.hls_cache.fail_generation(cache_key, &e);
notify.notify_waiters();
if let Some(response) = build_transcode_error_response(&e) {
return Some(response);
}
return None;
}
Err(e) => {
tracing::warn!("Playlist generation task panicked: {}", e);
return None;
}
}
}
HlsCacheStartResult::AlreadyInProgress(notify) => {
tracing::debug!("Waiting for in-progress playlist generation");
notify.notified().await;
match config.hls_cache.get_state(&cache_key) {
Some(HlsCacheState::Complete(data)) => {
return Some(Self::build_hls_playlist_response(data));
}
_ => return None,
}
}
HlsCacheStartResult::AlreadyComplete(data) => {
tracing::debug!("Serving cached playlist");
return Some(Self::build_hls_playlist_response(data));
}
HlsCacheStartResult::PreviouslyFailed(msg) => {
tracing::debug!("Previous playlist generation failed: {}", msg);
return Some(
Response::builder()
.status(StatusCode::UNPROCESSABLE_ENTITY)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(format!("Transcode failed: {}", msg)))
.unwrap(),
);
}
HlsCacheStartResult::CacheDisabled => {
let video_file_clone = video_file.clone();
let base_name = base_name.to_string();
let result = tokio::task::spawn_blocking(move || {
generate_hls_playlist(&video_file_clone, target, &base_name)
})
.await;
match result {
Ok(Ok(playlist)) => {
return Some(Self::build_hls_playlist_response(Arc::new(
playlist.into_bytes(),
)));
}
Ok(Err(e)) => {
tracing::warn!("Playlist generation failed: {}", e);
if let Some(response) = build_transcode_error_response(&e) {
return Some(response);
}
return None;
}
Err(e) => {
tracing::warn!("Playlist generation task panicked: {}", e);
return None;
}
}
}
}
}
HlsRequest::Segment { segment_index, .. } => {
let cache_key = HlsCacheKey::segment(video_file.clone(), target, segment_index);
match config.hls_cache.start_generation(cache_key.clone()) {
HlsCacheStartResult::Started(notify) => {
tracing::info!(
"Transcoding segment {} for {:?} @ {:?}",
segment_index,
video_file,
target
);
let video_file_clone = video_file.clone();
let result = tokio::task::spawn_blocking(move || {
transcode_segment(&video_file_clone, target, segment_index)
})
.await;
match result {
Ok(Ok(data)) => {
config
.hls_cache
.complete_generation(cache_key.clone(), data);
notify.notify_waiters();
if let Some(HlsCacheState::Complete(data)) =
config.hls_cache.get_state(&cache_key)
{
return Some(Self::build_hls_segment_response(data));
}
}
Ok(Err(e)) => {
tracing::warn!("Segment transcode failed: {}", e);
config.hls_cache.fail_generation(cache_key, &e);
notify.notify_waiters();
if let Some(response) = build_transcode_error_response(&e) {
return Some(response);
}
return None;
}
Err(e) => {
tracing::warn!("Segment transcode task panicked: {}", e);
return None;
}
}
}
HlsCacheStartResult::AlreadyInProgress(notify) => {
tracing::debug!("Waiting for in-progress segment transcode");
notify.notified().await;
match config.hls_cache.get_state(&cache_key) {
Some(HlsCacheState::Complete(data)) => {
return Some(Self::build_hls_segment_response(data));
}
_ => return None,
}
}
HlsCacheStartResult::AlreadyComplete(data) => {
tracing::debug!("Serving cached segment");
return Some(Self::build_hls_segment_response(data));
}
HlsCacheStartResult::PreviouslyFailed(msg) => {
tracing::debug!("Previous segment transcode failed: {}", msg);
return Some(
Response::builder()
.status(StatusCode::UNPROCESSABLE_ENTITY)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(format!("Transcode failed: {}", msg)))
.unwrap(),
);
}
HlsCacheStartResult::CacheDisabled => {
let video_file_clone = video_file.clone();
let result = tokio::task::spawn_blocking(move || {
transcode_segment(&video_file_clone, target, segment_index)
})
.await;
match result {
Ok(Ok(data)) => {
return Some(Self::build_hls_segment_response(Arc::new(data)));
}
Ok(Err(e)) => {
tracing::warn!("Segment transcode failed: {}", e);
if let Some(response) = build_transcode_error_response(&e) {
return Some(response);
}
return None;
}
Err(e) => {
tracing::warn!("Segment transcode task panicked: {}", e);
return None;
}
}
}
}
}
}
None
}
async fn markdown_to_html(
md_path: &Path,
config: &ServerState,
) -> Result<Response<Body>, Box<dyn std::error::Error>> {
let root_path = config.base_dir.as_path();
let is_index_file = md_path
.file_name()
.and_then(|f| f.to_str())
.is_some_and(|f| f == config.index_file);
let link_transform_config = LinkTransformConfig {
markdown_extensions: config.markdown_extensions.clone(),
index_file: config.index_file.clone(),
is_index_file,
url_depth: None,
};
#[cfg(feature = "media-metadata")]
let transcode_enabled = config.transcode_enabled;
#[cfg(not(feature = "media-metadata"))]
let transcode_enabled = false;
let valid_tag_sources = crate::config::tag_sources_to_set(&config.tag_sources);
let render_result = markdown::render_with_cache(
md_path.to_path_buf(),
root_path,
config.oembed_timeout_ms,
link_transform_config,
Some(config.oembed_cache.clone()),
true, transcode_enabled,
valid_tag_sources,
config.mark_incomplete,
&config.incomplete_markers,
)
.await
.inspect_err(|e| tracing::error!("Error rendering markdown: {e}"))?;
let mut frontmatter = render_result.frontmatter;
let headings = render_result.headings;
let inner_html_output = render_result.html;
let outbound_links = render_result.outbound_links;
let has_h1 = render_result.has_h1;
let word_count = render_result.word_count;
let readability_counts = crate::readability::ReadabilityCounts {
words: render_result.word_count,
sentences: render_result.sentence_count,
syllables: render_result.syllable_count,
};
let readability_scores = crate::readability::scores(&readability_counts);
let relative_md_path =
pathdiff::diff_paths(md_path, root_path).unwrap_or_else(|| md_path.to_path_buf());
frontmatter.insert(
"markdown_source".into(),
relative_md_path.to_string_lossy().into(),
);
frontmatter.insert("server_mode".into(), "true".into());
frontmatter.insert(
"gui_mode".into(),
if config.gui_mode { "true" } else { "" }.into(),
);
let url_path_buf = if is_index_file {
relative_md_path
.parent()
.unwrap_or(Path::new(""))
.to_path_buf()
} else {
let parent = relative_md_path.parent().unwrap_or(Path::new(""));
let stem = relative_md_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
parent.join(stem)
};
if config.link_tracking && !outbound_links.is_empty() {
let url_path_str = format!("/{}/", url_path_buf.display()).replace("//", "/");
let resolved_links =
resolve_outbound_links(&url_path_str, outbound_links, is_index_file);
config.link_cache.insert(url_path_str, resolved_links);
}
let breadcrumbs = generate_breadcrumbs(&url_path_buf);
let breadcrumbs_json: Vec<_> = breadcrumbs
.iter()
.map(|b| serde_json::json!({"name": b.name, "url": b.url}))
.collect();
let current_dir_name = get_current_dir_name(&url_path_buf);
let mut extra_context = std::collections::HashMap::new();
extra_context.insert(
"breadcrumbs".to_string(),
serde_json::json!(breadcrumbs_json),
);
extra_context.insert(
"current_dir_name".to_string(),
serde_json::json!(current_dir_name),
);
extra_context.insert("headings".to_string(), serde_json::json!(headings));
extra_context.insert("has_h1".to_string(), serde_json::json!(has_h1));
let tag_sources_json = serde_json::to_string(
&config
.tag_sources
.iter()
.map(|ts| {
serde_json::json!({
"field": ts.field,
"urlSource": ts.url_source(),
"label": ts.singular_label(),
"labelPlural": ts.plural_label()
})
})
.collect::<Vec<_>>(),
)
.unwrap_or_else(|_| "[]".to_string());
extra_context.insert(
"tag_sources".to_string(),
serde_json::json!(tag_sources_json),
);
let reading_time_minutes = word_count.div_ceil(crate::constants::WORDS_PER_MINUTE);
extra_context.insert("word_count".to_string(), serde_json::json!(word_count));
extra_context.insert(
"reading_time_minutes".to_string(),
serde_json::json!(reading_time_minutes),
);
extra_context.insert(
"flesch_reading_ease".to_string(),
serde_json::json!(readability_scores.flesch_reading_ease),
);
extra_context.insert(
"flesch_kincaid_grade".to_string(),
serde_json::json!(readability_scores.flesch_kincaid_grade),
);
extra_context.insert(
"file_path".to_string(),
serde_json::json!(relative_md_path.to_string_lossy()),
);
extra_context.insert(
"sidebar_style".to_string(),
serde_json::json!(config.sidebar_style),
);
extra_context.insert(
"sidebar_max_items".to_string(),
serde_json::json!(config.sidebar_max_items),
);
extra_context.insert(
"title_prefix".to_string(),
serde_json::json!(config.title_prefix),
);
extra_context.insert(
"title_suffix".to_string(),
serde_json::json!(config.title_suffix),
);
let modified_info = tokio::fs::metadata(md_path)
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok());
if let Some(duration) = modified_info {
extra_context.insert(
"modified_timestamp".to_string(),
serde_json::json!(duration.as_secs()),
);
}
let current_url = format!("/{}/", url_path_buf.display()).replace("//", "/");
let parent_dir = relative_md_path.parent().unwrap_or(Path::new(""));
let mut siblings: Vec<_> = config
.repo
.markdown_files
.pin()
.iter()
.filter_map(|(_, info)| {
let file_parent = info.raw_path.parent()?;
if file_parent == parent_dir {
Some(markdown_file_to_json(info))
} else {
None
}
})
.collect();
sort_files(&mut siblings, &config.sort);
if let Some(current_idx) = siblings.iter().position(|f| {
f.get("url_path")
.and_then(|v| v.as_str())
.is_some_and(|p| p == current_url)
}) {
if current_idx > 0
&& let Some(prev) = siblings.get(current_idx - 1)
{
extra_context.insert(
"prev_page".to_string(),
serde_json::json!({
"url": prev.get("url_path"),
"title": prev.get("title").and_then(|v| v.as_str()).unwrap_or("Previous")
}),
);
}
if let Some(next) = siblings.get(current_idx + 1) {
extra_context.insert(
"next_page".to_string(),
serde_json::json!({
"url": next.get("url_path"),
"title": next.get("title").and_then(|v| v.as_str()).unwrap_or("Next")
}),
);
}
}
let full_html_output = config
.templates
.render_markdown(&inner_html_output, frontmatter, extra_context)
.inspect_err(|e| tracing::error!("Error rendering template: {e}"))?;
tracing::debug!("generated the html");
let etag = generate_etag(full_html_output.as_bytes());
let last_modified = tokio::fs::metadata(md_path)
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| generate_last_modified(d.as_secs()));
let mut builder = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(header::ETAG, etag);
if let Some(lm) = last_modified {
builder = builder.header(header::LAST_MODIFIED, lm);
}
builder
.body(Body::from(full_html_output))
.map_err(|e| e.into())
}
async fn directory_to_html(
dir_path: &Path,
templates: &crate::templates::Templates,
root_path: &Path,
config: &ServerState,
) -> Result<Response<Body>, Box<dyn std::error::Error>> {
use serde_json::json;
let temp_repo = Repo::init(
root_path,
&config.static_folder,
&config.markdown_extensions,
&config.ignore_dirs,
&config.ignore_globs,
&config.index_file,
&config.tag_sources,
);
let relative_path = pathdiff::diff_paths(dir_path, root_path)
.unwrap_or_else(|| std::path::PathBuf::from("."));
temp_repo
.scan_folder(&relative_path)
.inspect_err(|e| tracing::error!("Error scanning directory: {e}"))?;
let mut files: Vec<_> = temp_repo
.markdown_files
.pin()
.iter()
.map(|(_, file_info)| markdown_file_to_json(file_info))
.collect();
sort_files(&mut files, &config.sort);
let subdirs: Vec<_> = temp_repo
.queued_folders
.pin()
.iter()
.filter_map(|(abs_path, rel_path)| {
let parent = abs_path.parent()?;
if parent == dir_path {
let name = abs_path.file_name()?.to_str()?.to_string();
let mut url_path = rel_path.to_str()?.to_string();
if !url_path.starts_with('/') {
url_path = "/".to_string() + &url_path;
}
if !url_path.ends_with('/') {
url_path.push('/');
}
Some(json!({
"name": name,
"url_path": url_path,
}))
} else {
None
}
})
.collect();
let breadcrumbs = generate_breadcrumbs(&relative_path);
let breadcrumbs_json: Vec<_> = breadcrumbs
.iter()
.map(|b| json!({"name": b.name, "url": b.url}))
.collect();
let current_dir_name = get_current_dir_name(&relative_path);
let parent_path = get_parent_path(&relative_path);
let mut context = std::collections::HashMap::new();
context.insert("files".to_string(), json!(files));
context.insert("subdirs".to_string(), json!(subdirs));
context.insert("breadcrumbs".to_string(), json!(breadcrumbs_json));
context.insert("current_dir_name".to_string(), json!(current_dir_name));
context.insert(
"current_path".to_string(),
json!(relative_path.to_string_lossy()),
);
if let Some(parent) = parent_path {
context.insert("parent_path".to_string(), json!(parent));
}
context.insert("server_mode".to_string(), json!(true));
context.insert("gui_mode".to_string(), json!(config.gui_mode));
context.insert(
"config".to_string(),
json!({
"static_folder": config.static_folder,
"markdown_extensions": config.markdown_extensions,
"index_file": config.index_file,
"oembed_timeout_ms": config.oembed_timeout_ms,
}),
);
let tag_sources_json = serde_json::to_string(
&config
.tag_sources
.iter()
.map(|ts| {
json!({
"field": ts.field,
"urlSource": ts.url_source(),
"label": ts.singular_label(),
"labelPlural": ts.plural_label()
})
})
.collect::<Vec<_>>(),
)
.unwrap_or_else(|_| "[]".to_string());
context.insert("tag_sources".to_string(), json!(tag_sources_json));
context.insert("sidebar_style".to_string(), json!(config.sidebar_style));
context.insert(
"sidebar_max_items".to_string(),
json!(config.sidebar_max_items),
);
context.insert("title_prefix".to_string(), json!(config.title_prefix));
context.insert("title_suffix".to_string(), json!(config.title_suffix));
let is_root =
relative_path.as_os_str().is_empty() || relative_path == std::path::Path::new(".");
context.insert("is_home".to_string(), json!(is_root));
let full_html_output = if is_root {
templates
.render_home(context)
.inspect_err(|e| tracing::error!("Error rendering home template: {e}"))?
} else {
templates
.render_section(context)
.inspect_err(|e| tracing::error!("Error rendering section template: {e}"))?
};
tracing::debug!("generated directory listing html");
let etag = generate_etag(full_html_output.as_bytes());
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_STORE)
.header(header::ETAG, etag)
.body(Body::from(full_html_output))
.map_err(|e| e.into())
}
async fn tag_page_to_html(
source: &str,
value: &str,
config: &ServerState,
) -> Result<Response<Body>, Box<dyn std::error::Error>> {
use serde_json::json;
let tag_source = config.tag_sources.iter().find(|s| s.url_source() == source);
let (label, label_plural) = if let Some(ts) = tag_source {
(ts.singular_label(), ts.plural_label())
} else {
let capitalized = source
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_default()
+ &source[1..];
(capitalized.clone(), format!("{}s", capitalized))
};
let pages = config.repo.tag_index.get_pages(source, value);
let display_value = config
.repo
.tag_index
.get_tag_display(source, value)
.unwrap_or_else(|| value.to_string());
let pages_json: Vec<serde_json::Value> = pages
.iter()
.map(|page| {
json!({
"url_path": page.url_path,
"title": page.title,
"description": page.description,
})
})
.collect();
let mut context = std::collections::HashMap::new();
context.insert("tag_source".to_string(), json!(source));
context.insert("tag_display_value".to_string(), json!(display_value));
context.insert("tag_label".to_string(), json!(label));
context.insert("tag_label_plural".to_string(), json!(label_plural));
context.insert("pages".to_string(), json!(pages_json));
context.insert("page_count".to_string(), json!(pages.len()));
context.insert("server_mode".to_string(), json!(true));
context.insert("relative_base".to_string(), json!("/.mbr/"));
context.insert("sidebar_style".to_string(), json!(config.sidebar_style));
context.insert(
"sidebar_max_items".to_string(),
json!(config.sidebar_max_items),
);
context.insert("title_prefix".to_string(), json!(config.title_prefix));
context.insert("title_suffix".to_string(), json!(config.title_suffix));
let html_output = config.templates.render_tag(context)?;
let etag = generate_etag(html_output.as_bytes());
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_STORE)
.header(header::ETAG, etag)
.body(Body::from(html_output))
.map_err(|e| e.into())
}
async fn tag_source_index_to_html(
source: &str,
config: &ServerState,
) -> Result<Response<Body>, Box<dyn std::error::Error>> {
use serde_json::json;
let tag_source = config.tag_sources.iter().find(|s| s.url_source() == source);
let (label, label_plural) = if let Some(ts) = tag_source {
(ts.singular_label(), ts.plural_label())
} else {
let capitalized = source
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_default()
+ &source[1..];
(capitalized.clone(), format!("{}s", capitalized))
};
let tags = config.repo.tag_index.get_all_tags(source);
let tags_json: Vec<serde_json::Value> = tags
.iter()
.map(|tag| {
json!({
"url_value": tag.normalized,
"display_value": tag.display,
"page_count": tag.count,
})
})
.collect();
let mut context = std::collections::HashMap::new();
context.insert("tag_source".to_string(), json!(source));
context.insert("tag_label".to_string(), json!(label));
context.insert("tag_label_plural".to_string(), json!(label_plural));
context.insert("tags".to_string(), json!(tags_json));
context.insert("tag_count".to_string(), json!(tags.len()));
context.insert("server_mode".to_string(), json!(true));
context.insert("relative_base".to_string(), json!("/.mbr/"));
context.insert("sidebar_style".to_string(), json!(config.sidebar_style));
context.insert(
"sidebar_max_items".to_string(),
json!(config.sidebar_max_items),
);
context.insert("title_prefix".to_string(), json!(config.title_prefix));
context.insert("title_suffix".to_string(), json!(config.title_suffix));
let html_output = config.templates.render_tag_index(context)?;
let etag = generate_etag(html_output.as_bytes());
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_STORE)
.header(header::ETAG, etag)
.body(Body::from(html_output))
.map_err(|e| e.into())
}
async fn home_page(State(config): State<ServerState>) -> Result<impl IntoResponse, StatusCode> {
tracing::debug!("home_page handler");
let tag_url_sources = crate::config::tag_sources_to_url_sources(&config.tag_sources);
let resolver_config = PathResolverConfig {
base_dir: config.base_dir.as_path(),
canonical_base_dir: config.canonical_base_dir.as_deref(),
static_folder: &config.static_folder,
markdown_extensions: &config.markdown_extensions,
index_file: &config.index_file,
tag_sources: &tag_url_sources,
};
match resolve_request_path(&resolver_config, "") {
ResolvedPath::MarkdownFile(md_path) => {
tracing::debug!("home: rendering index markdown: {:?}", &md_path);
Self::markdown_to_html(&md_path, &config)
.await
.map_err(|e| {
tracing::error!("Error rendering home markdown: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})
}
ResolvedPath::DirectoryListing(dir_path) => {
tracing::debug!("home: generating directory listing: {:?}", &dir_path);
Self::directory_to_html(
&dir_path,
&config.templates,
config.base_dir.as_path(),
&config,
)
.await
.map_err(|e| {
tracing::error!("Error generating home directory listing: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})
}
_ => {
tracing::debug!("home: unexpected resolution, showing directory listing");
Self::directory_to_html(
&config.base_dir,
&config.templates,
config.base_dir.as_path(),
&config,
)
.await
.map_err(|e| {
tracing::error!("Error generating home directory listing: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Breadcrumb {
pub name: String,
pub url: String,
}
impl Breadcrumb {
pub fn new(name: impl Into<String>, url: impl Into<String>) -> Self {
Self {
name: name.into(),
url: url.into(),
}
}
}
pub fn generate_breadcrumbs(relative_path: &Path) -> Vec<Breadcrumb> {
let path_components: Vec<_> = relative_path
.components()
.filter_map(|c| {
if let std::path::Component::Normal(s) = c {
s.to_str()
} else {
None
}
})
.collect();
if path_components.is_empty() {
return vec![];
}
let mut breadcrumbs = vec![Breadcrumb::new("Home", "/")];
for (idx, _) in path_components
.iter()
.enumerate()
.take(path_components.len().saturating_sub(1))
{
let partial_path: std::path::PathBuf = path_components.iter().take(idx + 1).collect();
let url = format!("/{}/", partial_path.to_string_lossy());
let name = path_components[idx].to_string();
breadcrumbs.push(Breadcrumb::new(name, url));
}
breadcrumbs
}
pub fn get_current_dir_name(relative_path: &Path) -> String {
relative_path
.file_name()
.and_then(|s| s.to_str())
.map(String::from)
.unwrap_or_else(|| "Home".to_string())
}
pub fn get_parent_path(relative_path: &Path) -> Option<String> {
let path_components: Vec<_> = relative_path
.components()
.filter_map(|c| {
if let std::path::Component::Normal(s) = c {
s.to_str()
} else {
None
}
})
.collect();
if path_components.len() > 1 {
let parent: std::path::PathBuf = path_components
.iter()
.take(path_components.len() - 1)
.collect();
Some(format!("/{}/", parent.to_string_lossy()))
} else if !path_components.is_empty() {
Some("/".to_string())
} else {
None
}
}
pub fn markdown_file_to_json(file_info: &MarkdownInfo) -> serde_json::Value {
use serde_json::json;
let title = file_info
.frontmatter
.as_ref()
.and_then(|fm| fm.get("title"))
.cloned()
.unwrap_or_else(|| {
serde_json::Value::String(
file_info
.raw_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Untitled")
.to_string(),
)
});
let description = file_info
.frontmatter
.as_ref()
.and_then(|fm| fm.get("description"))
.cloned();
let tags = file_info
.frontmatter
.as_ref()
.and_then(|fm| fm.get("tags"))
.cloned();
let modified_date = chrono::DateTime::from_timestamp(file_info.modified as i64, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "Unknown".to_string());
json!({
"title": title,
"url_path": file_info.url_path,
"description": description,
"tags": tags,
"modified_date": modified_date,
"modified": file_info.modified,
"name": file_info.raw_path.file_name().and_then(|s| s.to_str()).unwrap_or(""),
})
}
fn generate_etag(content: &[u8]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
let hash = hasher.finish();
format!("W/\"{:x}\"", hash)
}
fn generate_last_modified(timestamp: u64) -> Option<String> {
chrono::DateTime::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
}
const CACHE_CONTROL_NO_CACHE: &str = "no-cache";
const CACHE_CONTROL_NO_STORE: &str = "no-store";
pub const DEFAULT_FILES: &[(&str, &[u8], &str)] = &[
(
"/favicon.png",
include_bytes!("../templates/favicon.png"),
"image/png",
),
(
"/theme.css",
include_bytes!("../templates/theme.css"),
"text/css",
),
(
"/user.css",
include_bytes!("../templates/user.css"),
"text/css",
),
(
"/pico.min.css",
include_bytes!("../templates/pico-main/pico.min.css"),
"text/css",
),
(
"/components/mbr-components.min.js",
include_bytes!("../templates/components-js/mbr-components.min.js"),
"application/javascript",
),
(
"/hljs.dark.css",
include_bytes!("../templates/hljs.dark.11.11.1.css"),
"text/css",
),
(
"/hljs.atom-one-dark.css",
include_bytes!("../templates/hljs.atom-one-dark.11.11.1.css"),
"text/css",
),
(
"/hljs.js",
include_bytes!("../templates/hljs.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.css.js",
include_bytes!("../templates/hljs.lang.css.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.javascript.js",
include_bytes!("../templates/hljs.lang.javascript.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.typescript.js",
include_bytes!("../templates/hljs.lang.typescript.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.rust.js",
include_bytes!("../templates/hljs.lang.rust.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.python.js",
include_bytes!("../templates/hljs.lang.python.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.bash.js",
include_bytes!("../templates/hljs.lang.bash.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.java.js",
include_bytes!("../templates/hljs.lang.java.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.scala.js",
include_bytes!("../templates/hljs.lang.scala.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.go.js",
include_bytes!("../templates/hljs.lang.go.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.ruby.js",
include_bytes!("../templates/hljs.lang.ruby.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.nix.js",
include_bytes!("../templates/hljs.lang.nix.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.json.js",
include_bytes!("../templates/hljs.lang.json.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.yaml.js",
include_bytes!("../templates/hljs.lang.yaml.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.xml.js",
include_bytes!("../templates/hljs.lang.xml.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.sql.js",
include_bytes!("../templates/hljs.lang.sql.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.dockerfile.js",
include_bytes!("../templates/hljs.lang.dockerfile.11.11.1.js"),
"application/javascript",
),
(
"/hljs.lang.markdown.js",
include_bytes!("../templates/hljs.lang.markdown.11.11.1.js"),
"application/javascript",
),
(
"/mermaid.min.js",
include_bytes!("../templates/mermaid.11.12.2.min.js"),
"application/javascript",
),
(
"/reveal.js",
include_bytes!("../templates/reveal.5.2.1.js"),
"application/javascript",
),
(
"/reveal.css",
include_bytes!("../templates/reveal.5.2.1.css"),
"text/css",
),
(
"/reveal-theme-blank.css",
include_bytes!("../templates/reveal.theme.blank.5.2.1.css"),
"text/css",
),
(
"/reveal-theme-black.css",
include_bytes!("../templates/reveal.theme.black.5.2.1.css"),
"text/css",
),
(
"/reveal-theme-white.css",
include_bytes!("../templates/reveal.theme.white.5.2.1.css"),
"text/css",
),
(
"/reveal-slides.css",
include_bytes!("../templates/reveal-slides.css"),
"text/css",
),
(
"/reveal-notes.js",
include_bytes!("../templates/reveal.notes.5.2.1.js"),
"application/javascript",
),
];
fn build_tag_page_outbound_links(
source: &str,
value: &str,
tag_index: &crate::tag_index::TagIndex,
tag_sources: &[TagSource],
) -> Vec<crate::link_index::OutboundLink> {
use crate::link_index::OutboundLink;
let mut outbound = Vec::new();
for page in tag_index.get_pages(source, value) {
outbound.push(OutboundLink {
to: page.url_path,
text: page.title,
anchor: None,
internal: true,
});
}
let label = tag_sources
.iter()
.find(|ts| ts.url_source() == source)
.map(|ts| ts.plural_label())
.unwrap_or_else(|| source.to_string());
outbound.push(OutboundLink {
to: format!("/{}/", source),
text: label,
anchor: None,
internal: true,
});
outbound
}
fn build_tag_index_outbound_links(
source: &str,
tag_index: &crate::tag_index::TagIndex,
) -> Vec<crate::link_index::OutboundLink> {
use crate::link_index::OutboundLink;
tag_index
.get_all_tags(source)
.into_iter()
.map(|tag| OutboundLink {
to: format!("/{}/{}/", source, tag.normalized),
text: tag.display,
anchor: None,
internal: true,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
#[test]
fn test_generate_breadcrumbs_root() {
let path = Path::new("");
let breadcrumbs = generate_breadcrumbs(path);
assert_eq!(breadcrumbs.len(), 0);
}
#[test]
fn test_generate_breadcrumbs_single_level() {
let path = Path::new("docs");
let breadcrumbs = generate_breadcrumbs(path);
assert_eq!(breadcrumbs.len(), 1);
assert_eq!(breadcrumbs[0], Breadcrumb::new("Home", "/"));
}
#[test]
fn test_generate_breadcrumbs_two_levels() {
let path = Path::new("docs/api");
let breadcrumbs = generate_breadcrumbs(path);
assert_eq!(breadcrumbs.len(), 2);
assert_eq!(breadcrumbs[0], Breadcrumb::new("Home", "/"));
assert_eq!(breadcrumbs[1], Breadcrumb::new("docs", "/docs/"));
}
#[test]
fn test_generate_breadcrumbs_deep_nesting() {
let path = Path::new("/a/b/c/d");
let breadcrumbs = generate_breadcrumbs(path);
assert_eq!(breadcrumbs.len(), 4);
assert_eq!(breadcrumbs[0], Breadcrumb::new("Home", "/"));
assert_eq!(breadcrumbs[1], Breadcrumb::new("a", "/a/"));
assert_eq!(breadcrumbs[2], Breadcrumb::new("b", "/a/b/"));
assert_eq!(breadcrumbs[3], Breadcrumb::new("c", "/a/b/c/"));
}
#[test]
fn test_get_current_dir_name_root() {
let path = Path::new("");
assert_eq!(get_current_dir_name(path), "Home");
}
#[test]
fn test_get_current_dir_name_single_level() {
let path = Path::new("docs");
assert_eq!(get_current_dir_name(path), "docs");
}
#[test]
fn test_get_current_dir_name_nested() {
let path = Path::new("a/b/c");
assert_eq!(get_current_dir_name(path), "c");
}
#[test]
fn test_get_parent_path_root() {
let path = Path::new("");
assert_eq!(get_parent_path(path), None);
}
#[test]
fn test_get_parent_path_single_level() {
let path = Path::new("docs");
assert_eq!(get_parent_path(path), Some("/".to_string()));
}
#[test]
fn test_get_parent_path_two_levels() {
let path = Path::new("docs/api");
assert_eq!(get_parent_path(path), Some("/docs/".to_string()));
}
#[test]
fn test_get_parent_path_deep() {
let path = Path::new("a/b/c/d");
assert_eq!(get_parent_path(path), Some("/a/b/c/".to_string()));
}
#[test]
fn test_markdown_file_to_json_with_frontmatter() {
let mut frontmatter = HashMap::new();
frontmatter.insert(
"title".to_string(),
serde_json::Value::String("My Title".to_string()),
);
frontmatter.insert(
"description".to_string(),
serde_json::Value::String("My description".to_string()),
);
frontmatter.insert("tags".to_string(), serde_json::json!(["rust", "testing"]));
let file_info = MarkdownInfo {
raw_path: PathBuf::from("/root/test.md"),
url_path: "/test/".to_string(),
frontmatter: Some(frontmatter),
created: 1699000000,
modified: 1700000000,
};
let json = markdown_file_to_json(&file_info);
assert_eq!(json["title"], "My Title");
assert_eq!(json["url_path"], "/test/");
assert_eq!(json["description"], "My description");
assert_eq!(json["tags"], serde_json::json!(["rust", "testing"]));
assert_eq!(json["modified"], 1700000000);
assert_eq!(json["name"], "test.md");
}
#[test]
fn test_markdown_file_to_json_without_frontmatter() {
let file_info = MarkdownInfo {
raw_path: PathBuf::from("/root/my-document.md"),
url_path: "/my-document/".to_string(),
frontmatter: None,
created: 1699000000,
modified: 1700000000,
};
let json = markdown_file_to_json(&file_info);
assert_eq!(json["title"], "my-document");
assert_eq!(json["url_path"], "/my-document/");
assert!(json["description"].is_null());
assert!(json["tags"].is_null());
}
#[test]
fn test_markdown_file_to_json_partial_frontmatter() {
let mut frontmatter = HashMap::new();
frontmatter.insert(
"title".to_string(),
serde_json::Value::String("Only Title".to_string()),
);
let file_info = MarkdownInfo {
raw_path: PathBuf::from("/root/partial.md"),
url_path: "/partial/".to_string(),
frontmatter: Some(frontmatter),
created: 1699000000,
modified: 1700000000,
};
let json = markdown_file_to_json(&file_info);
assert_eq!(json["title"], "Only Title");
assert!(json["description"].is_null());
assert!(json["tags"].is_null());
}
#[test]
fn test_breadcrumb_equality() {
let b1 = Breadcrumb::new("Home", "/");
let b2 = Breadcrumb::new("Home", "/");
let b3 = Breadcrumb::new("Docs", "/docs/");
assert_eq!(b1, b2);
assert_ne!(b1, b3);
}
#[test]
fn test_media_viewer_type_from_route_videos() {
assert_eq!(
MediaViewerType::from_route("/.mbr/videos/"),
Some(MediaViewerType::Video)
);
}
#[test]
fn test_media_viewer_type_from_route_pdfs() {
assert_eq!(
MediaViewerType::from_route("/.mbr/pdfs/"),
Some(MediaViewerType::Pdf)
);
}
#[test]
fn test_media_viewer_type_from_route_audio() {
assert_eq!(
MediaViewerType::from_route("/.mbr/audio/"),
Some(MediaViewerType::Audio)
);
}
#[test]
fn test_media_viewer_type_from_route_images() {
assert_eq!(
MediaViewerType::from_route("/.mbr/images/"),
Some(MediaViewerType::Image)
);
}
#[test]
fn test_media_viewer_type_from_route_invalid() {
assert_eq!(MediaViewerType::from_route("/some/other/path"), None);
assert_eq!(MediaViewerType::from_route("/.mbr/videos"), None); assert_eq!(MediaViewerType::from_route("/.mbr/unknown/"), None);
}
#[test]
fn test_media_viewer_type_template_name() {
assert_eq!(MediaViewerType::Video.template_name(), "media_viewer.html");
assert_eq!(MediaViewerType::Pdf.template_name(), "media_viewer.html");
assert_eq!(MediaViewerType::Audio.template_name(), "media_viewer.html");
}
#[test]
fn test_media_viewer_type_label() {
assert_eq!(MediaViewerType::Video.label(), "Video");
assert_eq!(MediaViewerType::Pdf.label(), "PDF");
assert_eq!(MediaViewerType::Audio.label(), "Audio");
}
#[test]
fn test_media_viewer_type_as_str() {
assert_eq!(MediaViewerType::Video.as_str(), "video");
assert_eq!(MediaViewerType::Pdf.as_str(), "pdf");
assert_eq!(MediaViewerType::Audio.as_str(), "audio");
}
#[test]
fn test_media_viewer_type_from_extension_video() {
for ext in &[
"mp4", "m4v", "mov", "webm", "flv", "mpg", "mpeg", "avi", "3gp", "wmv", "mkv", "ts",
"mts", "m2ts", "vob", "divx", "xvid", "asf", "rm", "rmvb", "f4v", "ogv",
] {
assert_eq!(
MediaViewerType::from_extension(ext),
Some(MediaViewerType::Video),
"Expected Video for extension '{ext}'"
);
}
}
#[test]
fn test_media_viewer_type_from_extension_audio() {
for ext in &[
"mp3", "wav", "ogg", "flac", "aac", "m4a", "aiff", "aif", "oga", "opus", "wma",
] {
assert_eq!(
MediaViewerType::from_extension(ext),
Some(MediaViewerType::Audio),
"Expected Audio for extension '{ext}'"
);
}
}
#[test]
fn test_media_viewer_type_from_extension_image() {
for ext in &[
"jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff", "svg",
] {
assert_eq!(
MediaViewerType::from_extension(ext),
Some(MediaViewerType::Image),
"Expected Image for extension '{ext}'"
);
}
}
#[test]
fn test_media_viewer_type_from_extension_pdf() {
assert_eq!(
MediaViewerType::from_extension("pdf"),
Some(MediaViewerType::Pdf)
);
}
#[test]
fn test_media_viewer_type_from_extension_case_insensitive() {
assert_eq!(
MediaViewerType::from_extension("MP4"),
Some(MediaViewerType::Video)
);
assert_eq!(
MediaViewerType::from_extension("Pdf"),
Some(MediaViewerType::Pdf)
);
assert_eq!(
MediaViewerType::from_extension("JPG"),
Some(MediaViewerType::Image)
);
}
#[test]
fn test_media_viewer_type_from_extension_unknown() {
assert_eq!(MediaViewerType::from_extension("md"), None);
assert_eq!(MediaViewerType::from_extension("html"), None);
assert_eq!(MediaViewerType::from_extension("rs"), None);
assert_eq!(MediaViewerType::from_extension(""), None);
}
#[test]
fn test_media_viewer_type_from_path() {
assert_eq!(
MediaViewerType::from_path(Path::new("videos/demo.mp4")),
Some(MediaViewerType::Video)
);
assert_eq!(
MediaViewerType::from_path(Path::new("music/song.mp3")),
Some(MediaViewerType::Audio)
);
assert_eq!(
MediaViewerType::from_path(Path::new("images/photo.jpg")),
Some(MediaViewerType::Image)
);
assert_eq!(
MediaViewerType::from_path(Path::new("docs/paper.pdf")),
Some(MediaViewerType::Pdf)
);
assert_eq!(MediaViewerType::from_path(Path::new("readme.md")), None);
assert_eq!(MediaViewerType::from_path(Path::new("noext")), None);
}
#[test]
fn test_media_viewer_type_route_path() {
assert_eq!(MediaViewerType::Video.route_path(), "/.mbr/videos/");
assert_eq!(MediaViewerType::Pdf.route_path(), "/.mbr/pdfs/");
assert_eq!(MediaViewerType::Audio.route_path(), "/.mbr/audio/");
assert_eq!(MediaViewerType::Image.route_path(), "/.mbr/images/");
}
#[test]
fn test_media_viewer_type_route_path_roundtrips_with_from_route() {
for media_type in &[
MediaViewerType::Video,
MediaViewerType::Pdf,
MediaViewerType::Audio,
MediaViewerType::Image,
] {
assert_eq!(
MediaViewerType::from_route(media_type.route_path()),
Some(*media_type),
"route_path -> from_route roundtrip failed for {media_type:?}"
);
}
}
#[test]
fn test_validate_media_path_rejects_directory_traversal() {
let temp_dir = tempfile::tempdir().unwrap();
let result = validate_media_path("../etc/passwd", temp_dir.path(), "");
assert!(matches!(result, Err(MbrError::DirectoryTraversal)));
}
#[test]
fn test_validate_media_path_rejects_embedded_directory_traversal() {
let temp_dir = tempfile::tempdir().unwrap();
let result = validate_media_path("some/../../etc/passwd", temp_dir.path(), "");
assert!(matches!(result, Err(MbrError::DirectoryTraversal)));
}
#[test]
fn test_validate_media_path_rejects_url_encoded_traversal() {
let temp_dir = tempfile::tempdir().unwrap();
let result = validate_media_path("%2e%2e/etc/passwd", temp_dir.path(), "");
assert!(matches!(result, Err(MbrError::DirectoryTraversal)));
}
#[test]
fn test_validate_media_path_rejects_nonexistent_file() {
let temp_dir = tempfile::tempdir().unwrap();
let result = validate_media_path("nonexistent.mp4", temp_dir.path(), "");
assert!(matches!(result, Err(MbrError::InvalidMediaPath(_))));
}
#[test]
fn test_validate_media_path_accepts_valid_file() {
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("test.mp4");
std::fs::write(&test_file, "dummy content").unwrap();
let result = validate_media_path("test.mp4", temp_dir.path(), "");
assert!(result.is_ok());
assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
}
#[test]
fn test_validate_media_path_handles_leading_slash() {
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("test.mp4");
std::fs::write(&test_file, "dummy content").unwrap();
let result = validate_media_path("/test.mp4", temp_dir.path(), "");
assert!(result.is_ok());
assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
}
#[test]
fn test_validate_media_path_handles_url_encoded_spaces() {
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("test file.mp4");
std::fs::write(&test_file, "dummy content").unwrap();
let result = validate_media_path("test%20file.mp4", temp_dir.path(), "");
assert!(result.is_ok());
assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
}
#[test]
fn test_validate_media_path_handles_nested_paths() {
let temp_dir = tempfile::tempdir().unwrap();
let subdir = temp_dir.path().join("videos").join("2024");
std::fs::create_dir_all(&subdir).unwrap();
let test_file = subdir.join("demo.mp4");
std::fs::write(&test_file, "dummy content").unwrap();
let result = validate_media_path("videos/2024/demo.mp4", temp_dir.path(), "");
assert!(result.is_ok());
assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
}
#[test]
fn test_validate_media_path_external_static_folder_works() {
let parent_dir = tempfile::tempdir().unwrap();
let content_dir = parent_dir.path().join("content");
let static_dir = parent_dir.path().join("static");
std::fs::create_dir_all(&content_dir).unwrap();
std::fs::create_dir_all(static_dir.join("videos")).unwrap();
let video_file = static_dir.join("videos").join("test.mp4");
std::fs::write(&video_file, "video content").unwrap();
let result = validate_media_path("videos/test.mp4", &content_dir, "../static");
assert!(result.is_ok());
assert_eq!(result.unwrap(), video_file.canonicalize().unwrap());
}
#[test]
fn test_validate_media_path_content_root_takes_precedence() {
let parent_dir = tempfile::tempdir().unwrap();
let content_dir = parent_dir.path().join("content");
let static_dir = parent_dir.path().join("static");
std::fs::create_dir_all(content_dir.join("videos")).unwrap();
std::fs::create_dir_all(static_dir.join("videos")).unwrap();
let content_video = content_dir.join("videos").join("test.mp4");
let static_video = static_dir.join("videos").join("test.mp4");
std::fs::write(&content_video, "content version").unwrap();
std::fs::write(&static_video, "static version").unwrap();
let result = validate_media_path("videos/test.mp4", &content_dir, "../static");
assert!(result.is_ok());
assert_eq!(result.unwrap(), content_video.canonicalize().unwrap());
}
#[test]
fn test_validate_media_path_rejects_traversal_in_external_static() {
let parent_dir = tempfile::tempdir().unwrap();
let content_dir = parent_dir.path().join("content");
let static_dir = parent_dir.path().join("static");
std::fs::create_dir_all(&content_dir).unwrap();
std::fs::create_dir_all(&static_dir).unwrap();
let result = validate_media_path("../etc/passwd", &content_dir, "../static");
assert!(matches!(result, Err(MbrError::DirectoryTraversal)));
}
#[test]
fn test_validate_media_path_empty_static_folder_disables_fallback() {
let temp_dir = tempfile::tempdir().unwrap();
let result = validate_media_path("nonexistent.mp4", temp_dir.path(), "");
assert!(matches!(result, Err(MbrError::InvalidMediaPath(_))));
}
#[test]
fn test_validate_media_path_external_static_nested_path() {
let parent_dir = tempfile::tempdir().unwrap();
let content_dir = parent_dir.path().join("content");
let static_dir = parent_dir.path().join("static");
std::fs::create_dir_all(&content_dir).unwrap();
let nested_dir = static_dir.join("videos").join("Jay Sankey").join("2024");
std::fs::create_dir_all(&nested_dir).unwrap();
let video_file = nested_dir.join("performance.mp4");
std::fs::write(&video_file, "video content").unwrap();
let result = validate_media_path(
"videos/Jay%20Sankey/2024/performance.mp4",
&content_dir,
"../static",
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), video_file.canonicalize().unwrap());
}
#[test]
fn test_validate_media_path_nonexistent_static_folder_fallback_fails() {
let temp_dir = tempfile::tempdir().unwrap();
let content_dir = temp_dir.path().join("content");
std::fs::create_dir_all(&content_dir).unwrap();
let result = validate_media_path("videos/test.mp4", &content_dir, "../nonexistent");
assert!(matches!(result, Err(MbrError::InvalidMediaPath(_))));
}
#[test]
fn test_safe_join_asset_accepts_valid_file() {
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("theme.css");
std::fs::write(&test_file, "body {}").unwrap();
let result = safe_join_asset(temp_dir.path(), "theme.css");
assert!(result.is_some());
assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
}
#[test]
fn test_safe_join_asset_handles_leading_slash() {
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("theme.css");
std::fs::write(&test_file, "body {}").unwrap();
let result = safe_join_asset(temp_dir.path(), "/theme.css");
assert!(result.is_some());
assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
}
#[test]
fn test_safe_join_asset_rejects_directory_traversal() {
let temp_dir = tempfile::tempdir().unwrap();
let attacks = vec![
"../etc/passwd",
"../../etc/passwd",
"foo/../../../etc/passwd",
"../theme.css",
];
for attack in attacks {
let result = safe_join_asset(temp_dir.path(), attack);
assert!(
result.is_none(),
"Path traversal should be blocked for: {}",
attack
);
}
}
#[test]
fn test_safe_join_asset_rejects_nonexistent_file() {
let temp_dir = tempfile::tempdir().unwrap();
let result = safe_join_asset(temp_dir.path(), "nonexistent.css");
assert!(result.is_none());
}
#[test]
fn test_safe_join_asset_rejects_directory() {
let temp_dir = tempfile::tempdir().unwrap();
let subdir = temp_dir.path().join("subdir");
std::fs::create_dir(&subdir).unwrap();
let result = safe_join_asset(temp_dir.path(), "subdir");
assert!(result.is_none(), "Directories should not be served");
}
#[test]
fn test_safe_join_asset_handles_nested_paths() {
let temp_dir = tempfile::tempdir().unwrap();
let nested = temp_dir.path().join("components-js").join("module");
std::fs::create_dir_all(&nested).unwrap();
let test_file = nested.join("app.js");
std::fs::write(&test_file, "export {}").unwrap();
let result = safe_join_asset(temp_dir.path(), "components-js/module/app.js");
assert!(result.is_some());
assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
}
#[cfg(unix)]
#[test]
fn test_safe_join_asset_blocks_symlink_escape() {
use std::os::unix::fs::symlink;
let temp_dir = tempfile::tempdir().unwrap();
let link_path = temp_dir.path().join("escape");
if symlink("/tmp", &link_path).is_ok() {
let result = safe_join_asset(temp_dir.path(), "escape/some_file");
assert!(result.is_none(), "Symlink escape should be blocked");
}
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
fn path_component_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9_-]{1,15}"
}
proptest! {
#[test]
fn prop_breadcrumb_count_matches_path_depth(
components in proptest::collection::vec(path_component_strategy(), 0..5)
) {
let path_str = components.join("/");
let path = Path::new(&path_str);
let breadcrumbs = generate_breadcrumbs(path);
let expected_count = if components.is_empty() {
0 } else {
components.len() };
prop_assert_eq!(
breadcrumbs.len(),
expected_count,
"Path {:?} should have {} breadcrumbs, got {}",
path,
expected_count,
breadcrumbs.len()
);
}
#[test]
fn prop_first_breadcrumb_is_home(
components in proptest::collection::vec(path_component_strategy(), 1..5)
) {
let path_str = components.join("/");
let path = Path::new(&path_str);
let breadcrumbs = generate_breadcrumbs(path);
prop_assert!(!breadcrumbs.is_empty(), "Non-root paths should have at least Home breadcrumb");
prop_assert_eq!(&breadcrumbs[0].name, "Home");
prop_assert_eq!(&breadcrumbs[0].url, "/");
}
#[test]
fn prop_last_breadcrumb_matches_parent_component(
components in proptest::collection::vec(path_component_strategy(), 2..5)
) {
let path_str = components.join("/");
let path = Path::new(&path_str);
let breadcrumbs = generate_breadcrumbs(path);
let last_breadcrumb = breadcrumbs.last().unwrap();
let parent_component = &components[components.len() - 2];
prop_assert_eq!(
&last_breadcrumb.name,
parent_component,
"Last breadcrumb should be {:?}, got {:?}",
parent_component,
last_breadcrumb.name
);
}
#[test]
fn prop_breadcrumb_urls_end_with_slash(
components in proptest::collection::vec(path_component_strategy(), 0..5)
) {
let path_str = components.join("/");
let path = Path::new(&path_str);
let breadcrumbs = generate_breadcrumbs(path);
for bc in &breadcrumbs {
prop_assert!(
bc.url.ends_with('/'),
"Breadcrumb URL {:?} should end with /",
bc.url
);
}
}
#[test]
fn prop_current_dir_name_is_last_component(
components in proptest::collection::vec(path_component_strategy(), 1..5)
) {
let path_str = components.join("/");
let path = Path::new(&path_str);
let name = get_current_dir_name(path);
let expected = components.last().unwrap();
prop_assert_eq!(
&name,
expected,
"Current dir name should be {:?}, got {:?}",
expected,
name
);
}
#[test]
fn prop_parent_path_behavior(
components in proptest::collection::vec(path_component_strategy(), 0..5)
) {
let path_str = components.join("/");
let path = Path::new(&path_str);
let parent = get_parent_path(path);
if components.is_empty() {
prop_assert!(parent.is_none(), "Root should have no parent");
} else {
prop_assert!(parent.is_some(), "Non-root should have parent");
let parent_str = parent.unwrap();
prop_assert!(
parent_str.ends_with('/'),
"Parent path should end with /: {:?}",
parent_str
);
}
}
#[test]
fn prop_parent_path_shorter_than_original(
components in proptest::collection::vec(path_component_strategy(), 2..5)
) {
let path_str = components.join("/");
let path = Path::new(&path_str);
if let Some(parent) = get_parent_path(path) {
let parent_trimmed = parent.trim_end_matches('/');
prop_assert!(
parent_trimmed.len() < path_str.len(),
"Parent {:?} should be shorter than {:?}",
parent_trimmed,
path_str
);
}
}
#[test]
fn prop_validate_media_path_rejects_dotdot(
prefix in "[a-zA-Z0-9_-]{0,10}",
suffix in "[a-zA-Z0-9_-]{0,10}"
) {
let temp_dir = tempfile::tempdir().unwrap();
let test_paths = vec![
format!("{}/../{}", prefix, suffix),
format!("../{}/{}", prefix, suffix),
format!("{}/{}/..", prefix, suffix),
format!("{}%2F..%2F{}", prefix, suffix), ];
for path in test_paths {
if path.contains("..") {
let result = validate_media_path(&path, temp_dir.path(), "");
prop_assert!(
result.is_err(),
"Path containing '..' should be rejected: {:?}",
path
);
}
}
}
#[test]
fn prop_validate_media_path_deterministic(
path in "[a-zA-Z0-9_/-]{1,30}"
) {
let temp_dir = tempfile::tempdir().unwrap();
let result1 = validate_media_path(&path, temp_dir.path(), "");
let result2 = validate_media_path(&path, temp_dir.path(), "");
match (&result1, &result2) {
(Ok(p1), Ok(p2)) => prop_assert_eq!(p1, p2),
(Err(_), Err(_)) => (), _ => prop_assert!(false, "Results should be consistent: {:?} vs {:?}", result1, result2),
}
}
#[test]
fn prop_validate_media_path_decodes_url_encoding(
filename in "[a-zA-Z0-9]{1,15}"
) {
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join(&filename);
std::fs::write(&test_file, "test").unwrap();
let encoded = format!("%20{}", filename); let result = validate_media_path(&encoded, temp_dir.path(), "");
prop_assert!(result.is_err(), "Encoded path with non-existent target should fail");
let result = validate_media_path(&filename, temp_dir.path(), "");
prop_assert!(result.is_ok(), "Valid path should succeed: {:?}", filename);
}
#[test]
fn prop_validate_media_path_valid_paths_succeed(
filename in "[a-zA-Z0-9_-]{1,15}"
) {
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join(&filename);
std::fs::write(&test_file, "test content").unwrap();
let result = validate_media_path(&filename, temp_dir.path(), "");
prop_assert!(result.is_ok(), "Valid file path should succeed: {:?}", filename);
if let Ok(canonical) = result {
let expected_canonical = test_file.canonicalize().unwrap();
prop_assert_eq!(canonical, expected_canonical);
}
}
#[test]
fn prop_validate_media_path_handles_leading_slash(
filename in "[a-zA-Z0-9_-]{1,15}"
) {
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join(&filename);
std::fs::write(&test_file, "test content").unwrap();
let path_with_slash = format!("/{}", filename);
let result = validate_media_path(&path_with_slash, temp_dir.path(), "");
prop_assert!(result.is_ok(), "Path with leading slash should work: {:?}", path_with_slash);
let result_no_slash = validate_media_path(&filename, temp_dir.path(), "");
prop_assert!(result_no_slash.is_ok(), "Path without leading slash should work: {:?}", filename);
if let (Ok(p1), Ok(p2)) = (result, result_no_slash) {
prop_assert_eq!(p1, p2, "Leading slash should not change result");
}
}
}
}