use axum::{
Router,
body::Body,
extract::{self, ConnectInfo, DefaultBodyLimit, OriginalUri, State, ws::WebSocketUpgrade},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Json, Response},
routing::{get, post},
};
use futures_util::{SinkExt, StreamExt};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
use std::collections::HashSet;
use std::{net::SocketAddr, path::Path, path::PathBuf, sync::Arc};
use tokio::sync::broadcast;
use crate::config::{RelationType, SortField, TagSource};
use crate::embedded_katex;
use crate::embedded_pico;
use crate::errors::{MbrError, ServerError};
use crate::link_grep::InboundLinkCache;
use crate::link_index::{InboundIndex, LinkCache, resolve_outbound_links};
use crate::link_transform::LinkTransformConfig;
use crate::oembed_cache::OembedCache;
use crate::page_context::{self, ModeFlags, PageChrome, UrlMode};
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 std::time::Instant;
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;
#[cfg(feature = "media-metadata")]
const METADATA_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
#[cfg(feature = "media-metadata")]
const MEDIA_COMPAT_PROBE_CONCURRENCY: usize = 4;
enum InflightClaim {
Produce(Arc<tokio::sync::Notify>),
Wait(Arc<tokio::sync::Notify>),
}
fn claim_inflight(
inflight: &papaya::HashMap<String, Arc<tokio::sync::Notify>>,
key: &str,
) -> InflightClaim {
let notify = Arc::new(tokio::sync::Notify::new());
match inflight.pin().try_insert(key.to_string(), notify.clone()) {
Ok(_) => InflightClaim::Produce(notify),
Err(papaya::OccupiedError { current, .. }) => InflightClaim::Wait(current.clone()),
}
}
struct InflightSlot {
inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>>,
key: String,
notify: Arc<tokio::sync::Notify>,
}
impl InflightSlot {
fn new(
inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>>,
key: String,
notify: Arc<tokio::sync::Notify>,
) -> Self {
Self {
inflight,
key,
notify,
}
}
}
impl Drop for InflightSlot {
fn drop(&mut self) {
self.inflight.pin().remove(&self.key);
self.notify.notify_waiters();
}
}
#[cfg(feature = "media-metadata")]
struct HlsGenerationSlot<'a> {
cache: &'a HlsCache,
key: Option<crate::video_transcode_cache::HlsCacheKey>,
notify: Arc<tokio::sync::Notify>,
}
#[cfg(feature = "media-metadata")]
impl<'a> HlsGenerationSlot<'a> {
fn new(
cache: &'a HlsCache,
key: crate::video_transcode_cache::HlsCacheKey,
notify: Arc<tokio::sync::Notify>,
) -> Self {
Self {
cache,
key: Some(key),
notify,
}
}
fn settled(&mut self) {
self.key = None;
}
}
#[cfg(feature = "media-metadata")]
impl Drop for HlsGenerationSlot<'_> {
fn drop(&mut self) {
if let Some(key) = self.key.take() {
tracing::warn!("HLS generation abandoned for {key:?}; releasing the in-flight slot");
self.cache.fail_generation(
key,
&crate::video_transcode::TranscodeError::TranscodeFailed(
"generation did not finish (request cancelled or worker panicked)".to_string(),
),
);
self.notify.notify_waiters();
}
}
}
fn invalidate_derived_caches(
listing_caches: &ListingCaches,
link_cache: &LinkCache,
inbound_link_cache: &InboundLinkCache,
) {
listing_caches.invalidate();
link_cache.invalidate_all();
inbound_link_cache.invalidate_all();
}
#[derive(Clone)]
struct LinkIndexConfig {
base_dir: PathBuf,
index_file: String,
markdown_extensions: Vec<String>,
valid_tag_sources: HashSet<String>,
}
fn index_page_links(
repo: &Repo,
index: &InboundIndex,
cfg: &LinkIndexConfig,
path: &Path,
url_path: &str,
) {
let is_index_file = path
.file_name()
.and_then(|f| f.to_str())
.is_some_and(|f| f == cfg.index_file);
let link_transform_config = LinkTransformConfig {
markdown_extensions: cfg.markdown_extensions.clone(),
index_file: cfg.index_file.clone(),
is_index_file,
url_depth: None,
current_page_url: url_path.to_string(),
};
match markdown::extract_outbound_links_sync(
path.to_path_buf(),
&cfg.base_dir,
link_transform_config,
true, cfg.valid_tag_sources.clone(),
Some(repo.wikilink_index.clone()),
) {
Ok(links) => {
let resolved: Vec<crate::link_index::OutboundLink> = links
.into_iter()
.map(|mut link| {
if link.internal && !link.to.is_empty() {
link.to = crate::link_transform::transform_link(
&link.to,
&LinkTransformConfig {
markdown_extensions: cfg.markdown_extensions.clone(),
index_file: cfg.index_file.clone(),
is_index_file,
url_depth: None,
current_page_url: url_path.to_string(),
},
);
}
link
})
.collect();
let resolved = resolve_outbound_links(url_path, resolved, is_index_file);
index.set_page_links(url_path, &resolved);
}
Err(e) => {
tracing::warn!("Backlink index: failed to read {}: {e}", path.display());
index.remove_page(url_path);
}
}
}
fn populate_inbound_index(repo: &Repo, index: &InboundIndex, cfg: &LinkIndexConfig) {
use rayon::prelude::*;
let pages: Vec<(PathBuf, String)> = repo
.markdown_files
.pin()
.iter()
.map(|(path, info)| (path.clone(), info.url_path.clone()))
.collect();
let page_count = pages.len();
let started = Instant::now();
pages.par_iter().for_each(|(path, url_path)| {
index_page_links(repo, index, cfg, path, url_path);
});
index.mark_ready();
tracing::info!(
"Backlink index built in {:?}: {} pages parsed, {} pages have backlinks",
started.elapsed(),
page_count,
index.target_count(),
);
}
#[derive(Clone)]
struct ListingCaches {
sibling_nav_cache: Arc<papaya::HashMap<PathBuf, Arc<Vec<serde_json::Value>>>>,
subdir_cache: Arc<papaya::HashMap<PathBuf, Arc<Vec<serde_json::Value>>>>,
site_json_cache: Arc<parking_lot::RwLock<SiteJsonCache>>,
}
impl ListingCaches {
fn invalidate(&self) {
self.sibling_nav_cache.pin().clear();
self.subdir_cache.pin().clear();
self.site_json_cache.write().invalidate();
}
}
#[derive(Default)]
pub struct SiteJsonCache {
generation: u64,
body: Option<axum::body::Bytes>,
}
impl SiteJsonCache {
fn snapshot(&self) -> (u64, Option<axum::body::Bytes>) {
(self.generation, self.body.clone())
}
fn store(&mut self, generation: u64, body: axum::body::Bytes) {
if self.generation == generation {
self.body = Some(body);
}
}
fn invalidate(&mut self) {
self.generation = self.generation.wrapping_add(1);
self.body = None;
}
}
impl From<&ServerState> for ListingCaches {
fn from(state: &ServerState) -> Self {
Self {
sibling_nav_cache: Arc::clone(&state.sibling_nav_cache),
subdir_cache: Arc::clone(&state.subdir_cache),
site_json_cache: Arc::clone(&state.site_json_cache),
}
}
}
#[derive(Debug)]
enum LiveReloadAction {
Forward(crate::watcher::FileChangeEvent),
Skip,
Close,
}
fn live_reload_action(
result: Result<crate::watcher::FileChangeEvent, broadcast::error::RecvError>,
) -> LiveReloadAction {
match result {
Ok(event) => LiveReloadAction::Forward(event),
Err(broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!("Live reload client lagged; {skipped} file change event(s) dropped");
LiveReloadAction::Skip
}
Err(broadcast::error::RecvError::Closed) => {
tracing::debug!("File change channel closed; ending live reload stream");
LiveReloadAction::Close
}
}
}
const DEFAULT_LINK_CACHE_SIZE: usize = 2 * 1024 * 1024;
const DEFAULT_INBOUND_LINK_CACHE_SIZE: usize = 4 * 1024 * 1024;
const INBOUND_LINK_CACHE_TTL_SECS: u64 = 300;
const INBOUND_GREP_MAX_CONCURRENCY: usize = 2;
const INBOUND_GREP_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
const INCOMPRESSIBLE_CONTENT_TYPE_PREFIXES: &[&str] = &[
"video/",
"audio/",
"application/pdf",
"application/zip",
"application/gzip",
"application/x-gzip",
"application/octet-stream",
];
fn is_incompressible_content_type(headers: &HeaderMap) -> bool {
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
INCOMPRESSIBLE_CONTENT_TYPE_PREFIXES
.iter()
.any(|prefix| content_type.starts_with(prefix))
}
fn compress_by_content_type(
_status: StatusCode,
_version: axum::http::Version,
headers: &HeaderMap,
_extensions: &axum::http::Extensions,
) -> bool {
!is_incompressible_content_type(headers)
}
fn compression_predicate() -> impl tower_http::compression::Predicate {
use tower_http::compression::predicate::{DefaultPredicate, Predicate};
DefaultPredicate::new().and(compress_by_content_type)
}
#[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
}
}
#[cfg(feature = "media-metadata")]
fn resolve_media_source_file(
url_path: &str,
base_dir: &Path,
static_folder: &str,
) -> Option<PathBuf> {
let direct = base_dir.join(url_path);
validate_path_containment(&direct, base_dir).or_else(|| {
let static_dir = base_dir.join(static_folder);
validate_path_containment(&static_dir.join(url_path), &static_dir)
})
}
const MBR_TEMPLATE_DIR: &str = ".mbr";
const MBR_ASSET_EXTENSIONS: &[&str] = &[
"css",
"js",
"mjs",
"map",
"json",
"html",
"txt", "png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"ico",
"woff",
"woff2",
"ttf",
"otf",
"eot",
"wasm",
"pf_meta",
"pf_fragment",
"pf_index",
];
fn is_servable_mbr_asset(asset_path: &str) -> bool {
let path = Path::new(asset_path);
let has_hidden_component = path.components().any(|component| match component {
std::path::Component::Normal(name) => {
name.to_str().is_some_and(|name| name.starts_with('.'))
}
std::path::Component::CurDir | std::path::Component::ParentDir => true,
_ => false,
});
if has_hidden_component {
return false;
}
path.extension()
.and_then(|ext| ext.to_str())
.map(str::to_ascii_lowercase)
.is_some_and(|ext| MBR_ASSET_EXTENSIONS.contains(&ext.as_str()))
}
const UPLOAD_ALLOWED_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff", "aiff", "aif", "mp3", "aac", "m4a", "ogg", "oga", "opus", "wma", "flac", "wav",
"mp4", "m4v", "mov", "webm", "flv", "mpg", "mpeg", "avi", "3gp", "wmv",
"pdf", "vtt", "srt",
];
fn canonical_existing_ancestor(path: &Path) -> Option<PathBuf> {
path.ancestors()
.find_map(|ancestor| ancestor.canonicalize().ok())
}
fn is_template_folder_path(
target: &Path,
base_dir: &Path,
canonical_base_dir: Option<&Path>,
template_folder: Option<&Path>,
) -> bool {
let canonical_target = canonical_existing_ancestor(target);
std::iter::once(base_dir.join(MBR_TEMPLATE_DIR))
.chain(canonical_base_dir.map(|base| base.join(MBR_TEMPLATE_DIR)))
.chain(template_folder.map(Path::to_path_buf))
.any(|root| {
target.starts_with(&root)
|| match (root.canonicalize(), canonical_target.as_deref()) {
(Ok(root), Some(canonical_target)) => canonical_target.starts_with(root),
_ => false,
}
})
}
fn host_header_hostname(host: &str) -> &str {
let host = host.trim();
if let Some(rest) = host.strip_prefix('[') {
rest.split(']').next().unwrap_or(rest)
} else if host.matches(':').count() > 1 {
host
} else {
host.split(':').next().unwrap_or(host)
}
}
fn host_header_is_allowed(headers: &HeaderMap, bind_ip: [u8; 4]) -> bool {
let Some(raw) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else {
return false;
};
let hostname = host_header_hostname(raw);
if hostname.eq_ignore_ascii_case("localhost") {
return true;
}
match hostname.parse::<std::net::IpAddr>() {
Ok(ip) => ip.is_loopback() || ip == std::net::IpAddr::from(bind_ip),
Err(_) => false,
}
}
fn is_within_served_roots(
path: &Path,
base_dir: &Path,
canonical_base_dir: Option<&Path>,
static_folder: &str,
) -> bool {
let Ok(canonical) = path.canonicalize() else {
return false;
};
let owned_base;
let base = match canonical_base_dir {
Some(cached) => Some(cached),
None => {
owned_base = base_dir.canonicalize().ok();
owned_base.as_deref()
}
};
if base.is_some_and(|base| canonical.starts_with(base)) {
return true;
}
base_dir
.join(static_folder)
.canonicalize()
.is_ok_and(|static_root| canonical.starts_with(static_root))
}
pub struct Server {
pub router: Router,
pub port: u16,
pub ip: [u8; 4],
pub gui_mode: bool,
_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,
#[cfg(feature = "media-metadata")]
pub media_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 relationship_tracking: bool,
pub relationship_types: Vec<RelationType>,
pub tag_sources: Vec<TagSource>,
pub sidebar_style: String,
pub sidebar_max_items: usize,
pub graph_depth: usize,
pub title_prefix: String,
pub title_suffix: String,
pub mark_incomplete: bool,
pub incomplete_markers: Vec<String>,
pub edit_enabled: bool,
pub edit_require_token_on_loopback: bool,
pub edit_token_hash: Option<String>,
pub upload_max_bytes: usize,
#[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,
#[cfg(feature = "media-metadata")]
media_cache_size: config.media_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,
relationship_tracking: config.relationship_tracking,
relationship_types: config.relationship_types.clone(),
tag_sources: config.tag_sources.clone(),
sidebar_style: config.sidebar_style.clone(),
sidebar_max_items: config.sidebar_max_items,
graph_depth: config.graph_depth,
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(),
edit_enabled: config.edit_enabled,
edit_require_token_on_loopback: config.edit_require_token_on_loopback,
edit_token_hash: config.edit_token_hash.clone(),
upload_max_bytes: config.upload_max_bytes,
#[cfg(feature = "media-metadata")]
transcode_enabled: config.transcode,
}
}
}
#[derive(Clone)]
pub struct ServerState {
pub bind_ip: [u8; 4],
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>,
#[cfg(feature = "media-metadata")]
pub metadata_inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>>,
#[cfg(feature = "media-metadata")]
pub video_resolution_cache:
Arc<papaya::HashMap<String, crate::video_transcode::VideoResolution>>,
#[cfg(feature = "media-metadata")]
pub media_compat_cache:
Arc<papaya::HashMap<String, crate::video_metadata::PlaybackCompatibility>>,
pub sibling_nav_cache: Arc<papaya::HashMap<PathBuf, Arc<Vec<serde_json::Value>>>>,
pub subdir_cache: Arc<papaya::HashMap<PathBuf, Arc<Vec<serde_json::Value>>>>,
pub site_json_cache: Arc<parking_lot::RwLock<SiteJsonCache>>,
pub link_tracking: bool,
pub relationship_tracking: bool,
pub relationship_types: Vec<RelationType>,
pub link_cache: Arc<LinkCache>,
pub inbound_link_cache: Arc<InboundLinkCache>,
pub inbound_index: Arc<InboundIndex>,
pub inbound_grep_semaphore: Arc<tokio::sync::Semaphore>,
pub inbound_grep_inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>>,
pub tag_sources: Vec<TagSource>,
pub sidebar_style: String,
pub sidebar_max_items: usize,
pub graph_depth: usize,
pub title_prefix: String,
pub title_suffix: String,
pub mark_incomplete: bool,
pub incomplete_markers: Vec<String>,
pub edit_enabled: bool,
pub edit_require_token_on_loopback: bool,
pub edit_token_hash: Option<String>,
pub upload_max_bytes: usize,
}
#[derive(serde::Deserialize)]
pub struct EditRequest {
pub content: String,
pub base_hash: String,
}
#[derive(serde::Deserialize)]
pub struct CreateRequest {
pub content: String,
#[serde(default)]
pub create_dirs: bool,
}
#[derive(serde::Deserialize)]
pub struct MoveRequest {
pub to: String,
#[serde(default)]
pub create_dirs: bool,
}
#[derive(serde::Serialize)]
pub struct CreateResponse {
pub url_path: String,
pub path: String,
}
#[derive(serde::Serialize)]
pub struct MkdirResponse {
pub path: String,
}
#[derive(serde::Serialize)]
pub struct MoveResponse {
pub from_url: String,
pub url_path: String,
pub path: String,
pub rewritten: Vec<String>,
pub wikilinks_rewritten: Vec<String>,
pub created_dirs: bool,
}
#[derive(serde::Deserialize)]
pub struct UploadParams {
#[serde(default)]
pub dir: String,
pub name: String,
}
#[derive(serde::Serialize)]
pub struct UploadResponse {
pub url: String,
pub path: String,
pub name: String,
}
#[derive(Debug)]
enum FileOpError {
AlreadyExists,
ParentMissing,
NotMarkdown,
InvalidUploadName,
ForbiddenUploadDir,
SourceNotFound,
Traversal,
Io(std::io::Error),
}
impl IntoResponse for FileOpError {
fn into_response(self) -> Response {
let (status, msg): (StatusCode, &'static str) = match self {
FileOpError::AlreadyExists => (StatusCode::CONFLICT, "Target already exists"),
FileOpError::ParentMissing => (
StatusCode::BAD_REQUEST,
"Destination parent directory does not exist (set create_dirs to create it)",
),
FileOpError::NotMarkdown => (
StatusCode::BAD_REQUEST,
"Path must end in a markdown extension",
),
FileOpError::InvalidUploadName => (
StatusCode::BAD_REQUEST,
"Invalid upload filename (must be a basename with an allowed media extension)",
),
FileOpError::ForbiddenUploadDir => (
StatusCode::BAD_REQUEST,
"Uploads into the template folder are not allowed",
),
FileOpError::SourceNotFound => {
(StatusCode::NOT_FOUND, "Source markdown file not found")
}
FileOpError::Traversal => (StatusCode::BAD_REQUEST, "Invalid path"),
FileOpError::Io(e) => {
tracing::error!("file operation I/O error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "I/O error")
}
};
(status, msg).into_response()
}
}
fn resolve_new_target_path(canonical_base: &Path, rel: &str) -> Result<PathBuf, FileOpError> {
let clean = rel.trim_start_matches('/');
if clean.is_empty() {
return Err(FileOpError::Traversal);
}
for component in Path::new(clean).components() {
match component {
std::path::Component::Normal(_) | std::path::Component::CurDir => {}
_ => return Err(FileOpError::Traversal),
}
}
let candidate = canonical_base.join(clean);
let mut ancestor = candidate.as_path();
let existing = loop {
if ancestor.exists() {
break ancestor;
}
match ancestor.parent() {
Some(p) => ancestor = p,
None => break ancestor,
}
};
let canonical_existing = existing.canonicalize().map_err(FileOpError::Io)?;
if !canonical_existing.starts_with(canonical_base) {
return Err(FileOpError::Traversal);
}
Ok(candidate)
}
const UPLOAD_URL_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'/')
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~');
fn sanitize_upload_name(name: &str, markdown_extensions: &[String]) -> Option<String> {
let name = name.trim();
if name.is_empty() {
return None;
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return None;
}
let path = Path::new(name);
if path.file_name().and_then(|n| n.to_str()) != Some(name) {
return None;
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let ext = match path.extension().and_then(|e| e.to_str()) {
Some(e) if !e.is_empty() => e,
_ => return None,
};
if stem.is_empty() {
return None;
}
let ext_lower = ext.to_ascii_lowercase();
if crate::repo::is_markdown_extension(&ext_lower, markdown_extensions) {
return None;
}
if !UPLOAD_ALLOWED_EXTENSIONS.contains(&ext_lower.as_str()) {
return None;
}
Some(name.to_string())
}
fn dedupe_name(dir: &Path, stem: &str, ext: &str, exists: impl Fn(&Path) -> bool) -> PathBuf {
std::iter::once(dir.join(format!("{stem}.{ext}")))
.chain((1u64..).map(|n| dir.join(format!("{stem}-{n}.{ext}"))))
.find(|candidate| !exists(candidate))
.expect("candidate sequence is infinite, so a free path always exists")
}
#[derive(serde::Serialize)]
struct SiteJson<'a> {
index_file: &'a str,
markdown_files: &'a crate::repo::MarkdownFiles,
sort: &'a [SortField],
sidebar_style: &'a str,
sidebar_max_items: usize,
}
struct SiteJsonParams {
sort: Vec<SortField>,
sidebar_style: String,
sidebar_max_items: usize,
relationship_tracking: bool,
}
fn render_site_json(
repo: &Repo,
params: &SiteJsonParams,
) -> Result<axum::body::Bytes, serde_json::Error> {
let mut value = serde_json::to_value(SiteJson {
index_file: &repo.index_file,
markdown_files: &repo.markdown_files,
sort: ¶ms.sort,
sidebar_style: ¶ms.sidebar_style,
sidebar_max_items: params.sidebar_max_items,
})?;
if params.relationship_tracking {
repo.relationship_index.inject_into_site_json(&mut value);
}
Ok(axum::body::Bytes::from(serde_json::to_vec(&value)?))
}
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,
#[cfg(feature = "media-metadata")]
media_cache_size,
template_folder,
sort,
gui_mode,
theme,
log_filter,
link_tracking,
relationship_tracking,
relationship_types,
tag_sources,
sidebar_style,
sidebar_max_items,
graph_depth,
title_prefix,
title_suffix,
mark_incomplete,
incomplete_markers,
edit_enabled,
edit_require_token_on_loopback,
edit_token_hash,
upload_max_bytes,
#[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(media_cache_size));
#[cfg(feature = "media-metadata")]
let hls_cache = Arc::new(HlsCache::new(DEFAULT_HLS_CACHE_SIZE));
#[cfg(feature = "media-metadata")]
let metadata_inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>> =
Arc::new(papaya::HashMap::new());
#[cfg(feature = "media-metadata")]
let video_resolution_cache: Arc<
papaya::HashMap<String, crate::video_transcode::VideoResolution>,
> = Arc::new(papaya::HashMap::new());
#[cfg(feature = "media-metadata")]
let media_compat_cache: Arc<
papaya::HashMap<String, crate::video_metadata::PlaybackCompatibility>,
> = Arc::new(papaya::HashMap::new());
let listing_caches = ListingCaches {
sibling_nav_cache: Arc::new(papaya::HashMap::new()),
subdir_cache: Arc::new(papaya::HashMap::new()),
site_json_cache: Arc::new(parking_lot::RwLock::new(SiteJsonCache::default())),
};
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,
&relationship_types,
));
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.build_relationship_index();
repo_for_scan.build_wikilink_index();
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()
.map(|tf| tf.canonicalize().unwrap_or(tf));
let mut template_change_rx = file_change_tx.subscribe();
tokio::spawn(async move {
loop {
let event = match live_reload_action(template_change_rx.recv().await) {
LiveReloadAction::Forward(event) => event,
LiveReloadAction::Skip => continue,
LiveReloadAction::Close => break,
};
if !event.path.ends_with(".html") {
continue;
}
if should_reload_template(&event.path, template_folder_for_reload.as_deref()) {
tracing::debug!("Template file changed: {}", event.path);
if let Err(e) = templates_for_reload.reload() {
tracing::error!("Failed to reload templates: {}", e);
}
}
}
});
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 inbound_index = Arc::new(InboundIndex::new());
let index_lock = Arc::new(tokio::sync::Mutex::new(()));
let link_index_config = LinkIndexConfig {
base_dir: base_dir.clone(),
index_file: index_file.clone(),
markdown_extensions: markdown_extensions.clone(),
valid_tag_sources: crate::config::tag_sources_to_set(&tag_sources),
};
if link_tracking {
let repo_for_index = Arc::clone(&repo);
let index_for_build = Arc::clone(&inbound_index);
let cfg_for_build = link_index_config.clone();
let index_lock_for_build = Arc::clone(&index_lock);
tokio::spawn(async move {
repo_for_index.wait_for_scan().await;
let _guard = index_lock_for_build.lock().await;
let repo = Arc::clone(&repo_for_index);
tokio::task::spawn_blocking(move || {
populate_inbound_index(&repo, &index_for_build, &cfg_for_build);
})
.await
.unwrap_or_else(|e| tracing::error!("Backlink index build task failed: {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 listing_caches_for_invalidation = listing_caches.clone();
let link_cache_for_invalidation = Arc::clone(&link_cache);
let inbound_link_cache_for_invalidation = Arc::clone(&inbound_link_cache);
let inbound_index_for_invalidation = Arc::clone(&inbound_index);
let link_index_config_for_invalidation = link_index_config.clone();
let index_lock_for_invalidation = Arc::clone(&index_lock);
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();
let inbound_index = Arc::clone(&inbound_index_for_invalidation);
let link_index_cfg = link_index_config_for_invalidation.clone();
let index_lock = Arc::clone(&index_lock_for_invalidation);
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
)
});
let _index_guard = index_lock.lock().await;
tokio::task::spawn_blocking(move || {
let mut index_targets: Vec<(PathBuf, Option<String>, bool)> = Vec::new();
for event in &relevant_events {
let abs_path = if event.path.is_empty() {
continue;
} else {
PathBuf::from(&event.path)
};
index_targets.push((
abs_path.clone(),
repo.markdown_files
.pin()
.get(&abs_path)
.map(|info| info.url_path.clone()),
matches!(event.event, crate::watcher::ChangeEventType::Deleted),
));
repo.invalidate_file(&abs_path, &event.event);
}
if has_tag_changes {
repo.rebuild_tag_index();
}
repo.build_relationship_index();
repo.build_wikilink_index();
if inbound_index.is_ready() {
for (abs_path, known_url, is_delete) in &index_targets {
if !link_index_cfg.markdown_extensions.iter().any(|ext| {
abs_path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case(ext))
}) {
continue;
}
let url_path = match known_url {
Some(url) => url.clone(),
None => crate::repo::build_markdown_url_path(
abs_path,
&link_index_cfg.base_dir,
&link_index_cfg.index_file,
),
};
if *is_delete {
inbound_index.remove_page(&url_path);
} else {
index_page_links(
&repo,
&inbound_index,
&link_index_cfg,
abs_path,
&url_path,
);
}
}
}
})
.await
.ok();
} else {
tracing::info!(
"Full rescan triggered: {} file changes exceed threshold",
relevant_events.len()
);
tokio::task::spawn_blocking(move || {
repo.full_rescan();
if inbound_index.is_ready() {
populate_inbound_index(&repo, &inbound_index, &link_index_cfg);
}
let _ = base_dir; })
.await
.ok();
}
invalidate_derived_caches(
&listing_caches_for_invalidation,
&link_cache_for_invalidation,
&inbound_link_cache_for_invalidation,
);
}
});
let inbound_grep_semaphore =
Arc::new(tokio::sync::Semaphore::new(INBOUND_GREP_MAX_CONCURRENCY));
let inbound_grep_inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>> =
Arc::new(papaya::HashMap::new());
let canonical_base_dir = base_dir.canonicalize().ok();
let state = ServerState {
bind_ip: ip,
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,
#[cfg(feature = "media-metadata")]
metadata_inflight,
#[cfg(feature = "media-metadata")]
video_resolution_cache,
#[cfg(feature = "media-metadata")]
media_compat_cache,
sibling_nav_cache: listing_caches.sibling_nav_cache,
subdir_cache: listing_caches.subdir_cache,
site_json_cache: listing_caches.site_json_cache,
link_tracking,
relationship_tracking,
relationship_types,
link_cache,
inbound_link_cache,
inbound_index,
inbound_grep_semaphore,
inbound_grep_inflight,
tag_sources,
sidebar_style,
sidebar_max_items,
graph_depth,
title_prefix,
title_suffix,
mark_incomplete,
incomplete_markers,
edit_enabled,
edit_require_token_on_loopback,
edit_token_hash,
upload_max_bytes,
};
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/raw/{*path}", get(Self::raw_markdown_handler))
.route(
"/.mbr/edit/{*path}",
post(Self::save_markdown_handler)
.layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
)
.route(
"/.mbr/create/{*path}",
post(Self::create_markdown_handler)
.layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
)
.route("/.mbr/move/{*path}", post(Self::move_markdown_handler))
.route("/.mbr/mkdir/{*path}", post(Self::mkdir_handler))
.route(
"/.mbr/upload",
post(Self::upload_handler).layer(DefaultBodyLimit::max(upload_max_bytes)),
)
.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().compress_when(compression_predicate()))
.layer(TraceLayer::new_for_http())
.with_state(state);
Ok(Server {
router,
ip,
port,
gui_mode,
_watcher_handle: watcher_handle,
})
}
fn announce_listening(&self, local_addr: SocketAddr) {
tracing::debug!("listening on {}", local_addr);
if self.gui_mode {
tracing::info!("Server running at http://{}/", local_addr);
} else {
println!("Server running at http://{}/", local_addr);
}
}
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)?;
self.announce_listening(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()
.into_make_service_with_connect_info::<SocketAddr>(),
)
.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)?;
self.announce_listening(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()
.into_make_service_with_connect_info::<SocketAddr>(),
)
.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>,
headers: HeaderMap,
) -> Response {
if !headers.contains_key(header::ORIGIN) || !Self::is_same_origin(&headers) {
tracing::warn!("Blocked cross-origin live-reload WebSocket upgrade");
return (
StatusCode::FORBIDDEN,
"Cross-origin WebSocket upgrade blocked",
)
.into_response();
}
ws.on_upgrade(|socket| Self::handle_websocket(socket, config))
.into_response()
}
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! {
result = rx.recv() => {
let change_event = match live_reload_action(result) {
LiveReloadAction::Forward(event) => event,
LiveReloadAction::Skip => continue,
LiveReloadAction::Close => break,
};
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 (generation, cached) = config.site_json_cache.read().snapshot();
let body = match cached {
Some(body) => body,
None => {
let json_start = std::time::Instant::now();
let repo = Arc::clone(&config.repo);
let params = SiteJsonParams {
sort: config.sort.clone(),
sidebar_style: config.sidebar_style.clone(),
sidebar_max_items: config.sidebar_max_items,
relationship_tracking: config.relationship_tracking,
};
let built = tokio::task::spawn_blocking(move || render_site_json(&repo, ¶ms))
.await
.map_err(|e| {
tracing::error!("site.json render task failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?
.inspect_err(|e| tracing::error!("Error serializing site json: {e}"))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
config
.site_json_cache
.write()
.store(generation, built.clone());
tracing::debug!(
"get_site_info JSON serialization: {:?}",
json_start.elapsed()
);
built
}
};
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(body))
.inspect_err(|e| tracing::error!("Error rendering site file: {e}"))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
.map(IntoResponse::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
);
match serde_json::to_value(&response) {
Ok(value) => (StatusCode::OK, Json(value)),
Err(e) => {
tracing::error!("Failed to serialize search response: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to serialize search response",
"query": query_str,
"total_matches": 0,
"results": [],
"duration_ms": 0
})),
)
}
}
}
fn check_edit_access(
config: &ServerState,
headers: &HeaderMap,
peer_ip: std::net::IpAddr,
) -> Result<(), (StatusCode, &'static str)> {
if !config.edit_enabled {
return Err((StatusCode::FORBIDDEN, "Editing is not enabled"));
}
let csrf_ok = headers
.get("x-mbr-edit")
.and_then(|v| v.to_str().ok())
.map(|v| v == "1")
.unwrap_or(false);
if !csrf_ok {
return Err((
StatusCode::FORBIDDEN,
"Missing or invalid X-MBR-Edit header",
));
}
if !Self::is_same_origin(headers) {
return Err((StatusCode::FORBIDDEN, "Cross-origin edit request blocked"));
}
let token_configured = config.edit_token_hash.is_some();
if !token_configured && !host_header_is_allowed(headers, config.bind_ip) {
return Err((
StatusCode::FORBIDDEN,
"Host header does not name this server",
));
}
let caller_is_loopback = peer_ip.is_loopback();
let require_token =
token_configured || !caller_is_loopback || config.edit_require_token_on_loopback;
if require_token {
let provided = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::trim);
let ok = match (&config.edit_token_hash, provided) {
(Some(hash), Some(token)) => crate::edit_auth::verify_token(hash, token),
_ => false,
};
if !ok {
return Err((StatusCode::UNAUTHORIZED, "A valid edit token is required"));
}
}
Ok(())
}
fn is_same_origin(headers: &HeaderMap) -> bool {
if let Some(sfs) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) {
return sfs == "same-origin" || sfs == "none";
}
match (
headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()),
headers.get(header::HOST).and_then(|v| v.to_str().ok()),
) {
(Some(origin), Some(host)) => origin
.split_once("://")
.map(|(_, origin_host)| origin_host == host)
.unwrap_or(false),
(None, _) => true,
_ => false,
}
}
fn resolve_editable_markdown(
config: &ServerState,
path: &str,
) -> Result<PathBuf, (StatusCode, &'static str)> {
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::MarkdownFile(md_path) => {
let canonical = md_path.canonicalize().ok();
let base = config
.canonical_base_dir
.clone()
.or_else(|| config.base_dir.canonicalize().ok());
match (canonical, base) {
(Some(c), Some(b)) if c.starts_with(&b) && c.is_file() => Ok(c),
_ => Err((StatusCode::BAD_REQUEST, "Invalid path")),
}
}
_ => Err((StatusCode::NOT_FOUND, "Not an editable markdown file")),
}
}
pub async fn raw_markdown_handler(
extract::Path(path): extract::Path<String>,
State(config): State<ServerState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Response {
if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
return err.into_response();
}
let md_path = match Self::resolve_editable_markdown(&config, &path) {
Ok(p) => p,
Err(err) => return err.into_response(),
};
match tokio::fs::read(&md_path).await {
Ok(bytes) => {
let hash = crate::edit_auth::content_hash(&bytes);
let mut resp = (StatusCode::OK, bytes).into_response();
resp.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/markdown; charset=utf-8"),
);
if let Ok(hv) = HeaderValue::from_str(&hash) {
resp.headers_mut().insert("x-mbr-content-hash", hv);
}
resp
}
Err(e) => {
tracing::error!("Failed to read markdown for editing: {e}");
(StatusCode::NOT_FOUND, "File not found").into_response()
}
}
}
pub async fn save_markdown_handler(
extract::Path(path): extract::Path<String>,
State(config): State<ServerState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<EditRequest>,
) -> Response {
if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
return err.into_response();
}
let md_path = match Self::resolve_editable_markdown(&config, &path) {
Ok(p) => p,
Err(err) => return err.into_response(),
};
let current = match tokio::fs::read(&md_path).await {
Ok(bytes) => bytes,
Err(e) => {
tracing::error!("Failed to read markdown before save: {e}");
return (StatusCode::NOT_FOUND, "File not found").into_response();
}
};
if crate::edit_auth::content_hash(¤t) != req.base_hash {
return (
StatusCode::CONFLICT,
"File changed on disk since it was loaded",
)
.into_response();
}
let new_bytes = req.content.into_bytes();
let new_hash = crate::edit_auth::content_hash(&new_bytes);
let parent = md_path.parent().unwrap_or_else(|| Path::new("."));
let file_name = md_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file.md");
let tmp_path = parent.join(format!(".{file_name}.mbr-tmp"));
if let Err(e) = tokio::fs::write(&tmp_path, &new_bytes).await {
tracing::error!("Failed to write temp file: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "Write failed").into_response();
}
if let Err(e) = tokio::fs::rename(&tmp_path, &md_path).await {
tracing::error!("Failed to rename temp file into place: {e}");
let _ = tokio::fs::remove_file(&tmp_path).await;
return (StatusCode::INTERNAL_SERVER_ERROR, "Write failed").into_response();
}
if let Some(tx) = &config.file_change_tx {
let relative =
pathdiff::diff_paths(&md_path, &config.base_dir).unwrap_or_else(|| md_path.clone());
let _ = tx.send(crate::watcher::FileChangeEvent {
path: md_path.to_string_lossy().to_string(),
relative_path: relative.to_string_lossy().to_string(),
event: crate::watcher::ChangeEventType::Modified,
});
}
let mut resp = (StatusCode::OK, "Saved").into_response();
if let Ok(hv) = HeaderValue::from_str(&new_hash) {
resp.headers_mut().insert("x-mbr-content-hash", hv);
}
resp
}
fn resolve_new_target(config: &ServerState, rel: &str) -> Result<PathBuf, FileOpError> {
let canonical_base = config
.canonical_base_dir
.clone()
.or_else(|| config.base_dir.canonicalize().ok())
.ok_or_else(|| {
FileOpError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"repository root not found",
))
})?;
resolve_new_target_path(&canonical_base, rel)
}
fn path_has_markdown_extension(path: &Path, exts: &[String]) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| crate::repo::is_markdown_extension(&e.to_lowercase(), exts))
.unwrap_or(false)
}
fn path_is_index(path: &Path, index_file: &str) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == index_file)
}
fn rel_path_string(path: &Path, base_dir: &Path) -> String {
let relative = pathdiff::diff_paths(path, base_dir).unwrap_or_else(|| path.to_path_buf());
crate::url_path::path_to_url(&relative)
}
fn atomic_write_file(path: &Path, bytes: &[u8]) -> Result<(), FileOpError> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file.md");
let tmp = parent.join(format!(".{file_name}.mbr-tmp"));
std::fs::write(&tmp, bytes).map_err(FileOpError::Io)?;
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(FileOpError::Io(e));
}
Ok(())
}
fn broadcast_change(
config: &ServerState,
abs_path: &Path,
event: crate::watcher::ChangeEventType,
) {
if let Some(tx) = &config.file_change_tx {
let relative = pathdiff::diff_paths(abs_path, &config.base_dir)
.unwrap_or_else(|| abs_path.to_path_buf());
let _ = tx.send(crate::watcher::FileChangeEvent {
path: abs_path.to_string_lossy().to_string(),
relative_path: relative.to_string_lossy().to_string(),
event,
});
}
}
pub async fn create_markdown_handler(
extract::Path(path): extract::Path<String>,
State(config): State<ServerState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<CreateRequest>,
) -> Response {
if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
return err.into_response();
}
let result =
tokio::task::spawn_blocking(move || Self::do_create(&config, &path, req)).await;
match result {
Ok(Ok(resp)) => (StatusCode::OK, Json(resp)).into_response(),
Ok(Err(e)) => e.into_response(),
Err(e) => {
tracing::error!("create task panicked: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "Create failed").into_response()
}
}
}
fn do_create(
config: &ServerState,
rel: &str,
req: CreateRequest,
) -> Result<CreateResponse, FileOpError> {
let dst = Self::resolve_new_target(config, rel)?;
if !Self::path_has_markdown_extension(&dst, &config.markdown_extensions) {
return Err(FileOpError::NotMarkdown);
}
if dst.exists() {
return Err(FileOpError::AlreadyExists);
}
let parent = dst.parent().unwrap_or_else(|| Path::new("."));
if !parent.exists() {
if req.create_dirs {
std::fs::create_dir_all(parent).map_err(FileOpError::Io)?;
} else {
return Err(FileOpError::ParentMissing);
}
}
Self::atomic_write_file(&dst, req.content.as_bytes())?;
let url_path =
crate::repo::build_markdown_url_path(&dst, &config.base_dir, &config.index_file);
let rel_path = Self::rel_path_string(&dst, &config.base_dir);
config
.repo
.invalidate_file(&dst, &crate::watcher::ChangeEventType::Created);
config.repo.build_relationship_index();
config.repo.build_wikilink_index();
ListingCaches::from(config).invalidate();
config.inbound_link_cache.invalidate_all();
Self::broadcast_change(config, &dst, crate::watcher::ChangeEventType::Created);
Ok(CreateResponse {
url_path,
path: rel_path,
})
}
pub async fn mkdir_handler(
extract::Path(path): extract::Path<String>,
State(config): State<ServerState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Response {
if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
return err.into_response();
}
let result = tokio::task::spawn_blocking(move || Self::do_mkdir(&config, &path)).await;
match result {
Ok(Ok(resp)) => (StatusCode::OK, Json(resp)).into_response(),
Ok(Err(e)) => e.into_response(),
Err(e) => {
tracing::error!("mkdir task panicked: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "Mkdir failed").into_response()
}
}
}
fn do_mkdir(config: &ServerState, rel: &str) -> Result<MkdirResponse, FileOpError> {
let target = Self::resolve_new_target(config, rel)?;
let rel_path = Self::rel_path_string(&target, &config.base_dir);
if target.is_dir() {
return Ok(MkdirResponse { path: rel_path });
}
if target.exists() {
return Err(FileOpError::AlreadyExists);
}
std::fs::create_dir_all(&target).map_err(FileOpError::Io)?;
Self::broadcast_change(config, &target, crate::watcher::ChangeEventType::Created);
Ok(MkdirResponse { path: rel_path })
}
pub async fn upload_handler(
State(config): State<ServerState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
extract::Query(params): extract::Query<UploadParams>,
body: axum::body::Bytes,
) -> Response {
if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
return err.into_response();
}
let result = tokio::task::spawn_blocking(move || {
Self::do_upload(&config, ¶ms.dir, ¶ms.name, &body)
})
.await;
match result {
Ok(Ok(resp)) => (StatusCode::OK, Json(resp)).into_response(),
Ok(Err(e)) => e.into_response(),
Err(e) => {
tracing::error!("upload task panicked: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "Upload failed").into_response()
}
}
}
fn do_upload(
config: &ServerState,
dir: &str,
name: &str,
bytes: &[u8],
) -> Result<UploadResponse, FileOpError> {
let safe_name = sanitize_upload_name(name, &config.markdown_extensions)
.ok_or(FileOpError::InvalidUploadName)?;
let dir_clean = dir.trim_matches('/');
let rel = if dir_clean.is_empty() {
safe_name.clone()
} else {
format!("{dir_clean}/{safe_name}")
};
let target = Self::resolve_new_target(config, &rel)?;
if is_template_folder_path(
&target,
config.base_dir.as_path(),
config.canonical_base_dir.as_deref(),
config.template_folder.as_deref(),
) {
return Err(FileOpError::ForbiddenUploadDir);
}
let dest_dir = target
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
if !dest_dir.exists() {
std::fs::create_dir_all(&dest_dir).map_err(FileOpError::Io)?;
}
let name_path = Path::new(&safe_name);
let stem = name_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&safe_name);
let ext = name_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let final_path = dedupe_name(&dest_dir, stem, ext, |p| p.exists());
Self::atomic_write_file(&final_path, bytes)?;
let root_abs = crate::repo::build_static_url_path(
&final_path,
&config.base_dir,
&config.static_folder,
);
let url = utf8_percent_encode(&root_abs, UPLOAD_URL_ENCODE_SET).to_string();
let rel_path = Self::rel_path_string(&final_path, &config.base_dir);
let final_name = final_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&safe_name)
.to_string();
Self::broadcast_change(
config,
&final_path,
crate::watcher::ChangeEventType::Created,
);
Ok(UploadResponse {
url,
path: rel_path,
name: final_name,
})
}
pub async fn move_markdown_handler(
extract::Path(path): extract::Path<String>,
State(config): State<ServerState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<MoveRequest>,
) -> Response {
if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
return err.into_response();
}
let _permit = match config.inbound_grep_semaphore.clone().acquire_owned().await {
Ok(p) => p,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Server shutting down").into_response();
}
};
let result = tokio::task::spawn_blocking(move || Self::do_move(&config, &path, req)).await;
match result {
Ok(Ok(resp)) => (StatusCode::OK, Json(resp)).into_response(),
Ok(Err(e)) => e.into_response(),
Err(e) => {
tracing::error!("move task panicked: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "Move failed").into_response()
}
}
}
fn do_move(
config: &ServerState,
from: &str,
req: MoveRequest,
) -> Result<MoveResponse, FileOpError> {
let src = Self::resolve_editable_markdown(config, from).map_err(|(status, _)| {
if status == StatusCode::NOT_FOUND {
FileOpError::SourceNotFound
} else {
FileOpError::Traversal
}
})?;
let dst = Self::resolve_new_target(config, &req.to)?;
if !Self::path_has_markdown_extension(&dst, &config.markdown_extensions) {
return Err(FileOpError::NotMarkdown);
}
let dst_canon = dst.canonicalize().ok();
let case_only = dst.exists() && dst_canon.as_deref() == Some(src.as_path());
if dst.exists() && !case_only {
return Err(FileOpError::AlreadyExists);
}
let parent = dst.parent().unwrap_or_else(|| Path::new("."));
let mut created_dirs = false;
if !parent.exists() {
if req.create_dirs {
std::fs::create_dir_all(parent).map_err(FileOpError::Io)?;
created_dirs = true;
} else {
return Err(FileOpError::ParentMissing);
}
}
let old_url =
crate::repo::build_markdown_url_path(&src, &config.base_dir, &config.index_file);
let new_url =
crate::repo::build_markdown_url_path(&dst, &config.base_dir, &config.index_file);
let old_is_index = Self::path_is_index(&src, &config.index_file);
let new_is_index = Self::path_is_index(&dst, &config.index_file);
let src_meta = crate::markdown::extract_metadata_from_file(&src).ok();
let old_stem = src
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let new_stem = dst
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let delta = Self::wikilink_delta_names(
src_meta.as_ref().map(|m| &m.metadata),
&old_stem,
&new_stem,
);
let src_content = std::fs::read_to_string(&src).map_err(FileOpError::Io)?;
let moved_content = crate::link_rewrite::rewrite_moved_file_outbound_links(
&old_url,
old_is_index,
&new_url,
new_is_index,
&config.markdown_extensions,
&src_content,
);
let parent_dir = dst.parent().unwrap_or_else(|| Path::new("."));
let dst_name = dst
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file.md");
let tmp = parent_dir.join(format!(".{dst_name}.mbr-tmp"));
std::fs::write(&tmp, moved_content.as_bytes()).map_err(FileOpError::Io)?;
let rename_result = if case_only {
std::fs::remove_file(&src).and_then(|()| std::fs::rename(&tmp, &dst))
} else {
std::fs::rename(&tmp, &dst).and_then(|()| std::fs::remove_file(&src))
};
if let Err(e) = rename_result {
let _ = std::fs::remove_file(&tmp);
return Err(FileOpError::Io(e));
}
let mut skip: HashSet<PathBuf> = HashSet::new();
skip.insert(dst.clone());
if let Ok(c) = dst.canonicalize() {
skip.insert(c);
}
let rewritten_paths = crate::link_rewrite::rewrite_inbound_links_for_move(
&old_url,
&new_url,
&config.base_dir,
&config.markdown_extensions,
&config.ignore_dirs,
&config.ignore_globs,
&skip,
)
.map_err(FileOpError::Io)?;
let wiki_paths = crate::link_rewrite::rewrite_bare_wikilinks_for_rename(
&delta,
&old_url,
&config.base_dir,
&config.markdown_extensions,
&config.ignore_dirs,
&config.ignore_globs,
&config.index_file,
&config.repo.wikilink_index,
&skip,
)
.map_err(FileOpError::Io)?;
config
.repo
.invalidate_file(&src, &crate::watcher::ChangeEventType::Deleted);
config
.repo
.invalidate_file(&dst, &crate::watcher::ChangeEventType::Created);
let mut changed_union: Vec<PathBuf> = Vec::new();
for p in rewritten_paths.iter().chain(wiki_paths.iter()) {
if !changed_union.iter().any(|q| q == p) {
changed_union.push(p.clone());
}
}
for p in &changed_union {
config
.repo
.invalidate_file(p, &crate::watcher::ChangeEventType::Modified);
}
config.repo.build_relationship_index();
config.repo.build_wikilink_index();
config.repo.rebuild_tag_index();
ListingCaches::from(config).invalidate();
config.inbound_link_cache.invalidate_all();
config.link_cache.invalidate_all();
Self::broadcast_change(config, &src, crate::watcher::ChangeEventType::Deleted);
Self::broadcast_change(config, &dst, crate::watcher::ChangeEventType::Created);
for p in &changed_union {
Self::broadcast_change(config, p, crate::watcher::ChangeEventType::Modified);
}
let to_urls = |paths: &[PathBuf]| -> Vec<String> {
paths
.iter()
.map(|p| {
crate::repo::build_markdown_url_path(p, &config.base_dir, &config.index_file)
})
.collect()
};
Ok(MoveResponse {
from_url: old_url,
url_path: new_url,
path: Self::rel_path_string(&dst, &config.base_dir),
rewritten: to_urls(&rewritten_paths),
wikilinks_rewritten: to_urls(&wiki_paths),
created_dirs,
})
}
fn wikilink_delta_names(
frontmatter: Option<&crate::markdown::SimpleMetadata>,
old_stem: &str,
new_stem: &str,
) -> Vec<(String, String)> {
let title_for = |stem: &str| -> String {
frontmatter
.and_then(|fm| fm.get("title"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| stem.to_string())
};
let aliases: Vec<String> = frontmatter
.and_then(|fm| fm.get("aliases"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let old_title = title_for(old_stem);
let new_title = title_for(new_stem);
let mut new_names: HashSet<String> = HashSet::new();
new_names.insert(crate::relationships::normalize_name(new_stem));
new_names.insert(crate::relationships::normalize_name(&new_title));
for a in &aliases {
new_names.insert(crate::relationships::normalize_name(a));
}
let mut out = Vec::new();
let mut seen = HashSet::new();
for cand in std::iter::once(old_stem.to_string())
.chain(std::iter::once(old_title))
.chain(aliases.iter().cloned())
{
let norm = crate::relationships::normalize_name(&cand);
if new_names.contains(&norm) || !seen.insert(norm) {
continue;
}
out.push((cand, new_stem.to_string()));
}
out
}
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,
config.graph_depth,
);
}
};
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,
config.graph_depth,
);
}
};
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,
config.graph_depth,
);
}
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,
config.graph_depth,
);
}
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,
config.graph_depth,
);
}
};
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 = page_context::breadcrumbs_to_json(&breadcrumbs, &UrlMode::Absolute);
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));
}
page_context::insert_page_chrome(
&mut context,
&PageChrome {
mode: ModeFlags::Server {
gui_mode: Some(config.gui_mode),
mbr_base: true,
},
sidebar_style: &config.sidebar_style,
sidebar_max_items: config.sidebar_max_items,
graph_depth: config.graph_depth,
title_affixes: Some((&config.title_prefix, &config.title_suffix)),
},
);
match config.templates.render_media_viewer(context) {
Ok(html) => {
let etag = generate_etag(html.as_bytes());
build_response_or_500(
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)),
)
}
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,
config.graph_depth,
)
}
}
}
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 !is_servable_mbr_asset(&asset_path) {
tracing::debug!("serve_mbr_assets: not a servable asset: {}", asset_path);
return Err(StatusCode::NOT_FOUND);
}
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_TEMPLATE_DIR);
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,
graph_depth: usize,
) -> Response<Body> {
use std::collections::HashMap;
let mut context: HashMap<String, serde_json::Value> = HashMap::new();
page_context::insert_error_keys(
&mut context,
status_code.as_u16(),
error_title,
error_message,
);
context.insert(
"requested_url".to_string(),
serde_json::Value::String(requested_url.to_string()),
);
page_context::insert_page_chrome(
&mut context,
&PageChrome {
mode: ModeFlags::Server {
gui_mode: Some(gui_mode),
mbr_base: false,
},
sidebar_style,
sidebar_max_items,
graph_depth,
title_affixes: None,
},
);
match templates.render_error(context) {
Ok(html) => build_response_or_500(
Response::builder()
.status(status_code)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(html)),
),
Err(e) => {
tracing::error!("Failed to render error page: {}", e);
build_response_or_500(
Response::builder()
.status(status_code)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(format!(
"{} {}",
status_code.as_u16(),
error_title
))),
)
}
}
}
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,
};
let resolved = match resolve_request_path(&resolver_config, &path) {
ResolvedPath::StaticFile(resolved_path)
| ResolvedPath::MarkdownFile(resolved_path)
| ResolvedPath::DirectoryListing(resolved_path)
if !is_within_served_roots(
&resolved_path,
config.base_dir.as_path(),
config.canonical_base_dir.as_deref(),
&config.static_folder,
) =>
{
tracing::warn!(
"Blocked request for a path outside the repository root: {}",
resolved_path.display()
);
ResolvedPath::NotFound
}
other => other,
};
match resolved {
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(build_response_or_500(
Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header(header::LOCATION, &canonical_url)
.body(Body::empty()),
))
}
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_remux_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,
config.graph_depth,
))
}
}
}
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")]
fn metadata_response_from_cache(
cached: crate::video_metadata_cache::CachedMetadata,
) -> Option<Response<Body>> {
use crate::video_metadata_cache::CachedMetadata;
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,
}
}
#[cfg(feature = "media-metadata")]
async fn extract_video_metadata_and_cache(
video_file: std::path::PathBuf,
metadata_type: crate::video_metadata::MetadataType,
key: String,
config: &ServerState,
) -> Option<Response<Body>> {
use crate::video_metadata::{
MetadataType, extract_captions, extract_chapters, extract_cover,
};
use crate::video_metadata_cache::CachedMetadata;
let extracted = tokio::task::spawn_blocking(move || match metadata_type {
MetadataType::Cover => extract_cover(&video_file).map(CachedMetadata::Cover),
MetadataType::Chapters => extract_chapters(&video_file).map(CachedMetadata::Chapters),
MetadataType::Captions => extract_captions(&video_file).map(CachedMetadata::Captions),
})
.await;
match extracted {
Ok(Ok(cached)) => {
config.video_metadata_cache.insert(key, cached.clone());
Self::metadata_response_from_cache(cached)
}
Ok(Err(e)) => {
tracing::debug!("Failed to extract video metadata: {}", e);
config
.video_metadata_cache
.insert(key, CachedMetadata::NotAvailable);
None
}
Err(e) => {
tracing::warn!("Video metadata extraction task panicked: {}", e);
None
}
}
}
#[cfg(feature = "media-metadata")]
async fn try_serve_video_metadata(path: &str, config: &ServerState) -> Option<Response<Body>> {
use crate::video_metadata::{MetadataType, parse_metadata_request};
use crate::video_metadata_cache::cache_key_with_mtime;
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 Some(video_file) =
resolve_media_source_file(video_url_path, &config.base_dir, &config.static_folder)
else {
tracing::debug!(
"Video file not found for metadata generation: {}",
video_url_path
);
return None;
};
let key = cache_key_with_mtime(&video_file, cache_type_str);
if let Some(cached) = config.video_metadata_cache.get(&key) {
return Self::metadata_response_from_cache(cached);
}
match claim_inflight(&config.metadata_inflight, &key) {
InflightClaim::Produce(notify) => {
tracing::debug!(
"Generating {} for: {}",
cache_type_str,
video_file.display()
);
let _slot =
InflightSlot::new(Arc::clone(&config.metadata_inflight), key.clone(), notify);
Self::extract_video_metadata_and_cache(
video_file,
metadata_type,
key.clone(),
config,
)
.await
}
InflightClaim::Wait(notify) => {
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if let Some(cached) = config.video_metadata_cache.get(&key) {
return Self::metadata_response_from_cache(cached);
}
match tokio::time::timeout(METADATA_WAIT_TIMEOUT, notified).await {
Ok(()) => config
.video_metadata_cache
.get(&key)
.and_then(Self::metadata_response_from_cache),
Err(_) => {
tracing::warn!(
"Timed out waiting for in-progress metadata decode: {}",
key
);
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_with_mtime};
let _pdf_url_path = parse_pdf_cover_request(url_path)?;
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)
};
let key = cache_key_with_mtime(&pdf_file, "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, };
}
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_with_mtime};
let pdf_url_path = parse_pdf_cover_request(path)?;
let Some(pdf_file) =
resolve_media_source_file(pdf_url_path, &config.base_dir, &config.static_folder)
else {
tracing::debug!("PDF file not found for cover generation: {}", pdf_url_path);
return None;
};
let key = cache_key_with_mtime(&pdf_file, "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 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_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('/').trim_start_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,
current_page_url: page_url_path.clone(),
};
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,
Some(config.repo.wikilink_index.clone()),
)
.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 config.inbound_index.is_ready() {
config.inbound_index.get(&page_url_path)
} else if let Some(cached) = config.inbound_link_cache.get(&page_url_path) {
cached
} else {
match claim_inflight(&config.inbound_grep_inflight, &page_url_path) {
InflightClaim::Produce(notify) => {
let _slot = InflightSlot::new(
Arc::clone(&config.inbound_grep_inflight),
page_url_path.clone(),
notify,
);
let links = match config.inbound_link_cache.get(&page_url_path) {
Some(cached) => Some(cached),
None => Self::grep_inbound_links_bounded(&page_url_path, config).await,
};
links?
}
InflightClaim::Wait(notify) => {
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if let Some(cached) = config.inbound_link_cache.get(&page_url_path) {
cached
} else {
match tokio::time::timeout(INBOUND_GREP_WAIT_TIMEOUT, notified).await {
Ok(()) => config.inbound_link_cache.get(&page_url_path)?,
Err(_) => {
tracing::warn!(
"Timed out waiting for in-progress inbound link grep: {}",
page_url_path
);
return None;
}
}
}
}
}
};
let relationships = if config.relationship_tracking {
config.repo.relationship_index.get(&page_url_path)
} else {
Vec::new()
};
let page_links = PageLinks {
inbound,
outbound,
relationships,
};
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(build_response_or_500(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.body(Body::from(json)),
))
}
async fn grep_inbound_links_bounded(
page_url_path: &str,
config: &ServerState,
) -> Option<Vec<crate::link_index::InboundLink>> {
use crate::link_grep::find_inbound_links;
let _permit = config.inbound_grep_semaphore.acquire().await.ok()?;
let target = page_url_path.to_string();
let base_dir = config.base_dir.clone();
let markdown_extensions = config.markdown_extensions.clone();
let ignore_dirs = config.ignore_dirs.clone();
let ignore_globs = config.ignore_globs.clone();
let index_file = config.index_file.clone();
let links = tokio::task::spawn_blocking(move || {
find_inbound_links(
&target,
&base_dir,
&markdown_extensions,
&ignore_dirs,
&ignore_globs,
&index_file,
)
})
.await
.inspect_err(|e| tracing::error!("inbound link grep task failed: {e}"))
.ok()?;
config
.inbound_link_cache
.insert(page_url_path.to_string(), links.clone());
Some(links)
}
async fn try_serve_errors_json(path: &str, config: &ServerState) -> Option<Response<Body>> {
use crate::page_errors::{
PageErrors, ambiguous_relationship_endpoint_errors, ambiguous_wikilink_errors,
detect_unresolved_wikilinks, frontmatter_parse_errors, relationship_cycle_errors,
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, frontmatter_error, ambiguous_wikilinks): (
Vec<crate::link_index::OutboundLink>,
String,
Option<String>,
Vec<crate::wikilink_index::AmbiguousWikilink>,
) = 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,
current_page_url: page_url_path.clone(),
};
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,
Some(config.repo.wikilink_index.clone()),
)
.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,
render_result.frontmatter_error,
render_result.ambiguous_wikilinks,
)
}
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(), None, Vec::new())
}
ResolvedPath::TagSourceIndex { source } => {
let outbound = build_tag_index_outbound_links(&source, &config.repo.tag_index);
(outbound, String::new(), None, Vec::new())
}
_ => {
tracing::debug!("errors.json: page not found: {}", page_url_path);
return None;
}
};
let mut errors = Vec::new();
errors.extend(frontmatter_parse_errors(&frontmatter_error));
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,
&page_url_path,
));
errors.extend(detect_unresolved_wikilinks(&html_for_scan));
#[cfg(feature = "media-metadata")]
errors.extend(
Self::detect_unplayable_media(
&html_for_scan,
&resolver_config,
&page_url_path,
config,
)
.await,
);
}
errors.extend(ambiguous_wikilink_errors(&ambiguous_wikilinks));
if config.relationship_tracking {
let index = &config.repo.relationship_index;
errors.extend(relationship_cycle_errors(&index.cycles_for(&page_url_path)));
errors.extend(ambiguous_relationship_endpoint_errors(
&index.ambiguous_endpoints_for(&page_url_path),
));
}
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(build_response_or_500(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.body(Body::from(json)),
))
}
#[cfg(feature = "media-metadata")]
async fn probe_playback_compat_cached(
media_file: &std::path::Path,
config: &ServerState,
) -> Option<crate::video_metadata::PlaybackCompatibility> {
use crate::video_metadata::probe_playback_compatibility;
use crate::video_metadata_cache::cache_key_with_mtime;
let key = cache_key_with_mtime(media_file, "playback-compat");
if let Some(compat) = config.media_compat_cache.pin().get(&key).cloned() {
return Some(compat);
}
let path = media_file.to_path_buf();
let probed = tokio::task::spawn_blocking(move || probe_playback_compatibility(&path))
.await
.inspect_err(|e| tracing::warn!("playback-compat probe task failed: {e}"))
.ok()?
.inspect_err(|e| tracing::debug!("playback-compat probe of {media_file:?}: {e}"))
.ok()?;
config.media_compat_cache.pin().insert(key, probed.clone());
Some(probed)
}
#[cfg(feature = "media-metadata")]
async fn detect_unplayable_media(
html: &str,
resolver_config: &PathResolverConfig<'_>,
page_url: &str,
config: &ServerState,
) -> Vec<crate::page_errors::PageError> {
use crate::page_errors::{MediaKind, PageError, collect_media_references};
use crate::video_metadata::{PlaybackCompatibility, has_video_extension};
use futures::stream::StreamExt;
use itertools::Itertools;
let references: Vec<_> = collect_media_references(html, resolver_config, page_url)
.into_iter()
.filter(|reference| has_video_extension(&reference.path.to_string_lossy()))
.collect();
if references.is_empty() {
return Vec::new();
}
let unique_paths: Vec<PathBuf> = references
.iter()
.map(|reference| reference.path.clone())
.unique()
.collect();
let probes: std::collections::HashMap<PathBuf, PlaybackCompatibility> =
futures::stream::iter(unique_paths)
.map(|path| async move {
let compat = Self::probe_playback_compat_cached(&path, config).await?;
Some((path, compat))
})
.buffered(MEDIA_COMPAT_PROBE_CONCURRENCY)
.filter_map(|probe| async move { probe })
.collect()
.await;
references
.into_iter()
.filter_map(|reference| {
let compat = probes.get(&reference.path)?;
let reason = compat.reason()?;
Some(PageError::UnplayableMedia {
src: reference.src,
kind: MediaKind::Video,
reason,
remedy: compat.remedy(),
advisory: true,
})
})
.collect()
}
#[cfg(feature = "media-metadata")]
fn build_jpg_response(bytes: Vec<u8>) -> Response<Body> {
build_response_or_500(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.body(Body::from(bytes)),
)
}
#[cfg(feature = "media-metadata")]
fn build_vtt_response(vtt: String) -> Response<Body> {
build_response_or_500(
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)),
)
}
#[cfg(feature = "media-metadata")]
fn build_hls_playlist_response(playlist: Arc<Vec<u8>>) -> Response<Body> {
build_response_or_500(
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())),
)
}
#[cfg(feature = "media-metadata")]
fn build_hls_segment_response(segment: Arc<Vec<u8>>) -> Response<Body> {
build_response_or_500(
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())),
)
}
#[cfg(feature = "media-metadata")]
fn build_remux_response(
part: crate::video_remux::RemuxPart,
data: Arc<Vec<u8>>,
) -> Response<Body> {
use crate::video_remux::{
INIT_CONTENT_TYPE, PLAYLIST_CONTENT_TYPE, RemuxPart, SEGMENT_CONTENT_TYPE,
};
let content_type = match part {
RemuxPart::Playlist => PLAYLIST_CONTENT_TYPE,
RemuxPart::Init => INIT_CONTENT_TYPE,
RemuxPart::Segment(_) => SEGMENT_CONTENT_TYPE,
};
build_response_or_500(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.body(Body::from(data.as_ref().clone())),
)
}
#[cfg(feature = "media-metadata")]
async fn generate_remux_part<F>(
cache: &Arc<crate::video_transcode_cache::HlsCache>,
key: crate::video_transcode_cache::HlsCacheKey,
generate: F,
) -> Result<Arc<Vec<u8>>, Response<Body>>
where
F: FnOnce() -> Result<Vec<u8>, crate::video_transcode::TranscodeError> + Send + 'static,
{
use crate::video_transcode_cache::{HLS_WAIT_TIMEOUT, HlsCache, HlsCacheStartResult};
match cache.start_generation(key.clone()) {
HlsCacheStartResult::Started(notify) => {
let handle = HlsCache::spawn_generation(cache, key.clone(), notify, generate);
match handle.await {
Ok(Ok(data)) => Ok(data),
Ok(Err(error)) => Err(Self::build_remux_error_response(&error)),
Err(join_error) => {
tracing::warn!("remux generation task did not finish: {join_error}");
Err(Self::build_remux_retry_response(
"generation did not complete",
))
}
}
}
HlsCacheStartResult::AlreadyInProgress(notify) => {
match cache
.wait_for_completion(&key, notify, HLS_WAIT_TIMEOUT)
.await
{
Some(data) => Ok(data),
None => Err(Self::remux_incomplete_response(cache, &key)),
}
}
HlsCacheStartResult::AlreadyComplete(data) => Ok(data),
HlsCacheStartResult::PreviouslyFailed(message) => {
tracing::debug!("previous remux generation failed: {message}");
Err(Self::build_remux_unprocessable_response(&message))
}
HlsCacheStartResult::CacheDisabled => {
let notify = Arc::new(tokio::sync::Notify::new());
let handle = HlsCache::spawn_generation(cache, key, notify, generate);
match handle.await {
Ok(Ok(data)) => Ok(data),
Ok(Err(error)) => Err(Self::build_remux_error_response(&error)),
Err(join_error) => {
tracing::warn!("remux generation task did not finish: {join_error}");
Err(Self::build_remux_retry_response(
"generation did not complete",
))
}
}
}
}
}
#[cfg(feature = "media-metadata")]
fn remux_incomplete_response(
cache: &crate::video_transcode_cache::HlsCache,
key: &crate::video_transcode_cache::HlsCacheKey,
) -> Response<Body> {
use crate::video_transcode_cache::HlsCacheState;
match cache.get_state(key) {
Some(HlsCacheState::Failed(message)) => {
Self::build_remux_unprocessable_response(&message)
}
_ => Self::build_remux_retry_response("generation is not available yet"),
}
}
#[cfg(feature = "media-metadata")]
fn build_remux_unprocessable_response(message: &str) -> Response<Body> {
build_response_or_500(
Response::builder()
.status(StatusCode::UNPROCESSABLE_ENTITY)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(format!("Cannot serve this variant: {message}"))),
)
}
#[cfg(feature = "media-metadata")]
fn build_remux_retry_response(reason: &str) -> Response<Body> {
build_response_or_500(
Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.header(header::RETRY_AFTER, "1")
.body(Body::from(format!("{reason}; please retry"))),
)
}
#[cfg(feature = "media-metadata")]
fn build_remux_error_response(
error: &crate::video_transcode::TranscodeError,
) -> Response<Body> {
use crate::video_transcode::TranscodeError;
let status = match error {
TranscodeError::NoVideoStream { .. }
| TranscodeError::NoKeyframeIndex
| TranscodeError::UnsupportedFormat => StatusCode::UNPROCESSABLE_ENTITY,
TranscodeError::SegmentOutOfRange { .. } => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
tracing::warn!("remux failed ({status}): {error}");
build_response_or_500(
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(error.to_string())),
)
}
#[cfg(feature = "media-metadata")]
async fn try_serve_remux_content(path: &str, config: &ServerState) -> Option<Response<Body>> {
use crate::video_metadata_cache::cache_key_with_mtime;
use crate::video_remux::{
RemuxPart, generate_remux_init, generate_remux_playlist, generate_remux_segment,
parse_remux_request,
};
use crate::video_transcode_cache::HlsCacheKey;
let request = parse_remux_request(path)?;
let Some(video_file) =
resolve_media_source_file(request.video_path, &config.base_dir, &config.static_folder)
else {
tracing::debug!("no video file for remux request: {}", request.video_path);
return None;
};
let cache_key =
HlsCacheKey::remux(cache_key_with_mtime(&video_file, "remux"), request.part);
let base_name = std::path::Path::new(request.video_path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("video")
.to_string();
let part = request.part;
let source = video_file.clone();
let generate = move || match part {
RemuxPart::Playlist => {
generate_remux_playlist(&source, &base_name).map(String::into_bytes)
}
RemuxPart::Init => generate_remux_init(&source),
RemuxPart::Segment(index) => generate_remux_segment(&source, index),
};
tracing::debug!("remux request for {:?}: {:?}", video_file, part);
Some(
match Self::generate_remux_part(&config.hls_cache, cache_key, generate).await {
Ok(data) => Self::build_remux_response(part, data),
Err(response) => response,
},
)
}
#[cfg(feature = "media-metadata")]
async fn probe_resolution_cached(
video_file: &std::path::Path,
config: &ServerState,
) -> Option<crate::video_transcode::VideoResolution> {
use crate::video_metadata_cache::cache_key_with_mtime;
use crate::video_transcode::probe_video_resolution;
let key = cache_key_with_mtime(video_file, "resolution");
if let Some(res) = config.video_resolution_cache.pin().get(&key).cloned() {
return Some(res);
}
let path = video_file.to_path_buf();
let probed = tokio::task::spawn_blocking(move || probe_video_resolution(&path))
.await
.inspect_err(|e| tracing::warn!("resolution probe task failed: {e}"))
.ok()?
.ok()?;
config
.video_resolution_cache
.pin()
.insert(key, probed.clone());
Some(probed)
}
#[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, should_transcode,
transcode_segment,
};
use crate::video_transcode_cache::{
HLS_WAIT_TIMEOUT, HlsCacheKey, HlsCacheStartResult, HlsCacheState,
};
fn build_transcode_error_response(error: &TranscodeError) -> Option<Response<Body>> {
match error {
TranscodeError::SourceTooSmall {
source_height,
target_height,
} => Some(build_response_or_500(
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
))),
)),
TranscodeError::SegmentOutOfRange {
segment_index,
video_duration,
} => Some(build_response_or_500(
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
))),
)),
_ => 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 Some(video_file) =
resolve_media_source_file(&video_path, &config.base_dir, &config.static_folder)
else {
tracing::debug!("Original video file not found for HLS: {}", video_path);
return None;
};
let content_key = match &hls_request {
HlsRequest::Playlist { .. } => HlsCacheKey::playlist(video_file.clone(), target),
HlsRequest::Segment { segment_index, .. } => {
HlsCacheKey::segment(video_file.clone(), target, *segment_index)
}
};
if let Some(HlsCacheState::Complete(data)) = config.hls_cache.get_state(&content_key) {
tracing::debug!("Serving cached HLS content (pre-probe fast path)");
return Some(match hls_request {
HlsRequest::Playlist { .. } => Self::build_hls_playlist_response(data),
HlsRequest::Segment { .. } => Self::build_hls_segment_response(data),
});
}
let resolution = Self::probe_resolution_cached(&video_file, config).await?;
if !should_transcode(resolution.height, target) {
tracing::debug!(
"Video already at or below target resolution: {}x{} <= {}",
resolution.width,
resolution.height,
target.height()
);
return Some(build_response_or_500(
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()
))),
));
}
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 mut slot = HlsGenerationSlot::new(
&config.hls_cache,
cache_key.clone(),
Arc::clone(¬ify),
);
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());
slot.settled();
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);
slot.settled();
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");
match config
.hls_cache
.wait_for_completion(&cache_key, notify, HLS_WAIT_TIMEOUT)
.await
{
Some(data) => return Some(Self::build_hls_playlist_response(data)),
None => 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(build_response_or_500(
Response::builder()
.status(StatusCode::UNPROCESSABLE_ENTITY)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(format!("Transcode failed: {}", msg))),
));
}
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 mut slot = HlsGenerationSlot::new(
&config.hls_cache,
cache_key.clone(),
Arc::clone(¬ify),
);
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);
slot.settled();
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);
slot.settled();
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");
match config
.hls_cache
.wait_for_completion(&cache_key, notify, HLS_WAIT_TIMEOUT)
.await
{
Some(data) => return Some(Self::build_hls_segment_response(data)),
None => 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(build_response_or_500(
Response::builder()
.status(StatusCode::UNPROCESSABLE_ENTITY)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(format!("Transcode failed: {}", msg))),
));
}
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>, MbrError> {
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,
current_page_url: crate::repo::build_markdown_url_path(
md_path,
root_path,
&config.index_file,
),
};
#[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,
Some(config.repo.wikilink_index.clone()),
)
.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(),
crate::url_path::path_to_url(&relative_md_path).into(),
);
frontmatter.insert("server_mode".into(), "true".into());
frontmatter.insert(
"gui_mode".into(),
if config.gui_mode { "true" } else { "" }.into(),
);
frontmatter.insert(
"edit_enabled".into(),
if config.edit_enabled { "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)
};
let current_url =
format!("/{}/", crate::url_path::path_to_url(&url_path_buf)).replace("//", "/");
if config.link_tracking {
let resolved_links =
resolve_outbound_links(¤t_url, outbound_links, is_index_file);
config
.link_cache
.insert(current_url.clone(), resolved_links);
}
let modified_secs = tokio::fs::metadata(md_path)
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
let parent_dir = relative_md_path.parent().unwrap_or(Path::new(""));
let siblings: Arc<Vec<serde_json::Value>> = cached_dir_files(config, parent_dir);
let extra_context = page_context::markdown_extra_context(
&page_context::MarkdownPageParams {
breadcrumb_path: &url_path_buf,
headings: &headings,
has_h1,
word_count,
readability: &readability_scores,
file_path: &relative_md_path.to_string_lossy(),
modified_secs,
current_url: ¤t_url,
siblings: &siblings,
},
&page_context::MarkdownContextOptions {
tag_sources: &config.tag_sources,
sidebar_style: &config.sidebar_style,
sidebar_max_items: config.sidebar_max_items,
graph_depth: config.graph_depth,
title_prefix: &config.title_prefix,
title_suffix: &config.title_suffix,
},
&page_context::UrlMode::Absolute,
);
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(MbrError::from)
}
async fn scan_directory_children(
dir_path: &Path,
root_path: &Path,
relative_path: &Path,
config: &ServerState,
) -> Result<(Vec<serde_json::Value>, Vec<serde_json::Value>), MbrError> {
use serde_json::json;
let root_path = root_path.to_path_buf();
let dir_path = dir_path.to_path_buf();
let relative_path = relative_path.to_path_buf();
let static_folder = config.static_folder.clone();
let markdown_extensions = config.markdown_extensions.clone();
let ignore_dirs = config.ignore_dirs.clone();
let ignore_globs = config.ignore_globs.clone();
let index_file = config.index_file.clone();
let tag_sources = config.tag_sources.clone();
let relationship_types = config.relationship_types.clone();
let sort = config.sort.clone();
let scan_result = tokio::task::spawn_blocking(move || {
let temp_repo = Repo::init(
&root_path,
&static_folder,
&markdown_extensions,
&ignore_dirs,
&ignore_globs,
&index_file,
&tag_sources,
&relationship_types,
);
temp_repo.scan_folder(&relative_path).inspect_err(|e| {
tracing::error!("Error scanning directory: {e}");
})?;
let mut files: Vec<serde_json::Value> = temp_repo
.markdown_files
.pin()
.iter()
.map(|(_, file_info)| markdown_file_to_json(file_info))
.collect();
sort_files(&mut files, &sort);
let subdirs: Vec<serde_json::Value> = temp_repo
.queued_folders
.pin()
.iter()
.filter_map(|(abs_path, rel_path)| {
let parent = abs_path.parent()?;
if parent == dir_path.as_path() {
let name = abs_path.file_name()?.to_str()?.to_string();
let mut url_path = crate::url_path::path_to_url(rel_path);
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();
Ok::<_, crate::errors::RepoError>((files, subdirs))
})
.await;
match scan_result {
Ok(Ok(pair)) => Ok(pair),
Ok(Err(e)) => Err(e.into()),
Err(e) => {
tracing::error!("directory scan task failed: {e}");
Err(e.into())
}
}
}
async fn directory_to_html(
dir_path: &Path,
templates: &crate::templates::Templates,
root_path: &Path,
config: &ServerState,
) -> Result<Response<Body>, MbrError> {
use serde_json::json;
let relative_path = pathdiff::diff_paths(dir_path, root_path)
.unwrap_or_else(|| std::path::PathBuf::from("."));
let (files, subdirs) = if config.repo.is_scan_complete() {
let dir_key = listing_dir_key(&relative_path);
(
cached_dir_files(config, &dir_key).as_ref().clone(),
cached_dir_subdirs(config, &dir_key, root_path)
.as_ref()
.clone(),
)
} else {
Self::scan_directory_children(dir_path, root_path, &relative_path, config).await?
};
let breadcrumbs = generate_breadcrumbs(&relative_path);
let breadcrumbs_json = page_context::breadcrumbs_to_json(&breadcrumbs, &UrlMode::Absolute);
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(), serde_json::Value::Array(files));
context.insert("subdirs".to_string(), serde_json::Value::Array(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(
"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,
}),
);
context.insert(
"tag_sources".to_string(),
json!(page_context::tag_sources_json(&config.tag_sources)),
);
page_context::insert_page_chrome(
&mut context,
&PageChrome {
mode: ModeFlags::Server {
gui_mode: Some(config.gui_mode),
mbr_base: false,
},
sidebar_style: &config.sidebar_style,
sidebar_max_items: config.sidebar_max_items,
graph_depth: config.graph_depth,
title_affixes: Some((&config.title_prefix, &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(MbrError::from)
}
async fn tag_page_to_html(
source: &str,
value: &str,
config: &ServerState,
) -> Result<Response<Body>, MbrError> {
let (label, label_plural) =
page_context::tag_labels(&config.tag_sources, source, &capitalize_first(source));
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 mut context = std::collections::HashMap::new();
page_context::insert_tag_page_keys(
&mut context,
source,
&display_value,
&label,
&label_plural,
&pages,
&UrlMode::Absolute,
);
page_context::insert_page_chrome(
&mut context,
&PageChrome {
mode: ModeFlags::Server {
gui_mode: None,
mbr_base: true,
},
sidebar_style: &config.sidebar_style,
sidebar_max_items: config.sidebar_max_items,
graph_depth: config.graph_depth,
title_affixes: Some((&config.title_prefix, &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(MbrError::from)
}
async fn tag_source_index_to_html(
source: &str,
config: &ServerState,
) -> Result<Response<Body>, MbrError> {
let (label, label_plural) =
page_context::tag_labels(&config.tag_sources, source, &capitalize_first(source));
let tags = config.repo.tag_index.get_all_tags(source);
let mut context = std::collections::HashMap::new();
page_context::insert_tag_index_keys(&mut context, source, &label, &label_plural, &tags);
page_context::insert_page_chrome(
&mut context,
&PageChrome {
mode: ModeFlags::Server {
gui_mode: None,
mbr_base: true,
},
sidebar_style: &config.sidebar_style,
sidebar_max_items: config.sidebar_max_items,
graph_depth: config.graph_depth,
title_affixes: Some((&config.title_prefix, &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(MbrError::from)
}
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 url = format!("/{}/", path_components[..=idx].join("/"));
let name = path_components[idx].to_string();
breadcrumbs.push(Breadcrumb::new(name, url));
}
breadcrumbs
}
fn should_reload_template(event_path: &str, template_folder: Option<&Path>) -> bool {
match template_folder {
Some(tf) => Path::new(event_path).starts_with(tf),
None => Path::new(event_path)
.components()
.any(|c| c.as_os_str() == ".mbr"),
}
}
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 = path_components[..path_components.len() - 1].join("/");
Some(format!("/{}/", parent))
} else if !path_components.is_empty() {
Some("/".to_string())
} else {
None
}
}
fn compute_sibling_files<'a>(
files: impl Iterator<Item = &'a MarkdownInfo>,
parent_dir: &Path,
sort: &[SortField],
) -> Vec<serde_json::Value> {
let mut siblings: Vec<serde_json::Value> = files
.filter_map(|info| {
let file_parent = info.raw_path.parent()?;
(file_parent == parent_dir).then(|| markdown_file_to_json(info))
})
.collect();
sort_files(&mut siblings, sort);
siblings
}
fn listing_dir_key(relative_dir: &Path) -> PathBuf {
if relative_dir == Path::new(".") {
PathBuf::new()
} else {
relative_dir.to_path_buf()
}
}
fn immediate_subdir_name<'a>(file_path: &'a Path, dir: &Path) -> Option<&'a std::ffi::OsStr> {
let rest = if dir.as_os_str().is_empty() {
file_path
} else {
file_path.strip_prefix(dir).ok()?
};
let mut components = rest.components();
let first = components.next()?;
components.next()?;
match first {
std::path::Component::Normal(name) => Some(name),
_ => None,
}
}
fn compute_subdir_entries<'a>(
file_paths: impl Iterator<Item = &'a Path>,
dir: &Path,
) -> Vec<serde_json::Value> {
file_paths
.filter_map(|path| immediate_subdir_name(path, dir))
.map(|name| name.to_string_lossy().into_owned())
.collect::<std::collections::BTreeSet<String>>()
.into_iter()
.map(|name| {
let url_path = format!("/{}/", crate::url_path::path_to_url(&dir.join(&name)));
serde_json::json!({
"name": name,
"url_path": url_path,
})
})
.collect()
}
fn cached_dir_files(config: &ServerState, dir: &Path) -> Arc<Vec<serde_json::Value>> {
if let Some(cached) = config.sibling_nav_cache.pin().get(dir).cloned() {
return cached;
}
let computed = Arc::new(compute_sibling_files(
config
.repo
.markdown_files
.pin()
.iter()
.map(|(_, info)| info),
dir,
&config.sort,
));
if config.repo.is_scan_complete() {
config
.sibling_nav_cache
.pin()
.insert(dir.to_path_buf(), Arc::clone(&computed));
}
computed
}
fn cached_dir_subdirs(
config: &ServerState,
dir: &Path,
root_path: &Path,
) -> Arc<Vec<serde_json::Value>> {
if let Some(cached) = config.subdir_cache.pin().get(dir).cloned() {
return cached;
}
let markdown_guard = config.repo.markdown_files.pin();
let other_guard = config.repo.other_files.pin();
let computed = Arc::new(compute_subdir_entries(
markdown_guard
.iter()
.map(|(_, info)| info.raw_path.as_path())
.chain(
other_guard
.iter()
.filter_map(|(abs_path, _)| abs_path.strip_prefix(root_path).ok()),
),
dir,
));
if config.repo.is_scan_complete() {
config
.subdir_cache
.pin()
.insert(dir.to_path_buf(), Arc::clone(&computed));
}
computed
}
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 note_type = file_info
.frontmatter
.as_ref()
.and_then(|fm| fm.get("type"))
.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,
"type": note_type,
"modified_date": modified_date,
"modified": file_info.modified,
"name": file_info.raw_path.file_name().and_then(|s| s.to_str()).unwrap_or(""),
})
}
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn build_response_or_500(result: Result<Response<Body>, axum::http::Error>) -> Response<Body> {
result.unwrap_or_else(|e| {
tracing::error!("Failed to build HTTP response: {e}");
let mut response = Response::new(Body::from("Internal Server Error"));
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
response
})
}
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",
),
(
"/components/mbr-editor.min.js",
include_bytes!("../templates/components-js/mbr-editor.min.js"),
"application/javascript",
),
(
"/components/mbr-graph.min.js",
include_bytes!("../templates/components-js/mbr-graph.min.js"),
"application/javascript",
),
(
"/components/mbr-genealogy.min.js",
include_bytes!("../templates/components-js/mbr-genealogy.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::path::PathBuf;
#[test]
fn test_capitalize_first_ascii() {
assert_eq!(capitalize_first("tags"), "Tags");
assert_eq!(capitalize_first("t"), "T");
}
#[test]
fn test_capitalize_first_multibyte() {
assert_eq!(capitalize_first("étiquettes"), "Étiquettes");
assert_eq!(capitalize_first("über"), "Über");
assert_eq!(capitalize_first("日本語"), "日本語");
}
#[test]
fn test_capitalize_first_empty() {
assert_eq!(capitalize_first(""), "");
}
#[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_should_reload_template_inside_template_folder() {
let tf = Path::new("/x/tmpl");
assert!(should_reload_template("/x/tmpl/index.html", Some(tf)));
assert!(should_reload_template(
"/x/tmpl/partials/_nav.html",
Some(tf)
));
}
#[test]
fn test_should_reload_template_rejects_sibling_prefix() {
assert!(!should_reload_template(
"/x/tmpl-backup/index.html",
Some(Path::new("/x/tmpl"))
));
}
#[test]
fn test_should_reload_template_outside_template_folder() {
assert!(!should_reload_template(
"/other/place/index.html",
Some(Path::new("/x/tmpl"))
));
}
#[test]
fn test_should_reload_template_without_folder_matches_mbr_component() {
assert!(should_reload_template("/repo/.mbr/index.html", None));
assert!(!should_reload_template("/repo/docs/index.html", None));
assert!(!should_reload_template("/repo/.mbrx/index.html", None));
}
#[cfg(unix)]
#[test]
fn test_should_reload_template_matches_through_symlinked_folder() {
let temp = tempfile::tempdir().expect("create temp dir");
let real = temp.path().join("real-templates");
std::fs::create_dir(&real).expect("create real template dir");
let link = temp.path().join("link-templates");
std::os::unix::fs::symlink(&real, &link).expect("create symlink");
let event_path = real
.canonicalize()
.expect("canonicalize real dir")
.join("index.html");
let event_path = event_path.to_string_lossy().to_string();
assert!(!should_reload_template(&event_path, Some(link.as_path())));
let canonical = link.canonicalize().unwrap_or(link);
assert!(should_reload_template(&event_path, Some(&canonical)));
}
#[test]
fn test_markdown_file_to_json_with_frontmatter() {
let mut frontmatter = crate::markdown::SimpleMetadata::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("test.md"),
url_path: "/test/".to_string(),
frontmatter: Some(frontmatter),
created: 1699000000,
modified: 1700000000,
relationships: Vec::new(),
};
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("my-document.md"),
url_path: "/my-document/".to_string(),
frontmatter: None,
created: 1699000000,
modified: 1700000000,
relationships: Vec::new(),
};
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 = crate::markdown::SimpleMetadata::new();
frontmatter.insert(
"title".to_string(),
serde_json::Value::String("Only Title".to_string()),
);
let file_info = MarkdownInfo {
raw_path: PathBuf::from("partial.md"),
url_path: "/partial/".to_string(),
frontmatter: Some(frontmatter),
created: 1699000000,
modified: 1700000000,
relationships: Vec::new(),
};
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(_))));
}
#[cfg(feature = "media-metadata")]
#[test]
fn test_resolve_media_source_file_rejects_traversal() {
let temp_dir = tempfile::tempdir().unwrap();
let base = temp_dir.path().join("base");
std::fs::create_dir_all(&base).unwrap();
std::fs::write(temp_dir.path().join("secret.mp4"), b"secret").unwrap();
let result = resolve_media_source_file("../secret.mp4", &base, "static");
assert!(
result.is_none(),
"path traversal outside base_dir must be rejected"
);
}
#[cfg(feature = "media-metadata")]
#[test]
fn test_resolve_media_source_file_accepts_file_in_base() {
let temp_dir = tempfile::tempdir().unwrap();
let video_file = temp_dir.path().join("demo.mp4");
std::fs::write(&video_file, b"video content").unwrap();
let result = resolve_media_source_file("demo.mp4", temp_dir.path(), "static");
assert_eq!(result, Some(video_file.canonicalize().unwrap()));
}
#[cfg(feature = "media-metadata")]
#[test]
fn test_resolve_media_source_file_accepts_file_in_static_folder() {
let temp_dir = tempfile::tempdir().unwrap();
let static_dir = temp_dir.path().join("static");
std::fs::create_dir_all(&static_dir).unwrap();
let video_file = static_dir.join("demo.mp4");
std::fs::write(&video_file, b"video content").unwrap();
let result = resolve_media_source_file("demo.mp4", temp_dir.path(), "static");
assert_eq!(result, Some(video_file.canonicalize().unwrap()));
}
#[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");
}
}
fn mk_markdown_info(raw: &str, url: &str, title: &str) -> MarkdownInfo {
let mut frontmatter = crate::markdown::SimpleMetadata::new();
frontmatter.insert(
"title".to_string(),
serde_json::Value::String(title.to_string()),
);
MarkdownInfo {
raw_path: PathBuf::from(raw),
url_path: url.to_string(),
frontmatter: Some(frontmatter),
created: 0,
modified: 0,
relationships: Vec::new(),
}
}
fn title_sort() -> Vec<SortField> {
vec![SortField {
field: "title".to_string(),
order: "asc".to_string(),
compare: "string".to_string(),
}]
}
#[test]
fn test_compute_sibling_files_matches_full_scan() {
let files = [
mk_markdown_info("docs/b.md", "/docs/b/", "Beta"),
mk_markdown_info("docs/a.md", "/docs/a/", "Alpha"),
mk_markdown_info("other/c.md", "/other/c/", "Gamma"),
mk_markdown_info("root.md", "/root/", "Root"),
];
let sort = title_sort();
let parent = Path::new("docs");
let got = compute_sibling_files(files.iter(), parent, &sort);
let mut expected: Vec<serde_json::Value> = files
.iter()
.filter_map(|info| {
let file_parent = info.raw_path.parent()?;
(file_parent == parent).then(|| markdown_file_to_json(info))
})
.collect();
sort_files(&mut expected, &sort);
assert_eq!(got, expected);
assert_eq!(got.len(), 2);
assert_eq!(got[0]["title"], "Alpha");
assert_eq!(got[1]["title"], "Beta");
}
#[test]
fn test_compute_sibling_files_no_children() {
let files = [mk_markdown_info("docs/a.md", "/docs/a/", "Alpha")];
let got = compute_sibling_files(files.iter(), Path::new("empty"), &title_sort());
assert!(got.is_empty());
}
#[test]
fn test_immediate_subdir_name_rules() {
let root = Path::new("");
assert_eq!(
immediate_subdir_name(Path::new("docs/guide.md"), root)
.map(|n| n.to_string_lossy().into_owned()),
Some("docs".to_string())
);
assert_eq!(immediate_subdir_name(Path::new("readme.md"), root), None);
assert_eq!(
immediate_subdir_name(Path::new("docs/deep/x.md"), Path::new("docs"))
.map(|n| n.to_string_lossy().into_owned()),
Some("deep".to_string())
);
assert_eq!(
immediate_subdir_name(Path::new("other/x.md"), Path::new("docs")),
None
);
assert_eq!(
immediate_subdir_name(Path::new("docs/guide.md"), Path::new("docs")),
None
);
}
#[test]
fn test_compute_subdir_entries_dedupes_and_sorts() {
let paths = [
Path::new("docs/zeta/a.md"),
Path::new("docs/alpha/b.md"),
Path::new("docs/alpha/c.md"),
Path::new("docs/readme.md"),
Path::new("elsewhere/d.md"),
];
let got = compute_subdir_entries(paths.into_iter(), Path::new("docs"));
assert_eq!(got.len(), 2);
assert_eq!(got[0]["name"], "alpha");
assert_eq!(got[0]["url_path"], "/docs/alpha/");
assert_eq!(got[1]["name"], "zeta");
assert_eq!(got[1]["url_path"], "/docs/zeta/");
}
#[test]
fn test_listing_dir_key_normalizes_dot_to_root() {
assert_eq!(listing_dir_key(Path::new(".")), PathBuf::new());
assert_eq!(listing_dir_key(Path::new("")), PathBuf::new());
assert_eq!(listing_dir_key(Path::new("docs")), PathBuf::from("docs"));
}
fn listing_fixture() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("docs/deep")).unwrap();
std::fs::create_dir_all(root.join("assets")).unwrap();
std::fs::write(root.join("beta.md"), "---\ntitle: Beta\n---\n\nB\n").unwrap();
std::fs::write(root.join("alpha.md"), "---\ntitle: Alpha\n---\n\nA\n").unwrap();
std::fs::write(root.join("docs/guide.md"), "---\ntitle: Guide\n---\n\nG\n").unwrap();
std::fs::write(root.join("docs/deep/x.md"), "# X\n").unwrap();
std::fs::write(root.join("assets/pic.png"), b"not-really-a-png").unwrap();
tmp
}
fn fixture_repo(root: &Path) -> Repo {
Repo::init(
root,
"static",
&["md".to_string()],
&[],
&[],
"index.md",
&[],
&[],
)
}
#[test]
fn test_inbound_index_agrees_with_grep_backlinks() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
std::fs::create_dir_all(root.join("docs")).unwrap();
std::fs::write(
root.join("alpha.md"),
"---\ntitle: Alpha\n---\n\nSee [the guide](docs/guide.md) and [beta](beta.md).\n",
)
.unwrap();
std::fs::write(
root.join("beta.md"),
"---\ntitle: Beta\n---\n\nBack to [alpha](alpha.md).\n",
)
.unwrap();
std::fs::write(
root.join("docs/guide.md"),
"---\ntitle: Guide\n---\n\nUp to [alpha](../alpha.md); also [beta](../beta.md).\n",
)
.unwrap();
std::fs::write(
root.join("orphan.md"),
"---\ntitle: Orphan\n---\n\nAlone.\n",
)
.unwrap();
let repo = Arc::new(fixture_repo(&root));
repo.scan_all().unwrap();
repo.build_wikilink_index();
let cfg = LinkIndexConfig {
base_dir: root.clone(),
index_file: "index.md".to_string(),
markdown_extensions: vec!["md".to_string()],
valid_tag_sources: HashSet::new(),
};
let index = InboundIndex::new();
populate_inbound_index(&repo, &index, &cfg);
assert!(index.is_ready());
for page in ["/alpha/", "/beta/", "/docs/guide/", "/orphan/"] {
let indexed: Vec<String> = index.get(page).into_iter().map(|l| l.from).collect();
let mut grepped = crate::link_grep::find_inbound_links(
page,
&root,
&["md".to_string()],
&[],
&[],
"index.md",
);
crate::link_index::sort_inbound_links(&mut grepped);
let grepped: Vec<String> = grepped.into_iter().map(|l| l.from).collect();
assert_eq!(
indexed, grepped,
"backlink sources for {page} disagree: index={indexed:?} grep={grepped:?}"
);
}
assert_eq!(
index.get("/alpha/").len(),
2,
"alpha should be linked from beta and the guide"
);
assert!(index.get("/orphan/").is_empty());
}
#[test]
fn test_directory_listing_from_index_matches_disk_scan() {
let tmp = listing_fixture();
let root = tmp.path().canonicalize().unwrap();
let sort = title_sort();
let indexed = fixture_repo(&root);
indexed.scan_all().unwrap();
for dir in [Path::new(""), Path::new("docs")] {
let scan_dir = if dir.as_os_str().is_empty() {
PathBuf::from(".")
} else {
dir.to_path_buf()
};
let temp_repo = fixture_repo(&root);
temp_repo.scan_folder(&scan_dir).unwrap();
let mut expected_files: Vec<serde_json::Value> = temp_repo
.markdown_files
.pin()
.iter()
.map(|(_, info)| markdown_file_to_json(info))
.collect();
sort_files(&mut expected_files, &sort);
let abs_dir = root.join(dir);
let expected_subdirs: std::collections::BTreeSet<String> = temp_repo
.queued_folders
.pin()
.iter()
.filter_map(|(abs_path, _)| {
(abs_path.parent()? == abs_dir.as_path())
.then(|| abs_path.file_name()?.to_str().map(|s| s.to_string()))?
})
.collect();
let got_files = compute_sibling_files(
indexed.markdown_files.pin().iter().map(|(_, info)| info),
dir,
&sort,
);
let markdown_guard = indexed.markdown_files.pin();
let other_guard = indexed.other_files.pin();
let got_subdir_entries = compute_subdir_entries(
markdown_guard
.iter()
.map(|(_, info)| info.raw_path.as_path())
.chain(
other_guard
.iter()
.filter_map(|(abs, _)| abs.strip_prefix(&root).ok()),
),
dir,
);
let got_subdirs: std::collections::BTreeSet<String> = got_subdir_entries
.iter()
.filter_map(|entry| entry["name"].as_str().map(|s| s.to_string()))
.collect();
assert_eq!(got_files, expected_files, "files mismatch for {dir:?}");
assert_eq!(
got_subdirs, expected_subdirs,
"subdirs mismatch for {dir:?}"
);
assert!(
!got_files.is_empty(),
"fixture should have files in {dir:?}"
);
assert!(
!got_subdirs.is_empty(),
"fixture should have subdirs in {dir:?}"
);
}
}
#[test]
fn test_subdirs_include_asset_only_directories() {
let tmp = listing_fixture();
let root = tmp.path().canonicalize().unwrap();
let repo = fixture_repo(&root);
repo.scan_all().unwrap();
let markdown_guard = repo.markdown_files.pin();
let other_guard = repo.other_files.pin();
let entries = compute_subdir_entries(
markdown_guard
.iter()
.map(|(_, info)| info.raw_path.as_path())
.chain(
other_guard
.iter()
.filter_map(|(abs, _)| abs.strip_prefix(&root).ok()),
),
Path::new(""),
);
let names: Vec<&str> = entries
.iter()
.filter_map(|e| e["name"].as_str())
.collect::<Vec<_>>();
assert!(
names.contains(&"assets"),
"asset-only directory must still be listed: {names:?}"
);
}
#[test]
fn test_render_site_json_omits_other_files() {
let tmp = listing_fixture();
let root = tmp.path().canonicalize().unwrap();
let repo = fixture_repo(&root);
repo.scan_all().unwrap();
repo.scan_static_folder().unwrap();
assert!(
!repo.other_files.pin().is_empty(),
"fixture must contain at least one non-markdown file"
);
let params = SiteJsonParams {
sort: title_sort(),
sidebar_style: "panel".to_string(),
sidebar_max_items: 42,
relationship_tracking: false,
};
let bytes = render_site_json(&repo, ¶ms).expect("site.json renders");
let value: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
assert!(
value.get("other_files").is_none(),
"site.json must not include the media catalog"
);
let files = value["markdown_files"]
.as_array()
.expect("markdown_files array");
assert_eq!(files.len(), 4);
assert_eq!(value["index_file"], "index.md");
assert_eq!(value["sidebar_style"], "panel");
assert_eq!(value["sidebar_max_items"], 42);
assert_eq!(value["sort"][0]["field"], "title");
let again = render_site_json(&repo, ¶ms).expect("site.json renders");
assert_eq!(bytes, again);
}
#[cfg(feature = "media-metadata")]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_metadata_single_flight_one_decode() {
use crate::video_metadata_cache::{CachedMetadata, VideoMetadataCache};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
let inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>> =
Arc::new(papaya::HashMap::new());
let cache = Arc::new(VideoMetadataCache::new(1024 * 1024));
let decodes = Arc::new(AtomicUsize::new(0));
let key = "videos/clip.mp4::cover::mtime=1".to_string();
let producer_notify = match claim_inflight(&inflight, &key) {
InflightClaim::Produce(notify) => notify,
InflightClaim::Wait(_) => panic!("first claim must produce"),
};
let mut handles = Vec::new();
for _ in 0..8 {
let inflight = Arc::clone(&inflight);
let cache = Arc::clone(&cache);
let decodes = Arc::clone(&decodes);
let key = key.clone();
handles.push(tokio::spawn(async move {
match claim_inflight(&inflight, &key) {
InflightClaim::Produce(_) => {
decodes.fetch_add(1, Ordering::SeqCst);
panic!("concurrent request unexpectedly started a decode");
}
InflightClaim::Wait(notify) => {
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if cache.get(&key).is_none() {
tokio::time::timeout(Duration::from_secs(5), notified)
.await
.expect("waiter timed out");
}
cache.get(&key).is_some()
}
}
}));
}
tokio::time::sleep(Duration::from_millis(50)).await;
decodes.fetch_add(1, Ordering::SeqCst);
cache.insert(key.clone(), CachedMetadata::Cover(vec![1, 2, 3, 4]));
inflight.pin().remove(&key);
producer_notify.notify_waiters();
for handle in handles {
assert!(handle.await.unwrap(), "waiter did not observe the result");
}
assert_eq!(
decodes.load(Ordering::SeqCst),
1,
"only the producer should have decoded"
);
}
#[tokio::test]
async fn test_inflight_slot_released_when_producer_future_dropped() {
let inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>> =
Arc::new(papaya::HashMap::new());
let key = "videos/clip.mp4::cover::mtime=1";
{
let inflight = Arc::clone(&inflight);
let producing = async {
let notify = match claim_inflight(&inflight, key) {
InflightClaim::Produce(notify) => notify,
InflightClaim::Wait(_) => panic!("first claim must produce"),
};
let _slot = InflightSlot::new(Arc::clone(&inflight), key.to_string(), notify);
std::future::pending::<()>().await;
};
assert!(
tokio::time::timeout(std::time::Duration::ZERO, producing)
.await
.is_err()
);
}
assert!(
inflight.pin().get(key).is_none(),
"cancelled producer must not leave its claim behind"
);
assert!(
matches!(claim_inflight(&inflight, key), InflightClaim::Produce(_)),
"next request must be able to produce, not wedge on a stale claim"
);
}
#[tokio::test]
async fn test_inflight_slot_wakes_waiters_when_dropped() {
let inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>> =
Arc::new(papaya::HashMap::new());
let key = "videos/clip.mp4::chapters::mtime=1";
let producer_notify = match claim_inflight(&inflight, key) {
InflightClaim::Produce(notify) => notify,
InflightClaim::Wait(_) => panic!("first claim must produce"),
};
let waiter_notify = match claim_inflight(&inflight, key) {
InflightClaim::Wait(notify) => notify,
InflightClaim::Produce(_) => panic!("second claim must wait"),
};
let notified = waiter_notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
drop(InflightSlot::new(
Arc::clone(&inflight),
key.to_string(),
producer_notify,
));
tokio::time::timeout(std::time::Duration::from_secs(5), notified)
.await
.expect("waiter must be woken when the producer releases the slot");
}
#[test]
fn test_claim_inflight_admits_one_producer_under_concurrent_claims() {
use std::sync::Barrier;
use std::sync::atomic::{AtomicUsize, Ordering};
const THREADS: usize = 8;
const KEYS: usize = 400;
let inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>> =
Arc::new(papaya::HashMap::new());
let barrier = Arc::new(Barrier::new(THREADS));
let producers = Arc::new(AtomicUsize::new(0));
std::thread::scope(|scope| {
for _ in 0..THREADS {
let inflight = Arc::clone(&inflight);
let barrier = Arc::clone(&barrier);
let producers = Arc::clone(&producers);
scope.spawn(move || {
for i in 0..KEYS {
let key = format!("key-{i}");
barrier.wait();
if let InflightClaim::Produce(_) = claim_inflight(&inflight, &key) {
producers.fetch_add(1, Ordering::Relaxed);
}
}
});
}
});
assert_eq!(
producers.load(Ordering::Relaxed),
KEYS,
"each key must be claimed by exactly one producer"
);
}
#[cfg(feature = "media-metadata")]
#[tokio::test]
async fn test_hls_generation_slot_released_when_future_dropped() {
use crate::video_transcode::TranscodeTarget;
use crate::video_transcode_cache::{HlsCacheKey, HlsCacheStartResult};
let cache = HlsCache::new(1024 * 1024);
let key = HlsCacheKey::segment(
PathBuf::from("/videos/clip.mp4"),
TranscodeTarget::Resolution720p,
3,
);
{
let key = key.clone();
let producing = async {
let notify = match cache.start_generation(key.clone()) {
HlsCacheStartResult::Started(notify) => notify,
_ => panic!("first start_generation must start"),
};
let _slot = HlsGenerationSlot::new(&cache, key, notify);
std::future::pending::<()>().await;
};
assert!(
tokio::time::timeout(std::time::Duration::ZERO, producing)
.await
.is_err()
);
}
assert!(
!matches!(
cache.start_generation(key),
HlsCacheStartResult::AlreadyInProgress(_)
),
"cancelled generation must not leave the key wedged in progress"
);
}
#[tokio::test]
async fn test_live_reload_action_keeps_forwarding_after_lag() {
use crate::watcher::{ChangeEventType, FileChangeEvent};
let event = |name: &str| FileChangeEvent {
path: format!("/repo/{name}"),
relative_path: name.to_string(),
event: ChangeEventType::Modified,
};
let (tx, mut rx) = broadcast::channel::<FileChangeEvent>(2);
for i in 0..5 {
tx.send(event(&format!("a{i}.md"))).unwrap();
}
assert!(
matches!(live_reload_action(rx.recv().await), LiveReloadAction::Skip),
"an overflowed receiver must report lag, not terminate"
);
assert!(matches!(
live_reload_action(rx.recv().await),
LiveReloadAction::Forward(_)
));
tx.send(event("after-lag.md")).unwrap();
while let LiveReloadAction::Forward(forwarded) = live_reload_action(rx.recv().await) {
if forwarded.relative_path == "after-lag.md" {
return;
}
}
panic!("event sent after the lag was never forwarded");
}
#[tokio::test]
async fn test_live_reload_action_closes_when_sender_dropped() {
let (tx, mut rx) = broadcast::channel::<crate::watcher::FileChangeEvent>(2);
drop(tx);
assert!(matches!(
live_reload_action(rx.recv().await),
LiveReloadAction::Close
));
}
#[test]
fn test_invalidate_derived_caches_clears_link_caches() {
use crate::link_index::{InboundLink, OutboundLink};
let listing_caches = ListingCaches {
sibling_nav_cache: Arc::new(papaya::HashMap::new()),
subdir_cache: Arc::new(papaya::HashMap::new()),
site_json_cache: Arc::new(parking_lot::RwLock::new(SiteJsonCache {
generation: 0,
body: Some(axum::body::Bytes::from_static(b"{\"markdown_files\":[]}")),
})),
};
listing_caches
.sibling_nav_cache
.pin()
.insert(PathBuf::from("docs"), Arc::new(vec![serde_json::json!({})]));
listing_caches
.subdir_cache
.pin()
.insert(PathBuf::new(), Arc::new(vec![serde_json::json!({})]));
let link_cache = LinkCache::new(1024 * 1024);
link_cache.insert(
"/docs/guide/".to_string(),
vec![OutboundLink {
to: "/docs/other/".to_string(),
text: "Other".to_string(),
anchor: None,
internal: true,
}],
);
let inbound_link_cache = InboundLinkCache::new(1024 * 1024, 300);
inbound_link_cache.insert(
"/docs/other/".to_string(),
vec![InboundLink {
from: "/docs/guide/".to_string(),
text: "Other".to_string(),
anchor: None,
}],
);
invalidate_derived_caches(&listing_caches, &link_cache, &inbound_link_cache);
assert!(listing_caches.sibling_nav_cache.pin().is_empty());
assert!(
listing_caches.subdir_cache.pin().is_empty(),
"directory subdirectory lists must be dropped on file change"
);
assert!(
listing_caches.site_json_cache.read().body.is_none(),
"the cached site.json body must be dropped on file change"
);
assert!(
link_cache.get("/docs/guide/").is_none(),
"outbound link cache must be dropped on file change"
);
assert!(
inbound_link_cache.get("/docs/other/").is_none(),
"inbound link cache must be dropped on file change"
);
}
#[test]
fn test_site_json_cache_rejects_store_from_a_raced_rebuild() {
let cache = parking_lot::RwLock::new(SiteJsonCache::default());
let (generation, cached) = cache.read().snapshot();
assert!(cached.is_none());
cache.write().invalidate();
cache
.write()
.store(generation, axum::body::Bytes::from_static(b"stale"));
assert!(
cache.read().body.is_none(),
"a body built before the invalidation must not be published"
);
let (generation, _) = cache.read().snapshot();
cache
.write()
.store(generation, axum::body::Bytes::from_static(b"fresh"));
assert_eq!(cache.read().body.as_deref(), Some(&b"fresh"[..]));
}
#[cfg(feature = "media-metadata")]
#[test]
fn test_media_cache_size_is_independent_of_oembed_cache_size() {
use crate::video_metadata_cache::{CachedMetadata, VideoMetadataCache};
let config = crate::config::Config {
oembed_cache_size: 0,
..Default::default()
};
let server_config = ServerConfig::from(&config);
assert_eq!(server_config.oembed_cache_size, 0);
assert_eq!(server_config.media_cache_size, 64 * 1024 * 1024);
let cache = VideoMetadataCache::new(server_config.media_cache_size);
let key = "videos/clip.mp4::cover::mtime=1".to_string();
cache.insert(key.clone(), CachedMetadata::Cover(vec![0u8; 256 * 1024]));
assert!(
cache.get(&key).is_some(),
"media caching must stay enabled when the oembed cache is disabled"
);
let cache = VideoMetadataCache::new(server_config.media_cache_size);
for i in 0..100 {
cache.insert(
format!("videos/clip-{i}.mp4::cover::mtime=1"),
CachedMetadata::Cover(vec![0u8; 256 * 1024]),
);
}
assert_eq!(
cache.len(),
100,
"100 covers of 256 KB must fit in the default media cache"
);
}
#[tokio::test]
async fn test_server_init_carries_gui_mode() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("index.md"), "# Test").unwrap();
for gui_mode in [true, false] {
let config = crate::config::Config {
root_dir: temp.path().to_path_buf(),
..Default::default()
};
let server = Server::init(ServerConfig::from(&config).with_gui_mode(gui_mode))
.expect("server init should succeed over a temp repo");
assert_eq!(
server.gui_mode, gui_mode,
"Server::init must carry gui_mode through to Server; \
announce_listening reads it to decide stdout vs tracing"
);
}
}
#[cfg(feature = "media-metadata")]
#[test]
fn test_metadata_response_from_cache_variants() {
use crate::video_metadata_cache::CachedMetadata;
let jpg = Server::metadata_response_from_cache(CachedMetadata::Cover(vec![0xFF, 0xD8]));
assert!(jpg.is_some());
assert_eq!(
jpg.unwrap().headers().get(header::CONTENT_TYPE).unwrap(),
"image/jpeg"
);
let vtt =
Server::metadata_response_from_cache(CachedMetadata::Captions("WEBVTT".to_string()));
assert!(vtt.is_some());
assert!(Server::metadata_response_from_cache(CachedMetadata::NotAvailable).is_none());
}
#[test]
fn test_resolve_new_target_rejects_parent_traversal() {
let dir = tempfile::TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
assert!(matches!(
resolve_new_target_path(&base, "../escape.md"),
Err(FileOpError::Traversal)
));
assert!(matches!(
resolve_new_target_path(&base, "docs/../../escape.md"),
Err(FileOpError::Traversal)
));
assert!(!base.join("../escape.md").exists());
}
#[test]
fn test_resolve_new_target_rejects_empty_or_root() {
let dir = tempfile::TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
assert!(matches!(
resolve_new_target_path(&base, ""),
Err(FileOpError::Traversal)
));
assert!(matches!(
resolve_new_target_path(&base, "/"),
Err(FileOpError::Traversal)
));
}
#[test]
fn test_resolve_new_target_accepts_valid_new_path() {
let dir = tempfile::TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
let resolved = resolve_new_target_path(&base, "docs/new.md").expect("valid path");
assert_eq!(resolved, base.join("docs/new.md"));
}
#[test]
fn test_resolve_new_target_normalizes_leading_slash() {
let dir = tempfile::TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
let resolved = resolve_new_target_path(&base, "/docs/new.md").expect("valid path");
assert_eq!(resolved, base.join("docs/new.md"));
}
fn md_exts() -> Vec<String> {
vec!["md".to_string(), "markdown".to_string()]
}
#[test]
fn test_sanitize_upload_name_basename_and_trim() {
assert_eq!(
sanitize_upload_name("image.png", &md_exts()).as_deref(),
Some("image.png")
);
assert_eq!(
sanitize_upload_name(" pic.jpeg ", &md_exts()).as_deref(),
Some("pic.jpeg")
);
assert_eq!(
sanitize_upload_name("clip.final.cut.mp4", &md_exts()).as_deref(),
Some("clip.final.cut.mp4")
);
}
#[test]
fn test_sanitize_upload_name_enforces_media_allowlist() {
for name in [
"index.html",
"mbr-components.min.js",
"theme.css",
"config.toml",
"evil.svg",
"archive.tar.gz",
"payload.sh",
] {
assert_eq!(
sanitize_upload_name(name, &md_exts()),
None,
"{name} must not be uploadable"
);
}
for name in ["pic.png", "PIC.PNG", "clip.mp4", "song.mp3", "doc.pdf"] {
assert_eq!(
sanitize_upload_name(name, &md_exts()).as_deref(),
Some(name),
"{name} must be uploadable"
);
}
}
#[test]
fn test_sanitize_upload_name_rejects_separators_and_traversal() {
assert_eq!(sanitize_upload_name("../secret.png", &md_exts()), None);
assert_eq!(sanitize_upload_name("notes/pic.png", &md_exts()), None);
assert_eq!(sanitize_upload_name("a\\b.png", &md_exts()), None);
assert_eq!(sanitize_upload_name("..", &md_exts()), None);
assert_eq!(sanitize_upload_name(".", &md_exts()), None);
assert_eq!(sanitize_upload_name("my..pic.png", &md_exts()), None);
}
#[test]
fn test_sanitize_upload_name_requires_stem_and_extension() {
assert_eq!(sanitize_upload_name("noext", &md_exts()), None);
assert_eq!(sanitize_upload_name(".png", &md_exts()), None);
assert_eq!(sanitize_upload_name("pic.", &md_exts()), None);
assert_eq!(sanitize_upload_name("", &md_exts()), None);
assert_eq!(sanitize_upload_name(" ", &md_exts()), None);
}
#[test]
fn test_sanitize_upload_name_rejects_markdown_extensions() {
assert_eq!(sanitize_upload_name("note.md", &md_exts()), None);
assert_eq!(sanitize_upload_name("note.markdown", &md_exts()), None);
assert_eq!(sanitize_upload_name("note.MD", &md_exts()), None);
assert_eq!(
sanitize_upload_name("note.pdf", &md_exts()).as_deref(),
Some("note.pdf")
);
}
fn host_headers(value: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(header::HOST, HeaderValue::from_str(value).unwrap());
headers
}
#[test]
fn test_host_header_hostname_strips_port_and_brackets() {
assert_eq!(host_header_hostname("127.0.0.1:5200"), "127.0.0.1");
assert_eq!(host_header_hostname("localhost:5200"), "localhost");
assert_eq!(host_header_hostname("localhost"), "localhost");
assert_eq!(host_header_hostname("[::1]:5200"), "::1");
assert_eq!(host_header_hostname("[::1]"), "::1");
assert_eq!(host_header_hostname("::1"), "::1");
assert_eq!(
host_header_hostname(" evil.example.com "),
"evil.example.com"
);
}
#[test]
fn test_host_header_is_allowed_accepts_local_names() {
let loopback = [127, 0, 0, 1];
for value in [
"localhost",
"LocalHost:5200",
"127.0.0.1:5200",
"127.0.0.53",
"[::1]:5200",
] {
assert!(
host_header_is_allowed(&host_headers(value), loopback),
"{value} should be an allowed Host"
);
}
assert!(host_header_is_allowed(
&host_headers("192.168.1.5:5200"),
[192, 168, 1, 5]
));
}
#[test]
fn test_host_header_is_allowed_rejects_rebinding_names() {
let loopback = [127, 0, 0, 1];
for value in [
"evil.example.com",
"evil.example.com:5200",
"localhost.evil.example.com",
"127.0.0.1.evil.example.com",
"192.168.1.5",
] {
assert!(
!host_header_is_allowed(&host_headers(value), loopback),
"{value} must be rejected"
);
}
assert!(!host_header_is_allowed(&HeaderMap::new(), loopback));
}
#[test]
fn test_is_servable_mbr_asset_allows_known_asset_types() {
for path in [
"/theme.css",
"/components/mbr-components.min.js",
"/components/mbr-graph.min.js.map",
"/favicon.png",
"/fonts/KaTeX_Main-Bold.woff2",
"/index.html",
] {
assert!(is_servable_mbr_asset(path), "{path} should be servable");
}
}
#[test]
fn test_is_servable_mbr_asset_rejects_config_and_dotfiles() {
for path in [
"/config.toml",
"/config.TOML",
"/.env",
"/secrets/.env.local",
"/notes.md",
"/id_rsa",
"/",
] {
assert!(!is_servable_mbr_asset(path), "{path} must not be servable");
}
}
#[test]
fn test_is_template_folder_path_blocks_mbr_and_template_folder() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().canonicalize().unwrap();
std::fs::create_dir_all(base.join(".mbr/components")).unwrap();
let template_folder = base.join("custom-templates");
std::fs::create_dir_all(&template_folder).unwrap();
for rel in [".mbr/index.html", ".mbr/components/mbr-components.min.js"] {
assert!(
is_template_folder_path(&base.join(rel), &base, Some(&base), None),
"{rel} must be blocked"
);
}
assert!(is_template_folder_path(
&template_folder.join("index.html"),
&base,
Some(&base),
Some(&template_folder)
));
assert!(!is_template_folder_path(
&base.join("notes/pic.png"),
&base,
Some(&base),
Some(&template_folder)
));
assert!(!is_template_folder_path(
&base.join(".mbrx/pic.png"),
&base,
Some(&base),
None
));
}
#[test]
fn test_is_within_served_roots_accepts_repo_and_static_overlay() {
let parent = tempfile::tempdir().unwrap();
let repo = parent.path().join("content");
let static_dir = parent.path().join("static");
std::fs::create_dir_all(&repo).unwrap();
std::fs::create_dir_all(&static_dir).unwrap();
let inside = repo.join("note.md");
std::fs::write(&inside, "# hi").unwrap();
let overlay = static_dir.join("logo.png");
std::fs::write(&overlay, "png").unwrap();
let canonical_repo = repo.canonicalize().unwrap();
assert!(is_within_served_roots(
&inside,
&repo,
Some(&canonical_repo),
"static"
));
assert!(is_within_served_roots(
&repo,
&repo,
Some(&canonical_repo),
""
));
assert!(is_within_served_roots(
&overlay,
&repo,
Some(&canonical_repo),
"../static"
));
}
#[test]
fn test_is_within_served_roots_rejects_symlink_escape() {
let parent = tempfile::tempdir().unwrap();
let repo = parent.path().join("content");
std::fs::create_dir_all(&repo).unwrap();
let outside = parent.path().join("secret.txt");
std::fs::write(&outside, "top secret").unwrap();
let canonical_repo = repo.canonicalize().unwrap();
let link = repo.join("secret.txt");
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, &link).unwrap();
#[cfg(windows)]
let _ = std::os::windows::fs::symlink_file(&outside, &link);
if link.exists() {
assert!(!is_within_served_roots(
&link,
&repo,
Some(&canonical_repo),
"static"
));
}
assert!(!is_within_served_roots(
&repo.join("missing.txt"),
&repo,
Some(&canonical_repo),
"static"
));
}
#[test]
fn test_dedupe_name_no_collision() {
let dir = Path::new("/repo/notes");
let chosen = dedupe_name(dir, "pic", "png", |_| false);
assert_eq!(chosen, dir.join("pic.png"));
}
#[test]
fn test_dedupe_name_first_collision() {
let dir = Path::new("/repo");
let taken: std::collections::HashSet<PathBuf> = [dir.join("a.txt")].into_iter().collect();
let chosen = dedupe_name(dir, "a", "txt", |p| taken.contains(p));
assert_eq!(chosen, dir.join("a-1.txt"));
}
#[test]
fn test_dedupe_name_suffix_sequence() {
let dir = Path::new("/repo/notes");
let taken: std::collections::HashSet<PathBuf> = [
dir.join("pic.png"),
dir.join("pic-1.png"),
dir.join("pic-2.png"),
]
.into_iter()
.collect();
let chosen = dedupe_name(dir, "pic", "png", |p| taken.contains(p));
assert_eq!(chosen, dir.join("pic-3.png"));
}
fn headers_with_content_type(value: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_str(value).unwrap());
headers
}
fn response_with_content_type(value: &str) -> Response<Body> {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, value)
.header(header::CONTENT_LENGTH, 1_000_000)
.body(Body::from(vec![0u8; 64]))
.unwrap()
}
#[test]
fn video_and_audio_content_types_are_incompressible() {
for content_type in [
"video/mp4",
"video/quicktime",
"video/mp4; charset=binary",
"audio/mpeg",
"application/pdf",
"application/zip",
"application/gzip",
"application/x-gzip",
"application/octet-stream",
] {
assert!(
is_incompressible_content_type(&headers_with_content_type(content_type)),
"{content_type} must bypass compression"
);
}
}
#[test]
fn text_content_types_stay_compressible() {
for content_type in [
"text/html; charset=utf-8",
"text/css",
"application/json",
"application/javascript",
"image/svg+xml",
] {
assert!(
!is_incompressible_content_type(&headers_with_content_type(content_type)),
"{content_type} should still be compressed"
);
}
}
#[test]
fn missing_content_type_stays_compressible() {
assert!(!is_incompressible_content_type(&HeaderMap::new()));
}
#[test]
fn compression_predicate_skips_media_and_keeps_defaults() {
use tower_http::compression::predicate::Predicate;
let predicate = compression_predicate();
assert!(!predicate.should_compress(&response_with_content_type("video/mp4")));
assert!(!predicate.should_compress(&response_with_content_type("audio/mpeg")));
assert!(!predicate.should_compress(&response_with_content_type("image/png")));
assert!(!predicate.should_compress(&response_with_content_type("text/event-stream")));
assert!(predicate.should_compress(&response_with_content_type("text/html")));
}
}
#[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");
}
}
}
}