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::errors::ServerError;
use crate::file_write::{FileWriteLocks, create_unique_temp_file};
use crate::link_grep::InboundLinkCache;
use mbr_core::config::{RelationType, SortField, TagSource};
use mbr_core::embedded_katex;
use mbr_core::embedded_pico;
use mbr_core::errors::{MbrError, TaskPatchError};
use mbr_core::link_index::{InboundIndex, LinkCache, resolve_outbound_links};
use mbr_core::link_transform::LinkTransformConfig;
use mbr_core::media::MediaViewerType;
use mbr_core::oembed_cache::OembedCache;
use mbr_core::page_context::{self, ModeFlags, PageChrome, UrlMode, markdown_file_to_json};
use mbr_core::path_resolver::{PathResolverConfig, ResolvedPath, resolve_request_path};
use mbr_core::repo::MarkdownInfo;
use mbr_core::search::{SearchEngine, SearchQuery, search_other_files};
use mbr_core::sorting::sort_files;
use mbr_core::task_query::IncludeFilter;
use mbr_core::templates;
use mbr_core::url_helpers::{generate_breadcrumbs, get_current_dir_name, get_parent_path};
use mbr_core::vault::{Availability, Vault, VaultError, VaultPath};
#[cfg(feature = "media-metadata")]
use mbr_core::video_metadata_cache::VideoMetadataCache;
#[cfg(feature = "media-metadata")]
use mbr_core::video_transcode_cache::HlsCache;
use mbr_core::{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<mbr_core::video_transcode_cache::HlsCacheKey>,
notify: Arc<tokio::sync::Notify>,
}
#[cfg(feature = "media-metadata")]
impl<'a> HlsGenerationSlot<'a> {
fn new(
cache: &'a HlsCache,
key: mbr_core::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,
&mbr_core::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();
}
fn owned_resolver_config(config: &ServerState) -> mbr_core::path_resolver::OwnedPathResolverConfig {
mbr_core::path_resolver::OwnedPathResolverConfig::for_repo(
&config.repo,
&config.static_folder,
&config.markdown_extensions,
&config.index_file,
mbr_core::config::tag_sources_to_url_sources(&config.tag_sources),
)
}
#[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(),
markdown_page_probe: None,
};
match markdown::extract_outbound_links_from_vault(
repo.vault().as_ref(),
path,
&cfg.base_dir,
link_transform_config,
true, cfg.valid_tag_sources.clone(),
Some(repo.wikilink_index.clone()),
) {
Ok(links) => {
let resolved: Vec<mbr_core::link_index::OutboundLink> = links
.into_iter()
.map(|mut link| {
if link.internal && !link.to.is_empty() {
link.to = mbr_core::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(),
markdown_page_probe: None,
},
);
}
link
})
.collect();
let resolved = resolve_outbound_links(url_path, resolved, true);
index.set_page_links(url_path, &resolved);
}
Err(e) => {
if is_not_downloaded_markdown(&e) {
tracing::debug!("Backlink index: {} not downloaded yet: {e}", path.display());
} else {
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(mbr_core::change_event::FileChangeEvent),
Skip,
Close,
}
fn live_reload_action(
result: Result<mbr_core::change_event::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, 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> {
validate_media_path_with_mounts(path, repo_root, static_folder, &[])
}
pub fn validate_media_path_with_mounts(
path: &str,
repo_root: &Path,
static_folder: &str,
mount_targets: &[PathBuf],
) -> 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('/');
if clean_path
.split(['/', '\\'])
.any(mbr_core::vault::is_hidden_segment)
{
return Err(MbrError::InvalidMediaPath(format!(
"Path does not exist: {}",
decoded
)));
}
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)
|| within_mount_targets(&canonical_path, mount_targets))
{
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)
|| within_mount_targets(&canonical_path, mount_targets))
{
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,
mount_targets: &[PathBuf],
) -> Option<PathBuf> {
if url_path
.split(['/', '\\'])
.any(mbr_core::vault::is_hidden_segment)
{
return None;
}
let in_mount = |candidate: PathBuf| {
let canonical = candidate.canonicalize().ok()?;
(within_mount_targets(&canonical, mount_targets) && canonical.is_file())
.then_some(canonical)
};
let direct = base_dir.join(url_path);
let static_dir = base_dir.join(static_folder);
let in_static = static_dir.join(url_path);
validate_path_containment(&direct, base_dir)
.or_else(|| validate_path_containment(&in_static, &static_dir))
.or_else(|| in_mount(direct))
.or_else(|| in_mount(in_static))
}
fn within_mount_targets(canonical: &Path, mount_targets: &[PathBuf]) -> bool {
mount_targets.iter().any(|target| {
canonical.strip_prefix(target).is_ok_and(|below| {
!below.components().any(|component| match component {
std::path::Component::Normal(name) => {
name.to_str().is_none_or(mbr_core::vault::is_hidden_segment)
}
_ => true,
})
})
})
}
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 on_request_failure(
class: tower_http::classify::ServerErrorsFailureClass,
latency: std::time::Duration,
span: &tracing::Span,
) {
let latency_ms = latency.as_millis();
match class {
tower_http::classify::ServerErrorsFailureClass::StatusCode(
StatusCode::SERVICE_UNAVAILABLE,
) => {
tracing::debug!(parent: span, latency_ms, "response asks for a retry (503)");
}
class => {
tracing::error!(parent: span, classification = %class, latency_ms, "response failed");
}
}
}
fn cloud_provider_name(path: &Path) -> Option<&'static str> {
path.components().find_map(|component| {
let name = component.as_os_str().to_str()?;
match name {
"Mobile Documents" => Some("iCloud"),
_ if name == "Dropbox" || name.starts_with("Dropbox-") => Some("Dropbox"),
_ if name.starts_with("GoogleDrive-") => Some("Google Drive"),
_ if name.starts_with("OneDrive-") => Some("OneDrive"),
_ if name.starts_with("Box-") => Some("Box"),
_ => None,
}
})
}
const NOT_DOWNLOADED_RETRY_SECS: &str = "2";
const MBR_ASSET_DOWNLOAD_WAIT: std::time::Duration = std::time::Duration::from_secs(3);
const MBR_ASSET_DOWNLOAD_POLL: std::time::Duration = std::time::Duration::from_millis(150);
fn add_retry_headers(headers: &mut HeaderMap) {
headers.insert(
header::RETRY_AFTER,
HeaderValue::from_static(NOT_DOWNLOADED_RETRY_SECS),
);
headers.insert(
axum::http::HeaderName::from_static("refresh"),
HeaderValue::from_static(NOT_DOWNLOADED_RETRY_SECS),
);
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(CACHE_CONTROL_NO_STORE),
);
}
fn not_downloaded_response(message: Option<&'static str>) -> Response<Body> {
let mut response = (
StatusCode::SERVICE_UNAVAILABLE,
message.unwrap_or("Not downloaded yet; retry shortly"),
)
.into_response();
add_retry_headers(response.headers_mut());
response
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EditReadError {
NotFound,
NotDownloaded,
Failed,
NotUtf8,
}
impl IntoResponse for EditReadError {
fn into_response(self) -> Response {
match self {
Self::NotFound => (StatusCode::NOT_FOUND, "File not found").into_response(),
Self::NotDownloaded => {
not_downloaded_response(Some("Note is still downloading; retry shortly"))
}
Self::Failed => (StatusCode::INTERNAL_SERVER_ERROR, "Read failed").into_response(),
Self::NotUtf8 => (StatusCode::BAD_REQUEST, "File is not valid UTF-8").into_response(),
}
}
}
fn is_not_downloaded(error: &MbrError) -> bool {
match error {
MbrError::Markdown(markdown) => is_not_downloaded_markdown(markdown),
_ => false,
}
}
fn is_not_downloaded_markdown(error: &mbr_core::errors::MarkdownError) -> bool {
match error {
mbr_core::errors::MarkdownError::ReadFailed { source, .. } => {
source.kind() == std::io::ErrorKind::WouldBlock
|| source
.get_ref()
.and_then(|inner| inner.downcast_ref::<VaultError>())
.is_some_and(|e| matches!(e, VaultError::NotDownloaded { .. }))
}
_ => false,
}
}
fn log_render_failure(context: &str, error: &dyn std::fmt::Display, not_downloaded: bool) {
if not_downloaded {
tracing::debug!("{context}: not downloaded yet: {error}");
} else {
tracing::error!("{context}: {error}");
}
}
fn is_within_served_roots(
path: &Path,
base_dir: &Path,
canonical_base_dir: Option<&Path>,
static_folder: &str,
mount_targets: impl FnOnce() -> Vec<PathBuf>,
) -> 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))
|| within_mount_targets(&canonical, &mount_targets())
}
pub struct Server {
pub router: Router,
pub port: u16,
pub ip: [u8; 4],
pub gui_mode: bool,
#[cfg(feature = "watcher")]
_watcher_handle: WatcherHandle,
repo: Arc<Repo>,
changes: ChangeApplier,
restored_files: Option<usize>,
templates: mbr_core::templates::Templates,
}
#[cfg(feature = "watcher")]
type WatcherHandle = Arc<std::sync::Mutex<Option<crate::watcher::FileWatcher>>>;
#[cfg(feature = "watcher")]
#[allow(clippy::too_many_arguments)]
fn spawn_file_watcher(
watcher_handle: WatcherHandle,
repo: Arc<Repo>,
base_dir: PathBuf,
template_folder: Option<PathBuf>,
ignore_dirs: Vec<String>,
ignore_globs: Vec<String>,
explicit_hidden_dirs: Vec<PathBuf>,
tx: broadcast::Sender<mbr_core::change_event::FileChangeEvent>,
) -> WatcherHandle {
let watcher_handle_for_thread = Arc::clone(&watcher_handle);
std::thread::spawn(move || {
match crate::watcher::FileWatcher::new_with_sender(
&base_dir,
template_folder.as_deref(),
&ignore_dirs,
&ignore_globs,
&explicit_hidden_dirs,
tx,
) {
Ok(watcher) => {
tracing::info!("File watcher initialized successfully (background)");
if let Ok(mut guard) = watcher_handle_for_thread.lock() {
watcher.watch_mounts(&repo.mounts());
*guard = Some(watcher);
}
}
Err(e) => {
tracing::warn!(
"Failed to initialize file watcher: {}. Live reload disabled.",
e
);
}
}
});
watcher_handle
}
#[cfg(feature = "watcher")]
fn sync_watched_mounts(watcher_handle: &WatcherHandle, repo: &Repo) {
if let Ok(guard) = watcher_handle.lock()
&& let Some(watcher) = guard.as_ref()
{
watcher.watch_mounts(&repo.mounts());
}
}
const SURGICAL_THRESHOLD: usize = 50;
const DEBOUNCE_DURATION: std::time::Duration = std::time::Duration::from_secs(2);
#[derive(Clone)]
struct ChangeApplier {
repo: Arc<Repo>,
base_dir: PathBuf,
markdown_extensions: Vec<String>,
listing_caches: ListingCaches,
link_cache: Arc<LinkCache>,
inbound_link_cache: Arc<InboundLinkCache>,
inbound_index: Arc<InboundIndex>,
link_index_config: LinkIndexConfig,
index_lock: Arc<tokio::sync::Mutex<()>>,
task_index: Arc<mbr_core::task_index::TaskIndex>,
#[cfg(feature = "watcher")]
watcher_handle: WatcherHandle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChangeSource {
Events,
Diff,
}
impl ChangeApplier {
async fn apply(
&self,
pending_events: Vec<mbr_core::change_event::FileChangeEvent>,
source: ChangeSource,
) {
let relevant_events: Vec<_> = pending_events
.into_iter()
.filter(|event| match event.event {
mbr_core::change_event::ChangeEventType::Created
| mbr_core::change_event::ChangeEventType::Deleted => true,
mbr_core::change_event::ChangeEventType::Modified => {
source == ChangeSource::Diff
|| self
.markdown_extensions
.iter()
.any(|ext| event.relative_path.ends_with(&format!(".{}", ext)))
}
})
.collect();
if relevant_events.is_empty() {
return;
}
let repo = Arc::clone(&self.repo);
let base_dir = self.base_dir.clone();
let inbound_index = Arc::clone(&self.inbound_index);
let link_index_cfg = self.link_index_config.clone();
let index_lock = Arc::clone(&self.index_lock);
let task_index = Arc::clone(&self.task_index);
#[cfg(feature = "watcher")]
let watcher_handle = Arc::clone(&self.watcher_handle);
if source == ChangeSource::Diff || 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,
mbr_core::change_event::ChangeEventType::Deleted
| mbr_core::change_event::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,
mbr_core::change_event::ChangeEventType::Deleted
),
));
repo.invalidate_file(&abs_path, &event.event);
task_index.invalidate_file(&abs_path, &event.event, &repo);
}
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 => mbr_core::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();
#[cfg(feature = "watcher")]
sync_watched_mounts(&watcher_handle, &repo);
if inbound_index.is_ready() {
populate_inbound_index(&repo, &inbound_index, &link_index_cfg);
}
task_index.rebuild_if_built(&repo);
let _ = base_dir; })
.await
.ok();
}
invalidate_derived_caches(
&self.listing_caches,
&self.link_cache,
&self.inbound_link_cache,
);
}
}
#[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 explicit_hidden_dirs: Vec<std::path::PathBuf>,
pub symlink_mounts: bool,
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 flashcards_concentric_threshold: f64,
pub flashcards_progress_indicators: bool,
pub title_prefix: String,
pub title_suffix: String,
pub mark_incomplete: bool,
pub incomplete_markers: Vec<String>,
pub tasks_enabled: bool,
pub review_enabled: bool,
pub tasks_stamp_done: bool,
pub tasks_default_include: IncludeFilter,
pub tasks_ignore_globs: 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,
pub embedded: Option<EmbeddedStorage>,
pub config_problem: Option<String>,
}
#[derive(Clone)]
pub struct EmbeddedStorage {
pub vault: Arc<dyn Vault>,
pub snapshot: Option<mbr_core::repo::RepoSnapshot>,
}
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<&mbr_core::config::Config> for ServerConfig {
fn from(config: &mbr_core::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(),
explicit_hidden_dirs: config.explicit_hidden_dirs.clone(),
symlink_mounts: !config.no_symlink_mounts,
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,
flashcards_concentric_threshold: config.flashcards_concentric_threshold,
flashcards_progress_indicators: config.flashcards_progress_indicators,
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(),
tasks_enabled: config.tasks_enabled,
review_enabled: config.review_enabled,
tasks_stamp_done: config.tasks_stamp_done,
tasks_default_include: config.tasks_default_include,
tasks_ignore_globs: config.tasks_ignore_globs.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,
embedded: None,
config_problem: None,
}
}
}
impl ServerState {
fn mount_targets(&self) -> Vec<PathBuf> {
self.repo
.mounts()
.into_iter()
.map(|(_, mount)| mount.target)
.collect()
}
fn refuse_read_only(&self, key: &Path) -> Result<(), FileOpError> {
let path = self.vault_path(key)?;
let vault = self.vault();
let canonical = path
.ancestors()
.find(|ancestor| matches!(vault.stat(ancestor), Ok(Some(_))))
.and_then(|existing| vault.canonicalize(&existing).ok());
if vault.is_read_only(&path) || canonical.is_some_and(|c| vault.is_read_only(&c)) {
return Err(FileOpError::ReadOnly);
}
Ok(())
}
fn is_served(&self, key: &Path) -> bool {
match self.repo.locate(key) {
Some((vault, path)) if vault.local_path(&path).is_none() => true,
located => {
is_within_served_roots(
key,
self.base_dir.as_path(),
self.canonical_base_dir.as_deref(),
&self.static_folder,
|| self.mount_targets(),
) || located.is_some_and(|(vault, path)| {
vault.availability(&path) == Availability::NotDownloaded
})
}
}
}
fn vault(&self) -> &dyn Vault {
self.repo.vault().as_ref()
}
fn vault_path(&self, key: &Path) -> Result<VaultPath, FileOpError> {
Ok(self.vault().vault_path(key)?)
}
fn key_exists(&self, key: &Path) -> bool {
self.vault_path(key)
.is_ok_and(|path| matches!(self.vault().stat(&path), Ok(Some(_))))
}
fn key_is_dir(&self, key: &Path) -> bool {
self.vault_path(key)
.is_ok_and(|path| self.vault().is_dir(&path))
}
fn resolver_config<'a>(&'a self, tag_url_sources: &'a [String]) -> PathResolverConfig<'a> {
PathResolverConfig {
vault: self.repo.vault().as_ref(),
static_vault: self.repo.static_vault().map(|vault| vault.as_ref()),
static_folder: &self.static_folder,
markdown_extensions: &self.markdown_extensions,
index_file: &self.index_file,
tag_sources: tag_url_sources,
exempt_hidden_dirs: self.repo.exempt_hidden_dirs(),
}
}
}
#[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: mbr_core::templates::Templates,
pub repo: Arc<Repo>,
pub oembed_timeout_ms: u64,
pub file_change_tx: Option<broadcast::Sender<mbr_core::change_event::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, mbr_core::video_transcode::VideoResolution>>,
#[cfg(feature = "media-metadata")]
pub media_compat_cache:
Arc<papaya::HashMap<String, mbr_core::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 flashcards_concentric_threshold: f64,
pub flashcards_progress_indicators: bool,
pub title_prefix: String,
pub title_suffix: String,
pub mark_incomplete: bool,
pub incomplete_markers: Vec<String>,
pub tasks_enabled: bool,
pub review_enabled: bool,
pub tasks_stamp_done: bool,
pub tasks_default_include: IncludeFilter,
pub task_index: Arc<mbr_core::task_index::TaskIndex>,
pub edit_enabled: bool,
pub edit_require_token_on_loopback: bool,
pub edit_token_hash: Option<String>,
pub upload_max_bytes: usize,
pub config_problem: Option<String>,
pub file_write_locks: Arc<FileWriteLocks>,
}
#[derive(serde::Deserialize)]
pub struct EditRequest {
pub content: String,
pub base_hash: String,
}
#[derive(serde::Deserialize)]
pub struct TaskToggleRequest {
pub path: String,
pub line: u32,
pub expected: String,
pub to: mbr_core::tasks::TaskStatus,
#[serde(default)]
pub base_hash: Option<String>,
}
#[derive(serde::Serialize)]
pub struct TaskToggleResponse {
pub line: u32,
pub text: String,
pub hash: String,
}
#[derive(serde::Deserialize)]
pub struct FlashcardReviewRequest {
pub path: String,
pub line: u32,
pub expected: String,
pub rating: mbr_core::flashcards::Rating,
pub at: String,
#[serde(default)]
pub base_hash: Option<String>,
}
#[derive(serde::Serialize)]
pub struct FlashcardReviewResponse {
pub entry: String,
pub line: u32,
pub inserted_at: u32,
pub inserted: Vec<String>,
pub 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,
ReadOnly,
Io(std::io::Error),
}
impl From<VaultError> for FileOpError {
fn from(error: VaultError) -> Self {
match error {
VaultError::OutsideRoot { .. } | VaultError::InvalidPath(_) => Self::Traversal,
VaultError::ReadOnly { .. } => Self::ReadOnly,
other => Self::Io(other.into()),
}
}
}
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::ReadOnly => (
StatusCode::FORBIDDEN,
"This folder is read-only: it is an external folder mounted through a symlink",
),
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(vault: &dyn Vault, rel: &str) -> Result<VaultPath, 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),
}
}
if clean.split(['/', '\\']).any(|segment| segment == "..") {
return Err(FileOpError::Traversal);
}
let candidate = VaultPath::new(clean).map_err(|_| FileOpError::Traversal)?;
let existing = candidate
.ancestors()
.find(|ancestor| matches!(vault.stat(ancestor), Ok(Some(_))))
.unwrap_or_default();
vault.canonicalize(&existing)?;
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 mbr_core::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 mbr_core::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,
explicit_hidden_dirs,
symlink_mounts,
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,
flashcards_concentric_threshold,
flashcards_progress_indicators,
title_prefix,
title_suffix,
mark_incomplete,
incomplete_markers,
tasks_enabled,
review_enabled,
tasks_stamp_done,
tasks_default_include,
tasks_ignore_globs,
edit_enabled,
edit_require_token_on_loopback,
edit_token_hash,
upload_max_bytes,
#[cfg(feature = "media-metadata")]
transcode_enabled,
embedded,
config_problem,
} = 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, mbr_core::video_transcode::VideoResolution>,
> = Arc::new(papaya::HashMap::new());
#[cfg(feature = "media-metadata")]
let media_compat_cache: Arc<
papaya::HashMap<String, mbr_core::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 is_embedded = embedded.is_some();
let (repo, snapshot) = match embedded {
None => (
Repo::init(
&base_dir,
&static_folder,
&markdown_extensions,
&ignore_dirs,
&ignore_globs,
&index_file,
&tag_sources,
&relationship_types,
)
.with_explicit_hidden_dirs(&explicit_hidden_dirs)
.with_symlink_mounts(symlink_mounts),
None,
),
Some(EmbeddedStorage { vault, snapshot }) => (
Repo::init_with_vault(
vault,
static_folder.as_str(),
&markdown_extensions,
&ignore_dirs,
&ignore_globs,
index_file.as_str(),
&tag_sources,
&relationship_types,
)
.with_explicit_hidden_dirs(&explicit_hidden_dirs)
.with_change_tracking(),
snapshot,
),
};
let repo = Arc::new(repo);
let restored_files = snapshot.and_then(|snapshot| match repo.restore_snapshot(snapshot) {
Ok(count) => {
tracing::info!("Restored {count} files from the saved index");
Some(count)
}
Err(e) => {
tracing::info!("Saved index not used ({e}); scanning");
None
}
});
#[cfg(feature = "watcher")]
let watcher_handle: WatcherHandle = Arc::new(std::sync::Mutex::new(None));
#[cfg(feature = "watcher")]
let watcher_handle_for_scan = Arc::clone(&watcher_handle);
let repo_for_scan = Arc::clone(&repo);
let restored = restored_files.is_some();
tokio::task::spawn_blocking(move || {
if !restored && 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 !restored && let Err(e) = repo_for_scan.scan_static_folder() {
tracing::error!("Background static scan failed: {e}");
}
#[cfg(feature = "watcher")]
sync_watched_mounts(&watcher_handle_for_scan, &repo_for_scan);
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::<
mbr_core::change_event::FileChangeEvent,
>(mbr_core::change_event::BROADCAST_CAPACITY);
#[cfg(feature = "watcher")]
let watcher_handle = if is_embedded {
watcher_handle
} else {
spawn_file_watcher(
watcher_handle,
Arc::clone(&repo),
base_dir.clone(),
template_folder.clone(),
watcher_ignore_dirs,
ignore_globs.clone(),
explicit_hidden_dirs.clone(),
file_change_tx.clone(),
)
};
#[cfg(not(feature = "watcher"))]
let _ = is_embedded;
#[cfg(not(feature = "watcher"))]
let _ = watcher_ignore_dirs;
let templates_for_reload = templates.clone();
let templates_for_server = 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);
reload_templates(&templates_for_reload);
}
}
});
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: mbr_core::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 task_index = Arc::new(mbr_core::task_index::TaskIndex::with_markers(
&tasks_ignore_globs,
if mark_incomplete {
&incomplete_markers
} else {
&[]
},
));
let change_applier = ChangeApplier {
repo: Arc::clone(&repo),
base_dir: base_dir.clone(),
markdown_extensions: markdown_extensions.clone(),
listing_caches: listing_caches.clone(),
link_cache: Arc::clone(&link_cache),
inbound_link_cache: Arc::clone(&inbound_link_cache),
inbound_index: Arc::clone(&inbound_index),
link_index_config: link_index_config.clone(),
index_lock: Arc::clone(&index_lock),
task_index: Arc::clone(&task_index),
#[cfg(feature = "watcher")]
watcher_handle: Arc::clone(&watcher_handle),
};
let applier_for_task = change_applier.clone();
let mut repo_change_rx = file_change_tx.subscribe();
tokio::spawn(async move {
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(mbr_core::change_event::FileChangeEvent {
path: String::new(),
relative_path: String::new(),
event: mbr_core::change_event::ChangeEventType::Created,
});
for _ in 0..SURGICAL_THRESHOLD {
pending_events.push(mbr_core::change_event::FileChangeEvent {
path: String::new(),
relative_path: String::new(),
event: mbr_core::change_event::ChangeEventType::Created,
});
}
break;
}
}
}
_ = tokio::time::sleep_until(deadline) => break,
}
}
applier_for_task
.apply(pending_events, ChangeSource::Events)
.await;
}
});
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 repo_for_server = Arc::clone(&repo);
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,
flashcards_concentric_threshold,
flashcards_progress_indicators,
title_prefix,
title_suffix,
mark_incomplete,
incomplete_markers,
tasks_enabled,
review_enabled,
tasks_stamp_done,
tasks_default_include,
task_index,
file_write_locks: Arc::new(FileWriteLocks::default()),
edit_enabled,
edit_require_token_on_loopback,
edit_token_hash,
upload_max_bytes,
config_problem,
};
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/tasks", post(Self::tasks_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/task", post(Self::task_toggle_handler))
.route(
"/.mbr/flashcard-review",
post(Self::flashcard_review_handler),
)
.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().on_failure(on_request_failure))
.with_state(state);
Ok(Server {
router,
ip,
port,
gui_mode,
#[cfg(feature = "watcher")]
_watcher_handle: watcher_handle,
repo: repo_for_server,
changes: change_applier,
restored_files,
templates: templates_for_server,
})
}
pub fn reload_templates(&self) -> bool {
reload_templates(&self.templates)
}
pub fn repo(&self) -> &Arc<Repo> {
&self.repo
}
pub fn restored_files(&self) -> Option<usize> {
self.restored_files
}
pub async fn refresh(&self) -> mbr_core::repo::RepoDiff {
let repo = Arc::clone(&self.repo);
let diff = tokio::task::spawn_blocking(move || repo.diff_vault())
.await
.unwrap_or_default();
if !diff.is_empty() {
let root = self.repo.vault().root().to_path_buf();
self.changes
.apply(diff.events(&root), ChangeSource::Diff)
.await;
}
self.repo.record_diff(&diff);
diff
}
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 query_str = query.q.clone();
let search_result = tokio::task::spawn_blocking(move || {
let engine = SearchEngine::new(repo.clone());
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::<_, mbr_core::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
})),
)
}
}
}
pub async fn tasks_handler(
State(config): State<ServerState>,
Json(query): Json<mbr_core::task_query::TaskQuery>,
) -> Response<Body> {
if !config.tasks_enabled {
return Self::tasks_error(StatusCode::NOT_FOUND, "Task browsing is disabled");
}
let scan_in_progress = !config.repo.is_scan_complete();
if let Err(e) = config.task_index.ensure_built(&config.repo).await {
tracing::error!("Task index build failed: {e}");
return Self::tasks_error(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to build the task index",
);
}
let files = config.task_index.snapshot();
let today = chrono::Local::now().date_naive();
let result = tokio::task::spawn_blocking(move || {
mbr_core::task_query::run_query(&files, &query, today)
})
.await;
let mut response = match result {
Ok(response) => response,
Err(e) => {
tracing::error!("Task query task panicked: {e}");
return Self::tasks_error(
StatusCode::INTERNAL_SERVER_ERROR,
"Task query task failed",
);
}
};
response.scan_in_progress = scan_in_progress;
tracing::debug!(
"Task query completed: {} matches in {} group(s), {}ms",
response.total_matches,
response.groups.len(),
response.duration_ms
);
match serde_json::to_vec(&response) {
Ok(body) => build_response_or_500(
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(body)),
),
Err(e) => {
tracing::error!("Failed to serialize task response: {e}");
Self::tasks_error(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to serialize task response",
)
}
}
}
fn tasks_error(status: StatusCode, message: &str) -> Response<Body> {
let body = serde_json::json!({
"error": message,
"groups": [],
"folders": [],
"total_matches": 0,
"duration_ms": 0,
"scan_in_progress": false,
});
build_response_or_500(
Response::builder()
.status(status)
.header("Content-Type", "application/json")
.body(Body::from(body.to_string())),
)
}
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 !Self::host_may_edit(config, headers) {
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)) => mbr_core::edit_auth::verify_token(hash, token),
_ => false,
};
if !ok {
return Err((StatusCode::UNAUTHORIZED, "A valid edit token is required"));
}
}
Ok(())
}
fn host_may_edit(config: &ServerState, headers: &HeaderMap) -> bool {
config.edit_token_hash.is_some() || host_header_is_allowed(headers, config.bind_ip)
}
fn page_edit_enabled(config: &ServerState, headers: &HeaderMap) -> bool {
config.edit_enabled && Self::host_may_edit(config, headers)
}
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 = mbr_core::config::tag_sources_to_url_sources(&config.tag_sources);
let resolver_config = config.resolver_config(&tag_url_sources);
match resolve_request_path(&resolver_config, path) {
ResolvedPath::MarkdownFile(md_path) => {
let vault = config.vault();
match vault
.vault_path(&md_path)
.and_then(|path| vault.canonicalize(&path))
{
Ok(canonical) if vault.is_file(&canonical) => Ok(vault.key(&canonical)),
_ => Err((StatusCode::BAD_REQUEST, "Invalid path")),
}
}
_ => Err((StatusCode::NOT_FOUND, "Not an editable markdown file")),
}
}
async fn resolve_editable_markdown_settled(
config: &ServerState,
path: &str,
) -> Result<PathBuf, (StatusCode, &'static str)> {
const RETRIES: usize = 3;
const PAUSE: std::time::Duration = std::time::Duration::from_millis(20);
let mut result = Self::resolve_editable_markdown(config, path);
for _ in 0..RETRIES {
if result.is_ok() {
break;
}
tokio::time::sleep(PAUSE).await;
result = Self::resolve_editable_markdown(config, path);
}
result
}
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_settled(&config, &path).await {
Ok(p) => p,
Err(err) => return err.into_response(),
};
match Self::read_editable_bytes(&config, &md_path, "editing").await {
Ok(bytes) => {
let hash = mbr_core::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(error) => error.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_settled(&config, &path).await {
Ok(p) => p,
Err(err) => return err.into_response(),
};
if let Err(err) = config.refuse_read_only(&md_path) {
return err.into_response();
}
let _write_guard = config.file_write_locks.lock(&md_path).await;
let current = match Self::read_editable_bytes(&config, &md_path, "save").await {
Ok(bytes) => bytes,
Err(error) => return error.into_response(),
};
if let Some(conflict) = Self::base_hash_conflict(¤t, Some(&req.base_hash)) {
return conflict;
}
let new_bytes = req.content.into_bytes();
let new_hash = mbr_core::edit_auth::content_hash(&new_bytes);
match Self::atomic_write_file_async(&config, &md_path, new_bytes).await {
Ok(()) => {}
Err(e @ FileOpError::ReadOnly) => return e.into_response(),
Err(e) => {
tracing::error!("Failed to save markdown: {e:?}");
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(mbr_core::change_event::FileChangeEvent {
path: md_path.to_string_lossy().to_string(),
relative_path: relative.to_string_lossy().to_string(),
event: mbr_core::change_event::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
}
pub async fn task_toggle_handler(
State(config): State<ServerState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<TaskToggleRequest>,
) -> 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_settled(&config, &req.path).await {
Ok(p) => p,
Err(err) => return err.into_response(),
};
if let Err(err) = config.refuse_read_only(&md_path) {
return err.into_response();
}
let _write_guard = config.file_write_locks.lock(&md_path).await;
let source = match Self::read_markdown_source(&config, &md_path, "task toggle").await {
Ok(source) => source,
Err(error) => return error.into_response(),
};
if let Some(conflict) =
Self::base_hash_conflict(source.as_bytes(), req.base_hash.as_deref())
{
return conflict;
}
let stamp = config
.tasks_stamp_done
.then(|| chrono::Local::now().naive_local());
let patched =
match mbr_core::tasks::patch_task_line(&source, req.line, &req.expected, req.to, stamp)
{
Ok(patched) => patched,
Err(e) => {
let status = match e {
TaskPatchError::Mismatch { .. } => StatusCode::CONFLICT,
TaskPatchError::LineOutOfRange { .. } | TaskPatchError::NotATask { .. } => {
StatusCode::BAD_REQUEST
}
};
return (status, e.to_string()).into_response();
}
};
let hash = mbr_core::edit_auth::content_hash(patched.source.as_bytes());
if let Err(e) =
Self::atomic_write_file_async(&config, &md_path, patched.source.into_bytes()).await
{
tracing::error!("Failed to write task toggle: {e:?}");
return e.into_response();
}
Self::announce_in_place_edit(&config, &md_path);
Json(TaskToggleResponse {
line: req.line,
text: patched.text,
hash,
})
.into_response()
}
fn base_hash_conflict(current: &[u8], base_hash: Option<&str>) -> Option<Response> {
let base_hash = base_hash?;
(mbr_core::edit_auth::content_hash(current) != base_hash).then(|| {
(
StatusCode::CONFLICT,
"File changed on disk since it was loaded",
)
.into_response()
})
}
async fn read_markdown_source(
config: &ServerState,
md_path: &Path,
action: &str,
) -> Result<String, EditReadError> {
let bytes = Self::read_editable_bytes(config, md_path, action).await?;
String::from_utf8(bytes).map_err(|_| EditReadError::NotUtf8)
}
fn announce_in_place_edit(config: &ServerState, md_path: &Path) {
Self::broadcast_change(
config,
md_path,
mbr_core::change_event::ChangeEventType::Modified,
);
config.task_index.invalidate_file(
md_path,
&mbr_core::change_event::ChangeEventType::Modified,
&config.repo,
);
}
pub async fn flashcard_review_handler(
State(config): State<ServerState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<FlashcardReviewRequest>,
) -> 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_settled(&config, &req.path).await {
Ok(p) => p,
Err(err) => return err.into_response(),
};
if let Err(err) = config.refuse_read_only(&md_path) {
return err.into_response();
}
let reviewed_at = match mbr_core::flashcards::parse_review_time(
&req.at,
chrono::Utc::now().naive_utc(),
) {
Ok(time) => time,
Err(e) => return (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()).into_response(),
};
let _write_guard = config.file_write_locks.lock(&md_path).await;
let source = match Self::read_markdown_source(&config, &md_path, "flashcard review").await {
Ok(source) => source,
Err(error) => return error.into_response(),
};
if let Some(conflict) =
Self::base_hash_conflict(source.as_bytes(), req.base_hash.as_deref())
{
return conflict;
}
let patch = match mbr_core::flashcards::append_review(
&source,
req.line,
&req.expected,
req.rating,
reviewed_at,
) {
Ok(patch) => patch,
Err(e) => return (StatusCode::CONFLICT, e.to_string()).into_response(),
};
let entry_line = patch.entry_line();
let hash = mbr_core::edit_auth::content_hash(patch.source.as_bytes());
if let Err(e) =
Self::atomic_write_file_async(&config, &md_path, patch.source.into_bytes()).await
{
tracing::error!("Failed to write flashcard review: {e:?}");
return e.into_response();
}
Self::announce_in_place_edit(&config, &md_path);
Json(FlashcardReviewResponse {
line: entry_line,
entry: patch.entry,
inserted_at: patch.inserted_at,
inserted: patch.inserted,
hash,
})
.into_response()
}
fn resolve_new_target(config: &ServerState, rel: &str) -> Result<PathBuf, FileOpError> {
let vault = config.vault();
resolve_new_target_path(vault, rel).map(|path| vault.key(&path))
}
fn path_has_markdown_extension(path: &Path, exts: &[String]) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| mbr_core::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());
mbr_core::url_path::path_to_url(&relative)
}
fn atomic_write_file(
config: &ServerState,
path: &Path,
bytes: &[u8],
) -> Result<(), FileOpError> {
let target = config.vault_path(path)?;
Ok(config.vault().write_atomic(&target, bytes)?)
}
async fn atomic_write_file_async(
config: &ServerState,
path: &Path,
bytes: Vec<u8>,
) -> Result<(), FileOpError> {
let vault = Arc::clone(config.repo.vault());
let target = config.vault_path(path)?;
tokio::task::spawn_blocking(move || vault.write_atomic(&target, &bytes))
.await
.map_err(|e| FileOpError::Io(std::io::Error::other(e)))?
.map_err(FileOpError::from)
}
async fn read_editable_bytes(
config: &ServerState,
path: &Path,
action: &str,
) -> Result<Vec<u8>, EditReadError> {
let vault = Arc::clone(config.repo.vault());
let source = vault.vault_path(path).map_err(|e| {
tracing::error!("Failed to read markdown for {action}: {e}");
EditReadError::NotFound
})?;
let deadline = Instant::now() + MBR_ASSET_DOWNLOAD_WAIT;
let mut requested = false;
loop {
if vault.availability(&source) != Availability::NotDownloaded {
let (reader, target) = (Arc::clone(&vault), source.clone());
match tokio::task::spawn_blocking(move || reader.read(&target)).await {
Ok(Ok(bytes)) => return Ok(bytes),
Ok(Err(VaultError::NotDownloaded { .. })) => {}
Ok(Err(e)) => {
tracing::error!("Failed to read markdown for {action}: {e}");
return Err(EditReadError::NotFound);
}
Err(e) => {
tracing::error!("Failed to read markdown for {action}: {e}");
return Err(EditReadError::Failed);
}
}
}
if !requested {
requested = true;
vault.request_download(std::slice::from_ref(&source));
continue; }
if Instant::now() >= deadline {
tracing::debug!(
"{source} not downloaded after {}ms for {action}; asking for a retry",
MBR_ASSET_DOWNLOAD_WAIT.as_millis()
);
return Err(EditReadError::NotDownloaded);
}
tokio::time::sleep(MBR_ASSET_DOWNLOAD_POLL).await;
}
}
fn broadcast_change(
config: &ServerState,
abs_path: &Path,
event: mbr_core::change_event::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(mbr_core::change_event::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)?;
config.refuse_read_only(&dst)?;
if !Self::path_has_markdown_extension(&dst, &config.markdown_extensions) {
return Err(FileOpError::NotMarkdown);
}
if config.key_exists(&dst) {
return Err(FileOpError::AlreadyExists);
}
let parent = dst.parent().unwrap_or_else(|| Path::new("."));
if !config.key_exists(parent) {
if req.create_dirs {
config.vault().create_dir_all(&config.vault_path(parent)?)?;
} else {
return Err(FileOpError::ParentMissing);
}
}
Self::atomic_write_file(config, &dst, req.content.as_bytes())?;
let url_path =
mbr_core::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, &mbr_core::change_event::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,
mbr_core::change_event::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)?;
config.refuse_read_only(&target)?;
let rel_path = Self::rel_path_string(&target, &config.base_dir);
if config.key_is_dir(&target) {
return Ok(MkdirResponse { path: rel_path });
}
if config.key_exists(&target) {
return Err(FileOpError::AlreadyExists);
}
config
.vault()
.create_dir_all(&config.vault_path(&target)?)?;
Self::broadcast_change(
config,
&target,
mbr_core::change_event::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)?;
config.refuse_read_only(&target)?;
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 !config.key_exists(&dest_dir) {
config
.vault()
.create_dir_all(&config.vault_path(&dest_dir)?)?;
}
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| config.key_exists(p));
Self::atomic_write_file(config, &final_path, bytes)?;
let root_abs = mbr_core::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,
mbr_core::change_event::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
}
})?;
config.refuse_read_only(&src)?;
let dst = Self::resolve_new_target(config, &req.to)?;
config.refuse_read_only(&dst)?;
if !Self::path_has_markdown_extension(&dst, &config.markdown_extensions) {
return Err(FileOpError::NotMarkdown);
}
let vault = config.vault();
let src_path = config.vault_path(&src)?;
let dst_path = config.vault_path(&dst)?;
let dst_exists = config.key_exists(&dst);
let case_only =
dst_exists && vault.canonicalize(&dst_path).ok().as_ref() == Some(&src_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 !config.key_exists(parent) {
if req.create_dirs {
vault.create_dir_all(&config.vault_path(parent)?)?;
created_dirs = true;
} else {
return Err(FileOpError::ParentMissing);
}
}
let old_url =
mbr_core::repo::build_markdown_url_path(&src, &config.base_dir, &config.index_file);
let new_url =
mbr_core::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 move_guards = config
.file_write_locks
.lock_all_blocking(&[src.as_path(), dst.as_path()]);
let src_meta = mbr_core::markdown::extract_metadata_from_vault(vault, &src_path).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 = vault.read_to_string(&src_path)?;
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,
);
match (vault.local_path(&src_path), vault.local_path(&dst_path)) {
(Some(local_src), Some(local_dst)) => {
Self::replace_moved_file_locally(
&local_src,
&local_dst,
&moved_content,
case_only,
)?;
}
_ => {
if case_only {
vault.remove_file(&src_path)?;
vault.write_atomic(&dst_path, moved_content.as_bytes())?;
} else {
vault.write_atomic(&dst_path, moved_content.as_bytes())?;
vault.remove_file(&src_path)?;
}
}
}
drop(move_guards);
let mut skip: HashSet<PathBuf> = HashSet::new();
skip.insert(dst.clone());
if let Ok(c) = vault.canonicalize(&dst_path) {
skip.insert(vault.key(&c));
}
let rewritten_paths = crate::link_rewrite::rewrite_inbound_links_for_move(
&old_url,
&new_url,
vault,
&config.markdown_extensions,
&config.ignore_dirs,
&config.ignore_globs,
&skip,
Some(&config.file_write_locks),
)
.map_err(FileOpError::Io)?;
let wiki_paths = crate::link_rewrite::rewrite_bare_wikilinks_for_rename(
&delta,
&old_url,
vault,
&config.markdown_extensions,
&config.ignore_dirs,
&config.ignore_globs,
&config.index_file,
&config.repo.wikilink_index,
&skip,
Some(&config.file_write_locks),
)
.map_err(FileOpError::Io)?;
config
.repo
.invalidate_file(&src, &mbr_core::change_event::ChangeEventType::Deleted);
config
.repo
.invalidate_file(&dst, &mbr_core::change_event::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, &mbr_core::change_event::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,
mbr_core::change_event::ChangeEventType::Deleted,
);
Self::broadcast_change(
config,
&dst,
mbr_core::change_event::ChangeEventType::Created,
);
for p in &changed_union {
Self::broadcast_change(config, p, mbr_core::change_event::ChangeEventType::Modified);
}
let to_urls = |paths: &[PathBuf]| -> Vec<String> {
paths
.iter()
.map(|p| {
mbr_core::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 replace_moved_file_locally(
src: &Path,
dst: &Path,
moved_content: &str,
case_only: bool,
) -> Result<(), FileOpError> {
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, mut tmp_file) =
create_unique_temp_file(parent_dir, dst_name).map_err(FileOpError::Io)?;
let written = std::io::Write::write_all(&mut tmp_file, moved_content.as_bytes())
.and_then(|()| std::fs::metadata(src))
.and_then(|meta| tmp_file.set_permissions(meta.permissions()));
drop(tmp_file);
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
return Err(FileOpError::Io(e));
}
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));
}
Ok(())
}
fn wikilink_delta_names(
frontmatter: Option<&mbr_core::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
.map(mbr_core::contact::alias_names_in)
.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(mbr_core::relationships::normalize_name(new_stem));
new_names.insert(mbr_core::relationships::normalize_name(&new_title));
for a in &aliases {
new_names.insert(mbr_core::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 = mbr_core::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,
config.tasks_enabled,
config.tasks_default_include,
);
}
};
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,
config.tasks_enabled,
config.tasks_default_include,
);
}
};
let validated_path = match validate_media_path_with_mounts(
media_path,
&config.base_dir,
&config.static_folder,
&config.mount_targets(),
) {
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,
config.tasks_enabled,
config.tasks_default_include,
);
}
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,
config.tasks_enabled,
config.tasks_default_include,
);
}
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,
config.tasks_enabled,
config.tasks_default_include,
);
}
};
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,
tasks_enabled: config.tasks_enabled,
review_enabled: config.review_enabled,
tasks_default_include: config.tasks_default_include,
title_affixes: Some((&config.title_prefix, &config.title_suffix)),
root_dir: Some(&config.base_dir),
},
);
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,
config.tasks_enabled,
config.tasks_default_include,
)
}
}
}
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_mbr_file(&config, &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_mbr_file(&config, &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_mbr_file(
config: &ServerState,
path: &Path,
) -> Result<Response<Body>, StatusCode> {
let vault = config.repo.vault();
let in_vault = vault.vault_path(path).ok();
let deadline = Instant::now() + MBR_ASSET_DOWNLOAD_WAIT;
let mut requested = false;
loop {
let pending = in_vault
.as_ref()
.is_some_and(|p| vault.availability(p) == Availability::NotDownloaded);
if !pending {
match tokio::fs::read(path).await {
Ok(bytes) => return Self::file_response(path, bytes).await,
Err(e) => {
let error = VaultError::from_io(path.display(), e);
if !matches!(error, VaultError::NotDownloaded { .. }) {
tracing::error!("Failed to read file {}: {error}", path.display());
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
}
}
if !requested {
requested = true;
if let Some(vault_path) = &in_vault {
vault.request_download(std::slice::from_ref(vault_path));
continue; }
}
if Instant::now() >= deadline {
tracing::debug!(
"{} not downloaded after {}ms; asking for a retry",
path.display(),
MBR_ASSET_DOWNLOAD_WAIT.as_millis()
);
return Ok(not_downloaded_response(None));
}
tokio::time::sleep(MBR_ASSET_DOWNLOAD_POLL).await;
}
}
async fn file_response(path: &Path, bytes: Vec<u8>) -> Result<Response<Body>, StatusCode> {
let mime = Self::guess_mime_type(path);
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,
tasks_enabled: bool,
tasks_default_include: IncludeFilter,
) -> Response<Body> {
Self::render_error_page_with(
templates,
status_code,
error_title,
error_message,
requested_url,
gui_mode,
sidebar_style,
sidebar_max_items,
graph_depth,
tasks_enabled,
tasks_default_include,
Vec::new(),
)
}
#[allow(clippy::too_many_arguments)]
fn render_error_page_with(
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,
tasks_enabled: bool,
tasks_default_include: IncludeFilter,
extra: Vec<(&'static str, serde_json::Value)>,
) -> 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,
tasks_enabled,
review_enabled: false,
tasks_default_include,
title_affixes: None,
root_dir: None,
},
);
context.extend(
extra
.into_iter()
.map(|(key, value)| (key.to_string(), value)),
);
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 = mbr_core::assets::default_file(path)
.or_else(|| {
embedded_katex::KATEX_FILES
.iter()
.find(|(name, _, _)| path == *name)
.map(|(_, bytes, mime)| (*bytes, *mime))
});
if let Some((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 = mbr_core::config::tag_sources_to_url_sources(&config.tag_sources);
let resolver_config = config.resolver_config(&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 !config.is_served(&resolved_path) =>
{
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);
let Some((vault, vault_path)) = config.repo.locate_shared(&file_path) else {
return Err(StatusCode::NOT_FOUND);
};
if vault.availability(&vault_path) == Availability::NotDownloaded {
vault.request_download(std::slice::from_ref(&vault_path));
return Ok(not_downloaded_response(None));
}
match vault.local_path(&vault_path) {
Some(local) => Self::serve_static_file(local, req).await,
None => Self::serve_vault_file(vault, vault_path, req).await,
}
}
ResolvedPath::MarkdownFile(md_path) => {
if let Some(canonical) = mbr_core::path_resolver::canonical_page_redirect(
&path,
&mbr_core::repo::build_markdown_url_path(
&md_path,
config
.canonical_base_dir
.as_deref()
.unwrap_or(config.base_dir.as_path()),
&config.index_file,
),
) {
return Ok(canonical_redirect_response(&canonical, req.uri().query()));
}
tracing::debug!("rendering markdown: {:?}", &md_path);
let edit_enabled = Self::page_edit_enabled(&config, req.headers());
match Self::markdown_to_html(&md_path, &config, edit_enabled).await {
Ok(response) => Ok(response),
Err(e) if is_not_downloaded(&e) => {
if let Some((vault, path)) = config.repo.locate(&md_path) {
vault.request_download(std::slice::from_ref(&path));
}
Ok(Self::not_downloaded_page(&config, &path, &md_path))
}
Err(e) => {
tracing::error!("Error rendering markdown: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
ResolvedPath::DirectoryListing(dir_path) => {
tracing::debug!("generating directory listing: {:?}", &dir_path);
let edit_enabled = Self::page_edit_enabled(&config, req.headers());
Self::directory_to_html(
&dir_path,
&config.templates,
config.base_dir.as_path(),
&config,
edit_enabled,
)
.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) => Ok(canonical_redirect_response(
&canonical_url,
req.uri().query(),
)),
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,
config.tasks_enabled,
config.tasks_default_include,
))
}
}
}
async fn serve_vault_file(
vault: Arc<dyn Vault>,
path: VaultPath,
req: extract::Request<Body>,
) -> Result<Response, StatusCode> {
let stat_vault = Arc::clone(&vault);
let stat_path = path.clone();
let entry = tokio::task::spawn_blocking(move || stat_vault.stat(&stat_path))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let entry = match entry {
Ok(Some(entry)) if entry.is_file() => entry,
Ok(_) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Error serving {path}: {e}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let content_type = mime_guess::from_path(path.as_str())
.first_raw()
.unwrap_or("application/octet-stream");
let last_modified = entry
.epoch_secs()
.and_then(|(_, modified)| generate_last_modified(modified));
let mut response = crate::vault_body::vault_file_response(
vault,
path,
entry.size,
content_type,
last_modified,
req.headers(),
req.method() == axum::http::Method::HEAD,
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(CACHE_CONTROL_NO_CACHE),
);
Ok(response)
}
fn not_downloaded_page(config: &ServerState, path: &str, md_path: &Path) -> Response<Body> {
let note_name = md_path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("this note")
.to_string();
let provider = cloud_provider_name(md_path);
let mut response = Self::render_error_page_with(
&config.templates,
StatusCode::SERVICE_UNAVAILABLE,
"Downloading",
Some(
"This note is stored in the cloud and is not on this device yet. \
It is downloading now and will open as soon as it arrives.",
),
&format!("/{path}"),
config.gui_mode,
&config.sidebar_style,
config.sidebar_max_items,
config.graph_depth,
config.tasks_enabled,
config.tasks_default_include,
vec![
("downloading", serde_json::Value::Bool(true)),
("title", serde_json::Value::String(note_name.clone())),
("note_name", serde_json::Value::String(note_name)),
(
"download_provider",
provider.map_or(serde_json::Value::Null, |name| {
serde_json::Value::String(name.to_string())
}),
),
],
);
add_retry_headers(response.headers_mut());
response
}
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: mbr_core::video_metadata_cache::CachedMetadata,
) -> Option<Response<Body>> {
use mbr_core::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: mbr_core::video_metadata::MetadataType,
key: String,
config: &ServerState,
) -> Option<Response<Body>> {
use mbr_core::video_metadata::{
MetadataType, extract_captions, extract_chapters, extract_cover,
};
use mbr_core::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 mbr_core::video_metadata::{MetadataType, parse_metadata_request};
use mbr_core::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,
&config.mount_targets(),
) 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 mbr_core::pdf_metadata::parse_pdf_cover_request;
use mbr_core::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 mbr_core::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 mbr_core::pdf_metadata::parse_pdf_cover_request;
use mbr_core::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,
&config.mount_targets(),
) 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 mbr_core::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(mbr_core::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 mbr_core::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 = mbr_core::config::tag_sources_to_url_sources(&config.tag_sources);
let resolver_config = config.resolver_config(&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(),
markdown_page_probe: None,
};
let valid_tag_sources =
mbr_core::config::tag_sources_to_set(&config.tag_sources);
match markdown::render_from_vault_with_cache(
Arc::clone(config.repo.vault()),
md_path,
&config.base_dir,
config.oembed_timeout_ms,
link_transform_config,
Some(config.oembed_cache.clone()),
true, false, valid_tag_sources,
markdown::ReviewLines::Omit,
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<mbr_core::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 vault = Arc::clone(config.repo.vault());
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,
vault.as_ref(),
&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 mbr_core::page_errors::{
PageErrors, ambiguous_relationship_endpoint_errors, ambiguous_wikilink_errors,
contact_problem_errors, detect_unresolved_wikilinks, frontmatter_parse_errors,
relationship_cycle_errors, validate_internal_links, validate_media_references,
validate_rendered_links,
};
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 = mbr_core::config::tag_sources_to_url_sources(&config.tag_sources);
let resolver_config = config.resolver_config(&tag_url_sources);
let request_path = page_url_path.trim_matches('/');
let (
outbound_links,
html_for_scan,
frontmatter_error,
ambiguous_wikilinks,
contact_problems,
): (
Vec<mbr_core::link_index::OutboundLink>,
String,
Option<String>,
Vec<mbr_core::wikilink_index::AmbiguousWikilink>,
Vec<mbr_core::contact::ContactProblem>,
) = 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(),
markdown_page_probe: Some(
mbr_core::link_transform::filesystem_markdown_page_probe(
owned_resolver_config(config),
),
),
};
let valid_tag_sources = mbr_core::config::tag_sources_to_set(&config.tag_sources);
match markdown::render_from_vault_with_cache(
Arc::clone(config.repo.vault()),
md_path,
&config.base_dir,
config.oembed_timeout_ms,
link_transform_config,
Some(config.oembed_cache.clone()),
true, false, valid_tag_sources,
markdown::ReviewLines::Omit,
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,
render_result
.contact
.map(|c| c.problems)
.unwrap_or_default(),
)
}
Err(e) if is_not_downloaded_markdown(&e) => {
log_render_failure("errors.json", &e, true);
return Some(Self::not_downloaded_errors_json(config, page_url_path));
}
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(), Vec::new())
}
ResolvedPath::TagSourceIndex { source } => {
let outbound = build_tag_index_outbound_links(&source, &config.repo.tag_index);
(outbound, String::new(), None, Vec::new(), Vec::new())
}
_ => {
tracing::debug!("errors.json: page not found: {}", page_url_path);
return None;
}
};
let mut errors = Vec::new();
if let Some(message) = &config.config_problem {
errors.push(mbr_core::page_errors::PageError::ConfigParseError {
message: message.clone(),
});
}
errors.extend(frontmatter_parse_errors(&frontmatter_error));
errors.extend(contact_problem_errors(&contact_problems));
if html_for_scan.is_empty() {
errors.extend(validate_internal_links(&outbound_links, &resolver_config));
} else {
errors.extend(validate_rendered_links(
&html_for_scan,
&resolver_config,
&page_url_path,
));
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,
not_downloaded: false,
};
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)),
))
}
fn not_downloaded_errors_json(config: &ServerState, page_url: String) -> Response<Body> {
let errors = config
.config_problem
.iter()
.map(
|message| mbr_core::page_errors::PageError::ConfigParseError {
message: message.clone(),
},
)
.collect();
let payload = mbr_core::page_errors::PageErrors {
page_url,
errors,
not_downloaded: true,
};
match serde_json::to_string(&payload) {
Ok(json) => build_response_or_500(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.header(header::CACHE_CONTROL, CACHE_CONTROL_NO_STORE)
.body(Body::from(json)),
),
Err(e) => {
tracing::error!("Failed to serialize errors.json: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
#[cfg(feature = "media-metadata")]
async fn probe_playback_compat_cached(
media_file: &std::path::Path,
config: &ServerState,
) -> Option<mbr_core::video_metadata::PlaybackCompatibility> {
use mbr_core::video_metadata::probe_playback_compatibility;
use mbr_core::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<mbr_core::page_errors::PageError> {
use futures::stream::StreamExt;
use itertools::Itertools;
use mbr_core::page_errors::{MediaKind, PageError, collect_media_references};
use mbr_core::video_metadata::{PlaybackCompatibility, has_video_extension};
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: mbr_core::video_remux::RemuxPart,
data: Arc<Vec<u8>>,
) -> Response<Body> {
use mbr_core::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<mbr_core::video_transcode_cache::HlsCache>,
key: mbr_core::video_transcode_cache::HlsCacheKey,
generate: F,
) -> Result<Arc<Vec<u8>>, Box<Response<Body>>>
where
F: FnOnce() -> Result<Vec<u8>, mbr_core::video_transcode::TranscodeError> + Send + 'static,
{
use mbr_core::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(Box::new(Self::build_remux_error_response(&error))),
Err(join_error) => {
tracing::warn!("remux generation task did not finish: {join_error}");
Err(Box::new(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(Box::new(Self::remux_incomplete_response(cache, &key))),
}
}
HlsCacheStartResult::AlreadyComplete(data) => Ok(data),
HlsCacheStartResult::PreviouslyFailed(message) => {
tracing::debug!("previous remux generation failed: {message}");
Err(Box::new(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(Box::new(Self::build_remux_error_response(&error))),
Err(join_error) => {
tracing::warn!("remux generation task did not finish: {join_error}");
Err(Box::new(Self::build_remux_retry_response(
"generation did not complete",
)))
}
}
}
}
}
#[cfg(feature = "media-metadata")]
fn remux_incomplete_response(
cache: &mbr_core::video_transcode_cache::HlsCache,
key: &mbr_core::video_transcode_cache::HlsCacheKey,
) -> Response<Body> {
use mbr_core::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: &mbr_core::video_transcode::TranscodeError,
) -> Response<Body> {
use mbr_core::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 mbr_core::video_metadata_cache::cache_key_with_mtime;
use mbr_core::video_remux::{
RemuxPart, generate_remux_init, generate_remux_playlist, generate_remux_segment,
parse_remux_request,
};
use mbr_core::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,
&config.mount_targets(),
) 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<mbr_core::video_transcode::VideoResolution> {
use mbr_core::video_metadata_cache::cache_key_with_mtime;
use mbr_core::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 mbr_core::video_transcode::{
HlsRequest, TranscodeError, generate_hls_playlist, parse_hls_request, should_transcode,
transcode_segment,
};
use mbr_core::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,
&config.mount_targets(),
) 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 modified_secs(config: &ServerState, path: &Path) -> Option<u64> {
let vault = Arc::clone(config.repo.vault());
let source = vault.vault_path(path).ok()?;
tokio::task::spawn_blocking(move || vault.stat(&source))
.await
.ok()?
.ok()??
.modified
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
}
async fn markdown_to_html(
md_path: &Path,
config: &ServerState,
edit_enabled: bool,
) -> 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: mbr_core::repo::build_markdown_url_path(
md_path,
root_path,
&config.index_file,
),
markdown_page_probe: Some(mbr_core::link_transform::filesystem_markdown_page_probe(
owned_resolver_config(config),
)),
};
#[cfg(feature = "media-metadata")]
let transcode_enabled = config.transcode_enabled;
#[cfg(not(feature = "media-metadata"))]
let transcode_enabled = false;
let valid_tag_sources = mbr_core::config::tag_sources_to_set(&config.tag_sources);
let source = markdown::read_source_fingerprinted(
Arc::clone(config.repo.vault()),
md_path.to_path_buf(),
)
.await
.inspect_err(|e| {
log_render_failure("Error rendering markdown", e, is_not_downloaded_markdown(e))
})?;
let render_result = markdown::render_text_with_cache(
&source.text,
md_path,
markdown::RenderOptions {
root_path,
oembed_timeout_ms: config.oembed_timeout_ms,
link_transform_config,
oembed_cache: Some(config.oembed_cache.clone()),
server_mode: true,
transcode_enabled,
valid_tag_sources,
review: markdown::ReviewLines::for_server(
config.review_enabled,
config.edit_enabled,
),
mark_incomplete: config.mark_incomplete,
incomplete_markers: &config.incomplete_markers,
wikilink_index: Some(config.repo.wikilink_index.clone()),
},
)
.await
.inspect_err(|e| {
log_render_failure("Error rendering markdown", e, is_not_downloaded_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 contact = render_result.contact;
let readability_counts = mbr_core::readability::ReadabilityCounts {
words: render_result.word_count,
sentences: render_result.sentence_count,
syllables: render_result.syllable_count,
};
let readability_scores = mbr_core::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(),
mbr_core::url_path::path_to_url(&relative_md_path).into(),
);
frontmatter.insert("markdown_source_hash".into(), source.hash.into());
frontmatter.insert("server_mode".into(), "true".into());
frontmatter.insert(
"gui_mode".into(),
if config.gui_mode { "true" } else { "" }.into(),
);
let url_path_buf = if is_index_file {
relative_md_path
.parent()
.unwrap_or(Path::new(""))
.to_path_buf()
} else {
let parent = relative_md_path.parent().unwrap_or(Path::new(""));
let stem = relative_md_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
parent.join(stem)
};
let current_url =
format!("/{}/", mbr_core::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 = Self::modified_secs(config, md_path).await;
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 mut 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,
flashcards_concentric_threshold: config.flashcards_concentric_threshold,
flashcards_progress_indicators: config.flashcards_progress_indicators,
tasks_enabled: config.tasks_enabled,
review_enabled: config.review_enabled,
tasks_default_include: config.tasks_default_include,
title_prefix: &config.title_prefix,
title_suffix: &config.title_suffix,
root_dir: Some(&config.base_dir),
},
&page_context::UrlMode::Absolute,
);
extra_context.insert(
"edit_enabled".to_string(),
serde_json::Value::Bool(edit_enabled),
);
if contact.is_some() {
let relationships = if config.relationship_tracking {
config.repo.relationship_index.get(¤t_url)
} else {
Vec::new()
};
let backlinks = (config.link_tracking && config.inbound_index.is_ready())
.then(|| config.inbound_index.source_count(¤t_url));
page_context::insert_contact(
&mut extra_context,
contact,
&relationships,
backlinks,
&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 = modified_secs.and_then(generate_last_modified);
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,
relative_path: &Path,
config: &ServerState,
) -> Result<(Vec<serde_json::Value>, Vec<serde_json::Value>), MbrError> {
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 vault = Arc::clone(config.repo.vault());
let static_vault = config.repo.static_vault().cloned();
let scan_result = tokio::task::spawn_blocking(move || {
let temp_repo = Repo::init_with_vault(
Arc::clone(&vault),
static_folder,
&markdown_extensions,
&ignore_dirs,
&ignore_globs,
index_file.clone(),
&tag_sources,
&relationship_types,
)
.with_static_vault(static_vault);
let folder = match vault.vault_path(&dir_path) {
Ok(folder) => folder,
Err(_) => VaultPath::from_relative_native(&relative_path).unwrap_or_default(),
};
temp_repo.scan_vault_folder(&folder).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 mut subdirs: Vec<serde_json::Value> = temp_repo
.queued_folders
.pin()
.iter()
.filter_map(|(abs_path, queued)| {
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 = queued.path.as_str().to_string();
if !url_path.starts_with('/') {
url_path = "/".to_string() + &url_path;
}
if !url_path.ends_with('/') {
url_path.push('/');
}
let index_fm = queued
.path
.join(&index_file)
.ok()
.filter(|p| vault.is_file(p))
.and_then(|p| {
markdown::extract_metadata_from_vault(vault.as_ref(), &p).ok()
})
.map(|m| m.metadata);
Some(mbr_core::sorting::folder_entry(
&name,
url_path,
index_fm.as_ref(),
))
} else {
None
}
})
.collect();
mbr_core::sorting::sort_folders(&mut subdirs, &sort);
Ok::<_, mbr_core::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: &mbr_core::templates::Templates,
root_path: &Path,
config: &ServerState,
edit_enabled: bool,
) -> 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, &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,
tasks_enabled: config.tasks_enabled,
review_enabled: config.review_enabled,
tasks_default_include: config.tasks_default_include,
title_affixes: Some((&config.title_prefix, &config.title_suffix)),
root_dir: Some(&config.base_dir),
},
);
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));
context.insert("edit_enabled".to_string(), json!(edit_enabled));
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,
tasks_enabled: config.tasks_enabled,
review_enabled: config.review_enabled,
tasks_default_include: config.tasks_default_include,
title_affixes: Some((&config.title_prefix, &config.title_suffix)),
root_dir: Some(&config.base_dir),
},
);
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,
tasks_enabled: config.tasks_enabled,
review_enabled: config.review_enabled,
tasks_default_include: config.tasks_default_include,
title_affixes: Some((&config.title_prefix, &config.title_suffix)),
root_dir: Some(&config.base_dir),
},
);
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>,
headers: HeaderMap,
) -> Result<impl IntoResponse, StatusCode> {
tracing::debug!("home_page handler");
let tag_url_sources = mbr_core::config::tag_sources_to_url_sources(&config.tag_sources);
let resolver_config = config.resolver_config(&tag_url_sources);
match resolve_request_path(&resolver_config, "") {
ResolvedPath::MarkdownFile(md_path) => {
tracing::debug!("home: rendering index markdown: {:?}", &md_path);
let edit_enabled = Self::page_edit_enabled(&config, &headers);
match Self::markdown_to_html(&md_path, &config, edit_enabled).await {
Ok(response) => Ok(response),
Err(e) if is_not_downloaded(&e) => {
if let Some((vault, path)) = config.repo.locate(&md_path) {
vault.request_download(std::slice::from_ref(&path));
}
Ok(Self::not_downloaded_page(&config, "", &md_path))
}
Err(e) => {
tracing::error!("Error rendering home markdown: {e}");
Err(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,
Self::page_edit_enabled(&config, &headers),
)
.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,
Self::page_edit_enabled(&config, &headers),
)
.await
.map_err(|e| {
tracing::error!("Error generating home directory listing: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})
}
}
}
}
fn reload_templates(templates: &mbr_core::templates::Templates) -> bool {
match templates.reload() {
Ok(()) => true,
Err(e) => {
tracing::error!("Failed to reload templates: {}", e);
false
}
}
}
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"),
}
}
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,
folder_frontmatter: &std::collections::HashMap<String, mbr_core::markdown::SimpleMetadata>,
sort: &[SortField],
) -> Vec<serde_json::Value> {
let mut entries = 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!("/{}/", mbr_core::url_path::path_to_url(&dir.join(&name)));
let index_fm = folder_frontmatter.get(&url_path);
mbr_core::sorting::folder_entry(&name, url_path, index_fm)
})
.collect::<Vec<_>>();
mbr_core::sorting::sort_folders(&mut entries, sort);
entries
}
fn subfolder_index_frontmatter<'a>(
files: impl Iterator<Item = &'a MarkdownInfo>,
dir: &Path,
index_file: &str,
) -> std::collections::HashMap<String, mbr_core::markdown::SimpleMetadata> {
mbr_core::sorting::folder_index_frontmatter(
files.filter(|info| info.raw_path.parent().and_then(Path::parent) == Some(dir)),
index_file,
)
}
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 folder_frontmatter = subfolder_index_frontmatter(
markdown_guard.iter().map(|(_, info)| info),
dir,
&config.index_file,
);
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,
&folder_frontmatter,
&config.sort,
));
if config.repo.is_scan_complete() {
config
.subdir_cache
.pin()
.insert(dir.to_path_buf(), Arc::clone(&computed));
}
computed
}
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 canonical_redirect_response(canonical_url: &str, query: Option<&str>) -> Response<Body> {
let location = match query {
Some(query) if !query.is_empty() => format!("{canonical_url}?{query}"),
_ => canonical_url.to_string(),
};
tracing::debug!("redirecting to canonical URL: {}", &location);
build_response_or_500(
Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header(header::LOCATION, &location)
.body(Body::empty()),
)
}
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";
fn build_tag_page_outbound_links(
source: &str,
value: &str,
tag_index: &mbr_core::tag_index::TagIndex,
tag_sources: &[TagSource],
) -> Vec<mbr_core::link_index::OutboundLink> {
use mbr_core::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: &mbr_core::tag_index::TagIndex,
) -> Vec<mbr_core::link_index::OutboundLink> {
use mbr_core::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_http_build_error_converts_to_mbr_error() {
let http_err = Response::builder()
.header("bad header name", "x")
.body(Body::empty())
.unwrap_err();
let inner = http_err.to_string();
let err = MbrError::from(http_err);
assert!(matches!(err, MbrError::Http(_)));
assert_eq!(
err.to_string(),
format!("Failed to build HTTP response: {inner}")
);
let source = std::error::Error::source(&err).expect("source is kept");
assert_eq!(source.to_string(), inner);
}
#[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_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_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 cloud_provider_names_come_from_the_sync_folder() {
let name = |p: &str| cloud_provider_name(std::path::Path::new(p));
assert_eq!(
name("/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs/Notes/a.md"),
Some("iCloud")
);
assert_eq!(
name("/Users/me/Library/Mobile Documents/com~apple~CloudDocs/a.md"),
Some("iCloud")
);
assert_eq!(
name("/Users/me/Library/CloudStorage/Dropbox/notes/a.md"),
Some("Dropbox")
);
assert_eq!(
name("/Users/me/Library/CloudStorage/GoogleDrive-me@example.com/My Drive/a.md"),
Some("Google Drive")
);
assert_eq!(
name("/Users/me/Library/CloudStorage/OneDrive-Personal/a.md"),
Some("OneDrive")
);
assert_eq!(
name("/private/var/mobile/Containers/Shared/AppGroup/1234/File Provider Storage/a.md"),
None
);
assert_eq!(name("/Users/me/notes/Dropboxes/a.md"), None);
}
#[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 = mbr_core::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_wikilink_delta_names_keeps_labeled_aliases() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("mare.md");
std::fs::write(
&path,
"---\ntitle: Mary Doe\naliases:\n - nickname: Mare\n---\n",
)
.unwrap();
let fm = mbr_core::markdown::extract_metadata_from_file(&path)
.unwrap()
.metadata;
let delta = Server::wikilink_delta_names(Some(&fm), "mare", "mary");
assert!(delta.is_empty(), "{delta:?}");
}
#[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"),
&Default::default(),
&[],
);
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,
repo.vault().as_ref(),
&["md".to_string()],
&[],
&[],
"index.md",
);
mbr_core::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,
&Default::default(),
&[],
);
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(""),
&Default::default(),
&[],
);
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 mbr_core::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 mbr_core::video_transcode::TranscodeTarget;
use mbr_core::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 mbr_core::change_event::{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::<mbr_core::change_event::FileChangeEvent>(2);
drop(tx);
assert!(matches!(
live_reload_action(rx.recv().await),
LiveReloadAction::Close
));
}
#[test]
fn test_invalidate_derived_caches_clears_link_caches() {
use mbr_core::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 mbr_core::video_metadata_cache::{CachedMetadata, VideoMetadataCache};
let config = mbr_core::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 = mbr_core::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 mbr_core::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(&mbr_core::vault::LocalVault::new(&base), "../escape.md"),
Err(FileOpError::Traversal)
));
assert!(matches!(
resolve_new_target_path(
&mbr_core::vault::LocalVault::new(&base),
"docs/../../escape.md"
),
Err(FileOpError::Traversal)
));
assert!(!base.join("../escape.md").exists());
}
#[test]
fn test_resolve_new_target_rejects_backslash_traversal() {
let dir = tempfile::TempDir::new().unwrap();
let vault = mbr_core::vault::LocalVault::new(dir.path());
for rel in ["docs\\..\\..\\escape.md", "..\\escape.md", "docs\\..\\x.md"] {
assert!(
matches!(
resolve_new_target_path(&vault, rel),
Err(FileOpError::Traversal)
),
"{rel:?}"
);
}
}
#[cfg(unix)]
#[test]
fn test_resolve_new_target_rejects_symlinked_ancestor_outside_root() {
let dir = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
std::os::unix::fs::symlink(outside.path(), dir.path().join("leak")).unwrap();
let vault = mbr_core::vault::LocalVault::new(dir.path());
assert!(matches!(
resolve_new_target_path(&vault, "leak/new.md"),
Err(FileOpError::Traversal)
));
assert!(!outside.path().join("new.md").exists());
}
#[test]
fn test_resolve_new_target_through_a_mem_vault() {
let vault = mbr_core::vault::MemVault::new().with_dir("docs");
let resolved = resolve_new_target_path(&vault, "docs/sub/new.md").expect("valid path");
assert_eq!(resolved.as_str(), "docs/sub/new.md");
assert!(matches!(
resolve_new_target_path(&vault, "../x.md"),
Err(FileOpError::Traversal)
));
}
#[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(&mbr_core::vault::LocalVault::new(&base), ""),
Err(FileOpError::Traversal)
));
assert!(matches!(
resolve_new_target_path(&mbr_core::vault::LocalVault::new(&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 vault = mbr_core::vault::LocalVault::new(&base);
let resolved = resolve_new_target_path(&vault, "docs/new.md").expect("valid path");
assert_eq!(vault.key(&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 vault = mbr_core::vault::LocalVault::new(&base);
let resolved = resolve_new_target_path(&vault, "/docs/new.md").expect("valid path");
assert_eq!(vault.key(&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",
Vec::new
));
assert!(is_within_served_roots(
&repo,
&repo,
Some(&canonical_repo),
"",
Vec::new
));
assert!(is_within_served_roots(
&overlay,
&repo,
Some(&canonical_repo),
"../static",
Vec::new
));
}
#[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",
Vec::new
));
}
assert!(!is_within_served_roots(
&repo.join("missing.txt"),
&repo,
Some(&canonical_repo),
"static",
Vec::new
));
}
#[cfg(unix)]
#[test]
fn test_path_checks_follow_mounts_and_refuse_hidden_files() {
let outside = tempfile::Builder::new()
.prefix("mbr-server-test-")
.tempdir()
.unwrap();
let target = outside.path().canonicalize().unwrap();
std::fs::create_dir(target.join(".secret")).unwrap();
std::fs::write(target.join("clip.mp4"), b"clip").unwrap();
std::fs::write(target.join(".secret/key"), b"key").unwrap();
let repo = tempfile::tempdir().unwrap();
let root = repo.path().canonicalize().unwrap();
std::fs::write(root.join(".env"), b"SECRET=1").unwrap();
std::os::unix::fs::symlink(&target, root.join("videos")).unwrap();
let mounts = [target.clone()];
assert!(matches!(
validate_media_path(".env", &root, ""),
Err(MbrError::InvalidMediaPath(_))
));
assert!(validate_media_path("videos/clip.mp4", &root, "").is_err());
assert_eq!(
validate_media_path_with_mounts("videos/clip.mp4", &root, "", &mounts).unwrap(),
target.join("clip.mp4")
);
assert!(validate_media_path_with_mounts("videos/.secret/key", &root, "", &mounts).is_err());
let link = root.join("videos/clip.mp4");
assert!(!is_within_served_roots(
&link,
&root,
Some(&root),
"",
Vec::new
));
assert!(is_within_served_roots(
&link,
&root,
Some(&root),
"",
|| { mounts.to_vec() }
));
assert!(!is_within_served_roots(
&root.join("videos/.secret/key"),
&root,
Some(&root),
"",
|| mounts.to_vec()
));
}
#[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::*;
proptest! {
#[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");
}
}
}
}