1use axum::{
2 Router,
3 body::Body,
4 extract::{self, ConnectInfo, DefaultBodyLimit, OriginalUri, State, ws::WebSocketUpgrade},
5 http::{HeaderMap, HeaderValue, StatusCode, header},
6 response::{IntoResponse, Json, Response},
7 routing::{get, post},
8};
9use futures_util::{SinkExt, StreamExt};
10use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
11use std::collections::HashSet;
12use std::{net::SocketAddr, path::Path, path::PathBuf, sync::Arc};
13use tokio::sync::broadcast;
14
15use crate::errors::ServerError;
16use crate::file_write::{FileWriteLocks, create_unique_temp_file};
17use crate::link_grep::InboundLinkCache;
18use mbr_core::config::{RelationType, SortField, TagSource};
19use mbr_core::embedded_katex;
20use mbr_core::embedded_pico;
21use mbr_core::errors::{MbrError, TaskPatchError};
22use mbr_core::link_index::{InboundIndex, LinkCache, resolve_outbound_links};
23use mbr_core::link_transform::LinkTransformConfig;
24use mbr_core::media::MediaViewerType;
25use mbr_core::oembed_cache::OembedCache;
26use mbr_core::page_context::{self, ModeFlags, PageChrome, UrlMode, markdown_file_to_json};
27use mbr_core::path_resolver::{PathResolverConfig, ResolvedPath, resolve_request_path};
28use mbr_core::repo::MarkdownInfo;
29use mbr_core::search::{SearchEngine, SearchQuery, search_other_files};
30use mbr_core::sorting::sort_files;
31use mbr_core::task_query::IncludeFilter;
32use mbr_core::templates;
33use mbr_core::url_helpers::{generate_breadcrumbs, get_current_dir_name, get_parent_path};
34use mbr_core::vault::{Availability, Vault, VaultError, VaultPath};
35#[cfg(feature = "media-metadata")]
36use mbr_core::video_metadata_cache::VideoMetadataCache;
37#[cfg(feature = "media-metadata")]
38use mbr_core::video_transcode_cache::HlsCache;
39use mbr_core::{markdown, repo::Repo};
40use std::time::Instant;
41use tower::ServiceExt;
42use tower_http::{compression::CompressionLayer, services::ServeFile, trace::TraceLayer};
43use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
44
45#[cfg(feature = "media-metadata")]
47const DEFAULT_HLS_CACHE_SIZE: usize = 200 * 1024 * 1024;
48
49#[cfg(feature = "media-metadata")]
52const METADATA_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
53
54#[cfg(feature = "media-metadata")]
58const MEDIA_COMPAT_PROBE_CONCURRENCY: usize = 4;
59
60enum InflightClaim {
62 Produce(Arc<tokio::sync::Notify>),
64 Wait(Arc<tokio::sync::Notify>),
67}
68
69fn claim_inflight(
85 inflight: &papaya::HashMap<String, Arc<tokio::sync::Notify>>,
86 key: &str,
87) -> InflightClaim {
88 let notify = Arc::new(tokio::sync::Notify::new());
89 match inflight.pin().try_insert(key.to_string(), notify.clone()) {
90 Ok(_) => InflightClaim::Produce(notify),
91 Err(papaya::OccupiedError { current, .. }) => InflightClaim::Wait(current.clone()),
92 }
93}
94
95struct InflightSlot {
105 inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>>,
106 key: String,
107 notify: Arc<tokio::sync::Notify>,
108}
109
110impl InflightSlot {
111 fn new(
112 inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>>,
113 key: String,
114 notify: Arc<tokio::sync::Notify>,
115 ) -> Self {
116 Self {
117 inflight,
118 key,
119 notify,
120 }
121 }
122}
123
124impl Drop for InflightSlot {
125 fn drop(&mut self) {
126 self.inflight.pin().remove(&self.key);
127 self.notify.notify_waiters();
130 }
131}
132
133#[cfg(feature = "media-metadata")]
145struct HlsGenerationSlot<'a> {
146 cache: &'a HlsCache,
147 key: Option<mbr_core::video_transcode_cache::HlsCacheKey>,
149 notify: Arc<tokio::sync::Notify>,
150}
151
152#[cfg(feature = "media-metadata")]
153impl<'a> HlsGenerationSlot<'a> {
154 fn new(
155 cache: &'a HlsCache,
156 key: mbr_core::video_transcode_cache::HlsCacheKey,
157 notify: Arc<tokio::sync::Notify>,
158 ) -> Self {
159 Self {
160 cache,
161 key: Some(key),
162 notify,
163 }
164 }
165
166 fn settled(&mut self) {
169 self.key = None;
170 }
171}
172
173#[cfg(feature = "media-metadata")]
174impl Drop for HlsGenerationSlot<'_> {
175 fn drop(&mut self) {
176 if let Some(key) = self.key.take() {
177 tracing::warn!("HLS generation abandoned for {key:?}; releasing the in-flight slot");
178 self.cache.fail_generation(
179 key,
180 &mbr_core::video_transcode::TranscodeError::TranscodeFailed(
181 "generation did not finish (request cancelled or worker panicked)".to_string(),
182 ),
183 );
184 self.notify.notify_waiters();
185 }
186 }
187}
188
189fn invalidate_derived_caches(
200 listing_caches: &ListingCaches,
201 link_cache: &LinkCache,
202 inbound_link_cache: &InboundLinkCache,
203) {
204 listing_caches.invalidate();
205 link_cache.invalidate_all();
206 inbound_link_cache.invalidate_all();
207}
208
209fn owned_resolver_config(config: &ServerState) -> mbr_core::path_resolver::OwnedPathResolverConfig {
216 mbr_core::path_resolver::OwnedPathResolverConfig::for_repo(
217 &config.repo,
218 &config.static_folder,
219 &config.markdown_extensions,
220 &config.index_file,
221 mbr_core::config::tag_sources_to_url_sources(&config.tag_sources),
222 )
223}
224
225#[derive(Clone)]
232struct LinkIndexConfig {
233 base_dir: PathBuf,
234 index_file: String,
235 markdown_extensions: Vec<String>,
236 valid_tag_sources: HashSet<String>,
237}
238
239fn index_page_links(
247 repo: &Repo,
248 index: &InboundIndex,
249 cfg: &LinkIndexConfig,
250 path: &Path,
251 url_path: &str,
252) {
253 let is_index_file = path
254 .file_name()
255 .and_then(|f| f.to_str())
256 .is_some_and(|f| f == cfg.index_file);
257
258 let link_transform_config = LinkTransformConfig {
259 markdown_extensions: cfg.markdown_extensions.clone(),
260 index_file: cfg.index_file.clone(),
261 is_index_file,
262 url_depth: None,
263 current_page_url: url_path.to_string(),
264 markdown_page_probe: None,
265 };
266
267 match markdown::extract_outbound_links_from_vault(
268 repo.vault().as_ref(),
269 path,
270 &cfg.base_dir,
271 link_transform_config,
272 true, cfg.valid_tag_sources.clone(),
274 Some(repo.wikilink_index.clone()),
275 ) {
276 Ok(links) => {
277 let resolved: Vec<mbr_core::link_index::OutboundLink> = links
286 .into_iter()
287 .map(|mut link| {
288 if link.internal && !link.to.is_empty() {
289 link.to = mbr_core::link_transform::transform_link(
290 &link.to,
291 &LinkTransformConfig {
292 markdown_extensions: cfg.markdown_extensions.clone(),
293 index_file: cfg.index_file.clone(),
294 is_index_file,
295 url_depth: None,
296 current_page_url: url_path.to_string(),
297 markdown_page_probe: None,
298 },
299 );
300 }
301 link
302 })
303 .collect();
304 let resolved = resolve_outbound_links(url_path, resolved, true);
315 index.set_page_links(url_path, &resolved);
316 }
317 Err(e) => {
318 if is_not_downloaded_markdown(&e) {
324 tracing::debug!("Backlink index: {} not downloaded yet: {e}", path.display());
325 } else {
326 tracing::warn!("Backlink index: failed to read {}: {e}", path.display());
327 }
328 index.remove_page(url_path);
329 }
330 }
331}
332
333fn populate_inbound_index(repo: &Repo, index: &InboundIndex, cfg: &LinkIndexConfig) {
337 use rayon::prelude::*;
338
339 let pages: Vec<(PathBuf, String)> = repo
340 .markdown_files
341 .pin()
342 .iter()
343 .map(|(path, info)| (path.clone(), info.url_path.clone()))
344 .collect();
345
346 let page_count = pages.len();
347 let started = Instant::now();
348
349 pages.par_iter().for_each(|(path, url_path)| {
350 index_page_links(repo, index, cfg, path, url_path);
351 });
352
353 index.mark_ready();
354 tracing::info!(
355 "Backlink index built in {:?}: {} pages parsed, {} pages have backlinks",
356 started.elapsed(),
357 page_count,
358 index.target_count(),
359 );
360}
361
362#[derive(Clone)]
371struct ListingCaches {
372 sibling_nav_cache: Arc<papaya::HashMap<PathBuf, Arc<Vec<serde_json::Value>>>>,
373 subdir_cache: Arc<papaya::HashMap<PathBuf, Arc<Vec<serde_json::Value>>>>,
374 site_json_cache: Arc<parking_lot::RwLock<SiteJsonCache>>,
375}
376
377impl ListingCaches {
378 fn invalidate(&self) {
379 self.sibling_nav_cache.pin().clear();
380 self.subdir_cache.pin().clear();
381 self.site_json_cache.write().invalidate();
382 }
383}
384
385#[derive(Default)]
393pub struct SiteJsonCache {
394 generation: u64,
395 body: Option<axum::body::Bytes>,
396}
397
398impl SiteJsonCache {
399 fn snapshot(&self) -> (u64, Option<axum::body::Bytes>) {
401 (self.generation, self.body.clone())
402 }
403
404 fn store(&mut self, generation: u64, body: axum::body::Bytes) {
407 if self.generation == generation {
408 self.body = Some(body);
409 }
410 }
411
412 fn invalidate(&mut self) {
413 self.generation = self.generation.wrapping_add(1);
414 self.body = None;
415 }
416}
417
418impl From<&ServerState> for ListingCaches {
419 fn from(state: &ServerState) -> Self {
420 Self {
421 sibling_nav_cache: Arc::clone(&state.sibling_nav_cache),
422 subdir_cache: Arc::clone(&state.subdir_cache),
423 site_json_cache: Arc::clone(&state.site_json_cache),
424 }
425 }
426}
427
428#[derive(Debug)]
431enum LiveReloadAction {
432 Forward(mbr_core::change_event::FileChangeEvent),
434 Skip,
436 Close,
438}
439
440fn live_reload_action(
447 result: Result<mbr_core::change_event::FileChangeEvent, broadcast::error::RecvError>,
448) -> LiveReloadAction {
449 match result {
450 Ok(event) => LiveReloadAction::Forward(event),
451 Err(broadcast::error::RecvError::Lagged(skipped)) => {
452 tracing::warn!("Live reload client lagged; {skipped} file change event(s) dropped");
453 LiveReloadAction::Skip
454 }
455 Err(broadcast::error::RecvError::Closed) => {
456 tracing::debug!("File change channel closed; ending live reload stream");
457 LiveReloadAction::Close
458 }
459 }
460}
461
462const DEFAULT_LINK_CACHE_SIZE: usize = 2 * 1024 * 1024;
464
465const DEFAULT_INBOUND_LINK_CACHE_SIZE: usize = 4 * 1024 * 1024;
468
469const INBOUND_LINK_CACHE_TTL_SECS: u64 = 300;
476
477const INBOUND_GREP_MAX_CONCURRENCY: usize = 2;
481
482const INBOUND_GREP_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
485
486const INCOMPRESSIBLE_CONTENT_TYPE_PREFIXES: &[&str] = &[
506 "video/",
507 "audio/",
508 "application/pdf",
509 "application/zip",
510 "application/gzip",
511 "application/x-gzip",
512 "application/octet-stream",
513];
514
515fn is_incompressible_content_type(headers: &HeaderMap) -> bool {
522 let content_type = headers
523 .get(header::CONTENT_TYPE)
524 .and_then(|value| value.to_str().ok())
525 .unwrap_or_default();
526
527 INCOMPRESSIBLE_CONTENT_TYPE_PREFIXES
528 .iter()
529 .any(|prefix| content_type.starts_with(prefix))
530}
531
532fn compress_by_content_type(
539 _status: StatusCode,
540 _version: axum::http::Version,
541 headers: &HeaderMap,
542 _extensions: &axum::http::Extensions,
543) -> bool {
544 !is_incompressible_content_type(headers)
545}
546
547fn compression_predicate() -> impl tower_http::compression::Predicate {
554 use tower_http::compression::predicate::{DefaultPredicate, Predicate};
555
556 DefaultPredicate::new().and(compress_by_content_type)
557}
558
559#[derive(Debug, serde::Deserialize)]
561pub struct MediaViewerQuery {
562 pub path: Option<String>,
564}
565
566pub fn validate_media_path(
590 path: &str,
591 repo_root: &Path,
592 static_folder: &str,
593) -> Result<PathBuf, MbrError> {
594 validate_media_path_with_mounts(path, repo_root, static_folder, &[])
595}
596
597pub fn validate_media_path_with_mounts(
602 path: &str,
603 repo_root: &Path,
604 static_folder: &str,
605 mount_targets: &[PathBuf],
606) -> Result<PathBuf, MbrError> {
607 let decoded = percent_decode_str(path)
609 .decode_utf8()
610 .map_err(|_| MbrError::InvalidMediaPath("Invalid UTF-8 in path".to_string()))?;
611
612 if decoded.contains("..") {
614 return Err(MbrError::DirectoryTraversal);
615 }
616
617 let clean_path = decoded.trim_start_matches('/');
619
620 if clean_path
622 .split(['/', '\\'])
623 .any(mbr_core::vault::is_hidden_segment)
624 {
625 return Err(MbrError::InvalidMediaPath(format!(
626 "Path does not exist: {}",
627 decoded
628 )));
629 }
630
631 let full_path = repo_root.join(clean_path);
633
634 let canonical_root = repo_root
636 .canonicalize()
637 .map_err(|_| MbrError::InvalidMediaPath("Repository root not found".to_string()))?;
638
639 if let Ok(canonical_path) = full_path.canonicalize()
641 && (canonical_path.starts_with(&canonical_root)
642 || within_mount_targets(&canonical_path, mount_targets))
643 {
644 return Ok(canonical_path);
645 }
646
647 if !static_folder.is_empty() {
649 let static_root = repo_root.join(static_folder);
650
651 if let Ok(canonical_static_root) = static_root.canonicalize() {
653 let static_full_path = static_root.join(clean_path);
654
655 if let Ok(canonical_path) = static_full_path.canonicalize()
656 && (canonical_path.starts_with(&canonical_static_root)
657 || within_mount_targets(&canonical_path, mount_targets))
658 {
659 return Ok(canonical_path);
660 }
661 }
662 }
663
664 Err(MbrError::InvalidMediaPath(format!(
666 "Path does not exist: {}",
667 decoded
668 )))
669}
670
671fn safe_join_asset(base_dir: &Path, relative_path: &str) -> Option<PathBuf> {
684 if relative_path.contains("..") {
686 tracing::warn!(
687 "Path traversal attempt blocked in MBR assets: {}",
688 relative_path
689 );
690 return None;
691 }
692
693 let clean_path = relative_path.trim_start_matches('/');
694 let candidate = base_dir.join(clean_path);
695
696 let canonical_base = base_dir.canonicalize().ok()?;
698
699 let canonical = candidate.canonicalize().ok()?;
701
702 if canonical.starts_with(&canonical_base) && canonical.is_file() {
704 Some(canonical)
705 } else {
706 None
707 }
708}
709
710#[cfg(feature = "media-metadata")]
726fn validate_path_containment(file_path: &Path, base_dir: &Path) -> Option<PathBuf> {
727 let canonical_base = base_dir.canonicalize().ok()?;
728 let canonical_file = file_path.canonicalize().ok()?;
729
730 if canonical_file.starts_with(&canonical_base) && canonical_file.is_file() {
731 Some(canonical_file)
732 } else {
733 None
734 }
735}
736
737#[cfg(feature = "media-metadata")]
750fn resolve_media_source_file(
751 url_path: &str,
752 base_dir: &Path,
753 static_folder: &str,
754 mount_targets: &[PathBuf],
755) -> Option<PathBuf> {
756 if url_path
757 .split(['/', '\\'])
758 .any(mbr_core::vault::is_hidden_segment)
759 {
760 return None;
761 }
762 let in_mount = |candidate: PathBuf| {
763 let canonical = candidate.canonicalize().ok()?;
764 (within_mount_targets(&canonical, mount_targets) && canonical.is_file())
765 .then_some(canonical)
766 };
767 let direct = base_dir.join(url_path);
768 let static_dir = base_dir.join(static_folder);
769 let in_static = static_dir.join(url_path);
770 validate_path_containment(&direct, base_dir)
772 .or_else(|| validate_path_containment(&in_static, &static_dir))
774 .or_else(|| in_mount(direct))
775 .or_else(|| in_mount(in_static))
776}
777
778fn within_mount_targets(canonical: &Path, mount_targets: &[PathBuf]) -> bool {
783 mount_targets.iter().any(|target| {
784 canonical.strip_prefix(target).is_ok_and(|below| {
785 !below.components().any(|component| match component {
786 std::path::Component::Normal(name) => {
787 name.to_str().is_none_or(mbr_core::vault::is_hidden_segment)
788 }
789 _ => true,
790 })
791 })
792 })
793}
794
795const MBR_TEMPLATE_DIR: &str = ".mbr";
797
798const MBR_ASSET_EXTENSIONS: &[&str] = &[
806 "css",
808 "js",
809 "mjs",
810 "map",
811 "json",
812 "html",
813 "txt", "png",
815 "jpg",
816 "jpeg",
817 "gif",
818 "webp",
819 "svg",
820 "ico",
821 "woff",
822 "woff2",
823 "ttf",
824 "otf",
825 "eot",
826 "wasm",
828 "pf_meta",
829 "pf_fragment",
830 "pf_index",
831];
832
833fn is_servable_mbr_asset(asset_path: &str) -> bool {
840 let path = Path::new(asset_path);
841 let has_hidden_component = path.components().any(|component| match component {
842 std::path::Component::Normal(name) => {
843 name.to_str().is_some_and(|name| name.starts_with('.'))
844 }
845 std::path::Component::CurDir | std::path::Component::ParentDir => true,
846 _ => false,
847 });
848 if has_hidden_component {
849 return false;
850 }
851 path.extension()
852 .and_then(|ext| ext.to_str())
853 .map(str::to_ascii_lowercase)
854 .is_some_and(|ext| MBR_ASSET_EXTENSIONS.contains(&ext.as_str()))
855}
856
857const UPLOAD_ALLOWED_EXTENSIONS: &[&str] = &[
866 "jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff", "aiff", "aif", "mp3", "aac", "m4a", "ogg", "oga", "opus", "wma", "flac", "wav",
869 "mp4", "m4v", "mov", "webm", "flv", "mpg", "mpeg", "avi", "3gp", "wmv",
871 "pdf", "vtt", "srt",
873];
874
875fn canonical_existing_ancestor(path: &Path) -> Option<PathBuf> {
879 path.ancestors()
880 .find_map(|ancestor| ancestor.canonicalize().ok())
881}
882
883fn is_template_folder_path(
891 target: &Path,
892 base_dir: &Path,
893 canonical_base_dir: Option<&Path>,
894 template_folder: Option<&Path>,
895) -> bool {
896 let canonical_target = canonical_existing_ancestor(target);
897 std::iter::once(base_dir.join(MBR_TEMPLATE_DIR))
898 .chain(canonical_base_dir.map(|base| base.join(MBR_TEMPLATE_DIR)))
899 .chain(template_folder.map(Path::to_path_buf))
900 .any(|root| {
901 target.starts_with(&root)
902 || match (root.canonicalize(), canonical_target.as_deref()) {
903 (Ok(root), Some(canonical_target)) => canonical_target.starts_with(root),
904 _ => false,
905 }
906 })
907}
908
909fn host_header_hostname(host: &str) -> &str {
913 let host = host.trim();
914 if let Some(rest) = host.strip_prefix('[') {
915 rest.split(']').next().unwrap_or(rest)
917 } else if host.matches(':').count() > 1 {
918 host
920 } else {
921 host.split(':').next().unwrap_or(host)
922 }
923}
924
925fn host_header_is_allowed(headers: &HeaderMap, bind_ip: [u8; 4]) -> bool {
940 let Some(raw) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else {
941 return false;
942 };
943 let hostname = host_header_hostname(raw);
944 if hostname.eq_ignore_ascii_case("localhost") {
945 return true;
946 }
947 match hostname.parse::<std::net::IpAddr>() {
948 Ok(ip) => ip.is_loopback() || ip == std::net::IpAddr::from(bind_ip),
949 Err(_) => false,
950 }
951}
952
953fn on_request_failure(
961 class: tower_http::classify::ServerErrorsFailureClass,
962 latency: std::time::Duration,
963 span: &tracing::Span,
964) {
965 let latency_ms = latency.as_millis();
966 match class {
967 tower_http::classify::ServerErrorsFailureClass::StatusCode(
968 StatusCode::SERVICE_UNAVAILABLE,
969 ) => {
970 tracing::debug!(parent: span, latency_ms, "response asks for a retry (503)");
971 }
972 class => {
973 tracing::error!(parent: span, classification = %class, latency_ms, "response failed");
974 }
975 }
976}
977
978fn cloud_provider_name(path: &Path) -> Option<&'static str> {
984 path.components().find_map(|component| {
985 let name = component.as_os_str().to_str()?;
986 match name {
987 "Mobile Documents" => Some("iCloud"),
988 _ if name == "Dropbox" || name.starts_with("Dropbox-") => Some("Dropbox"),
989 _ if name.starts_with("GoogleDrive-") => Some("Google Drive"),
990 _ if name.starts_with("OneDrive-") => Some("OneDrive"),
991 _ if name.starts_with("Box-") => Some("Box"),
992 _ => None,
993 }
994 })
995}
996
997const NOT_DOWNLOADED_RETRY_SECS: &str = "2";
999
1000const MBR_ASSET_DOWNLOAD_WAIT: std::time::Duration = std::time::Duration::from_secs(3);
1004
1005const MBR_ASSET_DOWNLOAD_POLL: std::time::Duration = std::time::Duration::from_millis(150);
1007
1008fn add_retry_headers(headers: &mut HeaderMap) {
1011 headers.insert(
1012 header::RETRY_AFTER,
1013 HeaderValue::from_static(NOT_DOWNLOADED_RETRY_SECS),
1014 );
1015 headers.insert(
1016 axum::http::HeaderName::from_static("refresh"),
1017 HeaderValue::from_static(NOT_DOWNLOADED_RETRY_SECS),
1018 );
1019 headers.insert(
1020 header::CACHE_CONTROL,
1021 HeaderValue::from_static(CACHE_CONTROL_NO_STORE),
1022 );
1023}
1024
1025fn not_downloaded_response(message: Option<&'static str>) -> Response<Body> {
1027 let mut response = (
1028 StatusCode::SERVICE_UNAVAILABLE,
1029 message.unwrap_or("Not downloaded yet; retry shortly"),
1030 )
1031 .into_response();
1032 add_retry_headers(response.headers_mut());
1033 response
1034}
1035
1036#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1039enum EditReadError {
1040 NotFound,
1043 NotDownloaded,
1045 Failed,
1047 NotUtf8,
1049}
1050
1051impl IntoResponse for EditReadError {
1052 fn into_response(self) -> Response {
1053 match self {
1054 Self::NotFound => (StatusCode::NOT_FOUND, "File not found").into_response(),
1055 Self::NotDownloaded => {
1056 not_downloaded_response(Some("Note is still downloading; retry shortly"))
1057 }
1058 Self::Failed => (StatusCode::INTERNAL_SERVER_ERROR, "Read failed").into_response(),
1059 Self::NotUtf8 => (StatusCode::BAD_REQUEST, "File is not valid UTF-8").into_response(),
1060 }
1061 }
1062}
1063
1064fn is_not_downloaded(error: &MbrError) -> bool {
1068 match error {
1069 MbrError::Markdown(markdown) => is_not_downloaded_markdown(markdown),
1070 _ => false,
1071 }
1072}
1073
1074fn is_not_downloaded_markdown(error: &mbr_core::errors::MarkdownError) -> bool {
1076 match error {
1077 mbr_core::errors::MarkdownError::ReadFailed { source, .. } => {
1078 source.kind() == std::io::ErrorKind::WouldBlock
1079 || source
1080 .get_ref()
1081 .and_then(|inner| inner.downcast_ref::<VaultError>())
1082 .is_some_and(|e| matches!(e, VaultError::NotDownloaded { .. }))
1083 }
1084 _ => false,
1085 }
1086}
1087
1088fn log_render_failure(context: &str, error: &dyn std::fmt::Display, not_downloaded: bool) {
1095 if not_downloaded {
1096 tracing::debug!("{context}: not downloaded yet: {error}");
1097 } else {
1098 tracing::error!("{context}: {error}");
1099 }
1100}
1101
1102fn is_within_served_roots(
1118 path: &Path,
1119 base_dir: &Path,
1120 canonical_base_dir: Option<&Path>,
1121 static_folder: &str,
1122 mount_targets: impl FnOnce() -> Vec<PathBuf>,
1123) -> bool {
1124 let Ok(canonical) = path.canonicalize() else {
1125 return false;
1126 };
1127 let owned_base;
1128 let base = match canonical_base_dir {
1129 Some(cached) => Some(cached),
1130 None => {
1131 owned_base = base_dir.canonicalize().ok();
1132 owned_base.as_deref()
1133 }
1134 };
1135 if base.is_some_and(|base| canonical.starts_with(base)) {
1136 return true;
1137 }
1138 base_dir
1140 .join(static_folder)
1141 .canonicalize()
1142 .is_ok_and(|static_root| canonical.starts_with(static_root))
1143 || within_mount_targets(&canonical, &mount_targets())
1144}
1145
1146pub struct Server {
1147 pub router: Router,
1148 pub port: u16,
1149 pub ip: [u8; 4],
1150 pub gui_mode: bool,
1153 #[cfg(feature = "watcher")]
1156 _watcher_handle: WatcherHandle,
1157 repo: Arc<Repo>,
1159 changes: ChangeApplier,
1162 restored_files: Option<usize>,
1164 templates: mbr_core::templates::Templates,
1167}
1168
1169#[cfg(feature = "watcher")]
1171type WatcherHandle = Arc<std::sync::Mutex<Option<crate::watcher::FileWatcher>>>;
1172
1173#[cfg(feature = "watcher")]
1188#[allow(clippy::too_many_arguments)]
1189fn spawn_file_watcher(
1190 watcher_handle: WatcherHandle,
1191 repo: Arc<Repo>,
1192 base_dir: PathBuf,
1193 template_folder: Option<PathBuf>,
1194 ignore_dirs: Vec<String>,
1195 ignore_globs: Vec<String>,
1196 explicit_hidden_dirs: Vec<PathBuf>,
1197 tx: broadcast::Sender<mbr_core::change_event::FileChangeEvent>,
1198) -> WatcherHandle {
1199 let watcher_handle_for_thread = Arc::clone(&watcher_handle);
1200
1201 std::thread::spawn(move || {
1202 match crate::watcher::FileWatcher::new_with_sender(
1203 &base_dir,
1204 template_folder.as_deref(),
1205 &ignore_dirs,
1206 &ignore_globs,
1207 &explicit_hidden_dirs,
1208 tx,
1209 ) {
1210 Ok(watcher) => {
1211 tracing::info!("File watcher initialized successfully (background)");
1212 if let Ok(mut guard) = watcher_handle_for_thread.lock() {
1215 watcher.watch_mounts(&repo.mounts());
1216 *guard = Some(watcher);
1217 }
1218 }
1219 Err(e) => {
1220 tracing::warn!(
1221 "Failed to initialize file watcher: {}. Live reload disabled.",
1222 e
1223 );
1224 }
1225 }
1226 });
1227
1228 watcher_handle
1229}
1230
1231#[cfg(feature = "watcher")]
1234fn sync_watched_mounts(watcher_handle: &WatcherHandle, repo: &Repo) {
1235 if let Ok(guard) = watcher_handle.lock()
1236 && let Some(watcher) = guard.as_ref()
1237 {
1238 watcher.watch_mounts(&repo.mounts());
1239 }
1240}
1241
1242const SURGICAL_THRESHOLD: usize = 50;
1245
1246const DEBOUNCE_DURATION: std::time::Duration = std::time::Duration::from_secs(2);
1248
1249#[derive(Clone)]
1252struct ChangeApplier {
1253 repo: Arc<Repo>,
1254 base_dir: PathBuf,
1255 markdown_extensions: Vec<String>,
1256 listing_caches: ListingCaches,
1257 link_cache: Arc<LinkCache>,
1258 inbound_link_cache: Arc<InboundLinkCache>,
1259 inbound_index: Arc<InboundIndex>,
1260 link_index_config: LinkIndexConfig,
1261 index_lock: Arc<tokio::sync::Mutex<()>>,
1262 task_index: Arc<mbr_core::task_index::TaskIndex>,
1263 #[cfg(feature = "watcher")]
1264 watcher_handle: WatcherHandle,
1265}
1266
1267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1270enum ChangeSource {
1271 Events,
1275 Diff,
1280}
1281
1282impl ChangeApplier {
1283 async fn apply(
1291 &self,
1292 pending_events: Vec<mbr_core::change_event::FileChangeEvent>,
1293 source: ChangeSource,
1294 ) {
1295 let relevant_events: Vec<_> = pending_events
1297 .into_iter()
1298 .filter(|event| match event.event {
1299 mbr_core::change_event::ChangeEventType::Created
1300 | mbr_core::change_event::ChangeEventType::Deleted => true,
1301 mbr_core::change_event::ChangeEventType::Modified => {
1302 source == ChangeSource::Diff
1303 || self
1304 .markdown_extensions
1305 .iter()
1306 .any(|ext| event.relative_path.ends_with(&format!(".{}", ext)))
1307 }
1308 })
1309 .collect();
1310
1311 if relevant_events.is_empty() {
1312 return;
1313 }
1314
1315 let repo = Arc::clone(&self.repo);
1316 let base_dir = self.base_dir.clone();
1317 let inbound_index = Arc::clone(&self.inbound_index);
1318 let link_index_cfg = self.link_index_config.clone();
1319 let index_lock = Arc::clone(&self.index_lock);
1320 let task_index = Arc::clone(&self.task_index);
1321 #[cfg(feature = "watcher")]
1322 let watcher_handle = Arc::clone(&self.watcher_handle);
1323
1324 if source == ChangeSource::Diff || relevant_events.len() <= SURGICAL_THRESHOLD {
1325 tracing::debug!(
1327 "Surgical invalidation for {} file(s)",
1328 relevant_events.len()
1329 );
1330 let has_tag_changes = relevant_events.iter().any(|e| {
1331 matches!(
1332 e.event,
1333 mbr_core::change_event::ChangeEventType::Deleted
1334 | mbr_core::change_event::ChangeEventType::Modified
1335 )
1336 });
1337
1338 let _index_guard = index_lock.lock().await;
1341 tokio::task::spawn_blocking(move || {
1342 let mut index_targets: Vec<(PathBuf, Option<String>, bool)> = Vec::new();
1343 for event in &relevant_events {
1344 let abs_path = if event.path.is_empty() {
1345 continue;
1346 } else {
1347 PathBuf::from(&event.path)
1348 };
1349 index_targets.push((
1350 abs_path.clone(),
1351 repo.markdown_files
1363 .pin()
1364 .get(&abs_path)
1365 .map(|info| info.url_path.clone()),
1366 matches!(
1367 event.event,
1368 mbr_core::change_event::ChangeEventType::Deleted
1369 ),
1370 ));
1371 repo.invalidate_file(&abs_path, &event.event);
1372 task_index.invalidate_file(&abs_path, &event.event, &repo);
1378 }
1379 if has_tag_changes {
1382 repo.rebuild_tag_index();
1383 }
1384 repo.build_relationship_index();
1387 repo.build_wikilink_index();
1389
1390 if inbound_index.is_ready() {
1403 for (abs_path, known_url, is_delete) in &index_targets {
1404 if !link_index_cfg.markdown_extensions.iter().any(|ext| {
1405 abs_path
1406 .extension()
1407 .and_then(|e| e.to_str())
1408 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
1409 }) {
1410 continue;
1411 }
1412 let url_path = match known_url {
1413 Some(url) => url.clone(),
1414 None => mbr_core::repo::build_markdown_url_path(
1418 abs_path,
1419 &link_index_cfg.base_dir,
1420 &link_index_cfg.index_file,
1421 ),
1422 };
1423 if *is_delete {
1424 inbound_index.remove_page(&url_path);
1425 } else {
1426 index_page_links(
1427 &repo,
1428 &inbound_index,
1429 &link_index_cfg,
1430 abs_path,
1431 &url_path,
1432 );
1433 }
1434 }
1435 }
1436 })
1437 .await
1438 .ok();
1439 } else {
1440 tracing::info!(
1442 "Full rescan triggered: {} file changes exceed threshold",
1443 relevant_events.len()
1444 );
1445 tokio::task::spawn_blocking(move || {
1446 repo.full_rescan();
1447 #[cfg(feature = "watcher")]
1449 sync_watched_mounts(&watcher_handle, &repo);
1450 if inbound_index.is_ready() {
1455 populate_inbound_index(&repo, &inbound_index, &link_index_cfg);
1456 }
1457 task_index.rebuild_if_built(&repo);
1461 let _ = base_dir; })
1463 .await
1464 .ok();
1465 }
1466
1467 invalidate_derived_caches(
1472 &self.listing_caches,
1473 &self.link_cache,
1474 &self.inbound_link_cache,
1475 );
1476 }
1477}
1478
1479#[derive(Clone)]
1497pub struct ServerConfig {
1498 pub ip: [u8; 4],
1499 pub port: u16,
1500 pub base_dir: std::path::PathBuf,
1501 pub static_folder: String,
1502 pub markdown_extensions: Vec<String>,
1503 pub ignore_dirs: Vec<String>,
1504 pub ignore_globs: Vec<String>,
1505 pub watcher_ignore_dirs: Vec<String>,
1506 pub explicit_hidden_dirs: Vec<std::path::PathBuf>,
1510 pub symlink_mounts: bool,
1513 pub index_file: String,
1514 pub oembed_timeout_ms: u64,
1515 pub oembed_cache_size: usize,
1516 #[cfg(feature = "media-metadata")]
1520 pub media_cache_size: usize,
1521 pub template_folder: Option<std::path::PathBuf>,
1522 pub sort: Vec<SortField>,
1523 pub gui_mode: bool,
1524 pub theme: String,
1525 pub log_filter: Option<String>,
1526 pub link_tracking: bool,
1527 pub relationship_tracking: bool,
1529 pub relationship_types: Vec<RelationType>,
1531 pub tag_sources: Vec<TagSource>,
1532 pub sidebar_style: String,
1533 pub sidebar_max_items: usize,
1534 pub graph_depth: usize,
1536 pub flashcards_concentric_threshold: f64,
1538 pub flashcards_progress_indicators: bool,
1540 pub title_prefix: String,
1541 pub title_suffix: String,
1542 pub mark_incomplete: bool,
1544 pub incomplete_markers: Vec<String>,
1546 pub tasks_enabled: bool,
1548 pub review_enabled: bool,
1550 pub tasks_stamp_done: bool,
1553 pub tasks_default_include: IncludeFilter,
1555 pub tasks_ignore_globs: Vec<String>,
1558 pub edit_enabled: bool,
1560 pub edit_require_token_on_loopback: bool,
1562 pub edit_token_hash: Option<String>,
1564 pub upload_max_bytes: usize,
1566 #[cfg(feature = "media-metadata")]
1567 pub transcode_enabled: bool,
1568 pub embedded: Option<EmbeddedStorage>,
1573 pub config_problem: Option<String>,
1578}
1579
1580#[derive(Clone)]
1598pub struct EmbeddedStorage {
1599 pub vault: Arc<dyn Vault>,
1600 pub snapshot: Option<mbr_core::repo::RepoSnapshot>,
1601}
1602
1603impl ServerConfig {
1604 #[must_use]
1606 pub fn with_gui_mode(mut self, gui_mode: bool) -> Self {
1607 self.gui_mode = gui_mode;
1608 self
1609 }
1610
1611 #[must_use]
1613 pub fn with_log_filter(mut self, filter: Option<&str>) -> Self {
1614 self.log_filter = filter.map(|s| s.to_string());
1615 self
1616 }
1617}
1618
1619impl From<&mbr_core::config::Config> for ServerConfig {
1620 fn from(config: &mbr_core::config::Config) -> Self {
1621 Self {
1622 ip: config.host.0,
1623 port: config.port,
1624 base_dir: config.root_dir.clone(),
1625 static_folder: config.static_folder.clone(),
1626 markdown_extensions: config.markdown_extensions.clone(),
1627 ignore_dirs: config.ignore_dirs.clone(),
1628 ignore_globs: config.ignore_globs.clone(),
1629 watcher_ignore_dirs: config.watcher_ignore_dirs.clone(),
1630 explicit_hidden_dirs: config.explicit_hidden_dirs.clone(),
1631 symlink_mounts: !config.no_symlink_mounts,
1632 index_file: config.index_file.clone(),
1633 oembed_timeout_ms: config.oembed_timeout_ms,
1634 oembed_cache_size: config.oembed_cache_size,
1635 #[cfg(feature = "media-metadata")]
1636 media_cache_size: config.media_cache_size,
1637 template_folder: config.template_folder.clone(),
1638 sort: config.sort.clone(),
1639 gui_mode: false, theme: config.theme.clone(),
1641 log_filter: None, link_tracking: config.link_tracking,
1643 relationship_tracking: config.relationship_tracking,
1644 relationship_types: config.relationship_types.clone(),
1645 tag_sources: config.tag_sources.clone(),
1646 sidebar_style: config.sidebar_style.clone(),
1647 sidebar_max_items: config.sidebar_max_items,
1648 graph_depth: config.graph_depth,
1649 flashcards_concentric_threshold: config.flashcards_concentric_threshold,
1650 flashcards_progress_indicators: config.flashcards_progress_indicators,
1651 title_prefix: config.title_prefix.clone(),
1652 title_suffix: config.title_suffix.clone(),
1653 mark_incomplete: config.mark_incomplete.unwrap_or(true),
1655 incomplete_markers: config.incomplete_markers.clone(),
1656 tasks_enabled: config.tasks_enabled,
1657 review_enabled: config.review_enabled,
1658 tasks_stamp_done: config.tasks_stamp_done,
1659 tasks_default_include: config.tasks_default_include,
1660 tasks_ignore_globs: config.tasks_ignore_globs.clone(),
1661 edit_enabled: config.edit_enabled,
1662 edit_require_token_on_loopback: config.edit_require_token_on_loopback,
1663 edit_token_hash: config.edit_token_hash.clone(),
1664 upload_max_bytes: config.upload_max_bytes,
1665 #[cfg(feature = "media-metadata")]
1666 transcode_enabled: config.transcode,
1667 embedded: None,
1668 config_problem: None,
1669 }
1670 }
1671}
1672
1673impl ServerState {
1674 fn mount_targets(&self) -> Vec<PathBuf> {
1678 self.repo
1679 .mounts()
1680 .into_iter()
1681 .map(|(_, mount)| mount.target)
1682 .collect()
1683 }
1684
1685 fn refuse_read_only(&self, key: &Path) -> Result<(), FileOpError> {
1688 let path = self.vault_path(key)?;
1689 let vault = self.vault();
1690 let canonical = path
1691 .ancestors()
1692 .find(|ancestor| matches!(vault.stat(ancestor), Ok(Some(_))))
1693 .and_then(|existing| vault.canonicalize(&existing).ok());
1694 if vault.is_read_only(&path) || canonical.is_some_and(|c| vault.is_read_only(&c)) {
1695 return Err(FileOpError::ReadOnly);
1696 }
1697 Ok(())
1698 }
1699
1700 fn is_served(&self, key: &Path) -> bool {
1716 match self.repo.locate(key) {
1717 Some((vault, path)) if vault.local_path(&path).is_none() => true,
1718 located => {
1719 is_within_served_roots(
1720 key,
1721 self.base_dir.as_path(),
1722 self.canonical_base_dir.as_deref(),
1723 &self.static_folder,
1724 || self.mount_targets(),
1725 ) || located.is_some_and(|(vault, path)| {
1726 vault.availability(&path) == Availability::NotDownloaded
1727 })
1728 }
1729 }
1730 }
1731
1732 fn vault(&self) -> &dyn Vault {
1735 self.repo.vault().as_ref()
1736 }
1737
1738 fn vault_path(&self, key: &Path) -> Result<VaultPath, FileOpError> {
1741 Ok(self.vault().vault_path(key)?)
1742 }
1743
1744 fn key_exists(&self, key: &Path) -> bool {
1747 self.vault_path(key)
1748 .is_ok_and(|path| matches!(self.vault().stat(&path), Ok(Some(_))))
1749 }
1750
1751 fn key_is_dir(&self, key: &Path) -> bool {
1753 self.vault_path(key)
1754 .is_ok_and(|path| self.vault().is_dir(&path))
1755 }
1756
1757 fn resolver_config<'a>(&'a self, tag_url_sources: &'a [String]) -> PathResolverConfig<'a> {
1762 PathResolverConfig {
1763 vault: self.repo.vault().as_ref(),
1764 static_vault: self.repo.static_vault().map(|vault| vault.as_ref()),
1765 static_folder: &self.static_folder,
1766 markdown_extensions: &self.markdown_extensions,
1767 index_file: &self.index_file,
1768 tag_sources: tag_url_sources,
1769 exempt_hidden_dirs: self.repo.exempt_hidden_dirs(),
1770 }
1771 }
1772}
1773
1774#[derive(Clone)]
1775pub struct ServerState {
1776 pub bind_ip: [u8; 4],
1779 pub base_dir: std::path::PathBuf,
1780 pub canonical_base_dir: Option<std::path::PathBuf>,
1782 pub static_folder: String,
1783 pub markdown_extensions: Vec<String>,
1784 pub ignore_dirs: Vec<String>,
1785 pub ignore_globs: Vec<String>,
1786 pub index_file: String,
1787 pub templates: mbr_core::templates::Templates,
1788 pub repo: Arc<Repo>,
1789 pub oembed_timeout_ms: u64,
1790 pub file_change_tx: Option<broadcast::Sender<mbr_core::change_event::FileChangeEvent>>,
1791 pub template_folder: Option<std::path::PathBuf>,
1793 pub sort: Vec<SortField>,
1795 pub gui_mode: bool,
1797 pub theme: String,
1799 pub oembed_cache: Arc<OembedCache>,
1801 #[cfg(feature = "media-metadata")]
1803 pub video_metadata_cache: Arc<VideoMetadataCache>,
1804 #[cfg(feature = "media-metadata")]
1806 pub transcode_enabled: bool,
1807 #[cfg(feature = "media-metadata")]
1809 pub hls_cache: Arc<HlsCache>,
1810 #[cfg(feature = "media-metadata")]
1814 pub metadata_inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>>,
1815 #[cfg(feature = "media-metadata")]
1818 pub video_resolution_cache:
1819 Arc<papaya::HashMap<String, mbr_core::video_transcode::VideoResolution>>,
1820 #[cfg(feature = "media-metadata")]
1824 pub media_compat_cache:
1825 Arc<papaya::HashMap<String, mbr_core::video_metadata::PlaybackCompatibility>>,
1826 pub sibling_nav_cache: Arc<papaya::HashMap<PathBuf, Arc<Vec<serde_json::Value>>>>,
1832 pub subdir_cache: Arc<papaya::HashMap<PathBuf, Arc<Vec<serde_json::Value>>>>,
1835 pub site_json_cache: Arc<parking_lot::RwLock<SiteJsonCache>>,
1843 pub link_tracking: bool,
1845 pub relationship_tracking: bool,
1847 pub relationship_types: Vec<RelationType>,
1849 pub link_cache: Arc<LinkCache>,
1851 pub inbound_link_cache: Arc<InboundLinkCache>,
1853 pub inbound_index: Arc<InboundIndex>,
1857 pub inbound_grep_semaphore: Arc<tokio::sync::Semaphore>,
1861 pub inbound_grep_inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>>,
1865 pub tag_sources: Vec<TagSource>,
1867 pub sidebar_style: String,
1869 pub sidebar_max_items: usize,
1871 pub graph_depth: usize,
1873 pub flashcards_concentric_threshold: f64,
1875 pub flashcards_progress_indicators: bool,
1877 pub title_prefix: String,
1879 pub title_suffix: String,
1881 pub mark_incomplete: bool,
1883 pub incomplete_markers: Vec<String>,
1885 pub tasks_enabled: bool,
1887 pub review_enabled: bool,
1889 pub tasks_stamp_done: bool,
1892 pub tasks_default_include: IncludeFilter,
1897 pub task_index: Arc<mbr_core::task_index::TaskIndex>,
1903 pub edit_enabled: bool,
1905 pub edit_require_token_on_loopback: bool,
1907 pub edit_token_hash: Option<String>,
1909 pub upload_max_bytes: usize,
1911 pub config_problem: Option<String>,
1913 pub file_write_locks: Arc<FileWriteLocks>,
1916}
1917
1918#[derive(serde::Deserialize)]
1920pub struct EditRequest {
1921 pub content: String,
1923 pub base_hash: String,
1925}
1926
1927#[derive(serde::Deserialize)]
1932pub struct TaskToggleRequest {
1933 pub path: String,
1936 pub line: u32,
1938 pub expected: String,
1941 pub to: mbr_core::tasks::TaskStatus,
1943 #[serde(default)]
1950 pub base_hash: Option<String>,
1951}
1952
1953#[derive(serde::Serialize)]
1955pub struct TaskToggleResponse {
1956 pub line: u32,
1958 pub text: String,
1961 pub hash: String,
1963}
1964
1965#[derive(serde::Deserialize)]
1971pub struct FlashcardReviewRequest {
1972 pub path: String,
1974 pub line: u32,
1976 pub expected: String,
1978 pub rating: mbr_core::flashcards::Rating,
1980 pub at: String,
1984 #[serde(default)]
1987 pub base_hash: Option<String>,
1988}
1989
1990#[derive(serde::Serialize)]
1997pub struct FlashcardReviewResponse {
1998 pub entry: String,
2000 pub line: u32,
2002 pub inserted_at: u32,
2004 pub inserted: Vec<String>,
2007 pub hash: String,
2009}
2010
2011#[derive(serde::Deserialize)]
2013pub struct CreateRequest {
2014 pub content: String,
2016 #[serde(default)]
2018 pub create_dirs: bool,
2019}
2020
2021#[derive(serde::Deserialize)]
2023pub struct MoveRequest {
2024 pub to: String,
2026 #[serde(default)]
2028 pub create_dirs: bool,
2029}
2030
2031#[derive(serde::Serialize)]
2033pub struct CreateResponse {
2034 pub url_path: String,
2036 pub path: String,
2038}
2039
2040#[derive(serde::Serialize)]
2042pub struct MkdirResponse {
2043 pub path: String,
2045}
2046
2047#[derive(serde::Serialize)]
2049pub struct MoveResponse {
2050 pub from_url: String,
2052 pub url_path: String,
2054 pub path: String,
2056 pub rewritten: Vec<String>,
2058 pub wikilinks_rewritten: Vec<String>,
2060 pub created_dirs: bool,
2062}
2063
2064#[derive(serde::Deserialize)]
2066pub struct UploadParams {
2067 #[serde(default)]
2070 pub dir: String,
2071 pub name: String,
2073}
2074
2075#[derive(serde::Serialize)]
2077pub struct UploadResponse {
2078 pub url: String,
2081 pub path: String,
2083 pub name: String,
2085}
2086
2087#[derive(Debug)]
2092enum FileOpError {
2093 AlreadyExists,
2095 ParentMissing,
2098 NotMarkdown,
2100 InvalidUploadName,
2104 ForbiddenUploadDir,
2108 SourceNotFound,
2110 Traversal,
2112 ReadOnly,
2116 Io(std::io::Error),
2118}
2119
2120impl From<VaultError> for FileOpError {
2121 fn from(error: VaultError) -> Self {
2122 match error {
2123 VaultError::OutsideRoot { .. } | VaultError::InvalidPath(_) => Self::Traversal,
2124 VaultError::ReadOnly { .. } => Self::ReadOnly,
2125 other => Self::Io(other.into()),
2126 }
2127 }
2128}
2129
2130impl IntoResponse for FileOpError {
2131 fn into_response(self) -> Response {
2132 let (status, msg): (StatusCode, &'static str) = match self {
2133 FileOpError::AlreadyExists => (StatusCode::CONFLICT, "Target already exists"),
2134 FileOpError::ParentMissing => (
2135 StatusCode::BAD_REQUEST,
2136 "Destination parent directory does not exist (set create_dirs to create it)",
2137 ),
2138 FileOpError::NotMarkdown => (
2139 StatusCode::BAD_REQUEST,
2140 "Path must end in a markdown extension",
2141 ),
2142 FileOpError::InvalidUploadName => (
2143 StatusCode::BAD_REQUEST,
2144 "Invalid upload filename (must be a basename with an allowed media extension)",
2145 ),
2146 FileOpError::ForbiddenUploadDir => (
2147 StatusCode::BAD_REQUEST,
2148 "Uploads into the template folder are not allowed",
2149 ),
2150 FileOpError::SourceNotFound => {
2151 (StatusCode::NOT_FOUND, "Source markdown file not found")
2152 }
2153 FileOpError::Traversal => (StatusCode::BAD_REQUEST, "Invalid path"),
2154 FileOpError::ReadOnly => (
2155 StatusCode::FORBIDDEN,
2156 "This folder is read-only: it is an external folder mounted through a symlink",
2157 ),
2158 FileOpError::Io(e) => {
2159 tracing::error!("file operation I/O error: {e}");
2160 (StatusCode::INTERNAL_SERVER_ERROR, "I/O error")
2161 }
2162 };
2163 (status, msg).into_response()
2164 }
2165}
2166
2167fn resolve_new_target_path(vault: &dyn Vault, rel: &str) -> Result<VaultPath, FileOpError> {
2176 let clean = rel.trim_start_matches('/');
2177 if clean.is_empty() {
2178 return Err(FileOpError::Traversal);
2179 }
2180 for component in Path::new(clean).components() {
2184 match component {
2185 std::path::Component::Normal(_) | std::path::Component::CurDir => {}
2186 _ => return Err(FileOpError::Traversal),
2187 }
2188 }
2189 if clean.split(['/', '\\']).any(|segment| segment == "..") {
2192 return Err(FileOpError::Traversal);
2193 }
2194 let candidate = VaultPath::new(clean).map_err(|_| FileOpError::Traversal)?;
2195
2196 let existing = candidate
2200 .ancestors()
2201 .find(|ancestor| matches!(vault.stat(ancestor), Ok(Some(_))))
2202 .unwrap_or_default();
2203 vault.canonicalize(&existing)?;
2204
2205 Ok(candidate)
2206}
2207
2208const UPLOAD_URL_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
2214 .remove(b'/')
2215 .remove(b'-')
2216 .remove(b'.')
2217 .remove(b'_')
2218 .remove(b'~');
2219
2220fn sanitize_upload_name(name: &str, markdown_extensions: &[String]) -> Option<String> {
2238 let name = name.trim();
2239 if name.is_empty() {
2240 return None;
2241 }
2242 if name.contains('/') || name.contains('\\') || name.contains("..") {
2245 return None;
2246 }
2247 let path = Path::new(name);
2250 if path.file_name().and_then(|n| n.to_str()) != Some(name) {
2251 return None;
2252 }
2253 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
2255 let ext = match path.extension().and_then(|e| e.to_str()) {
2256 Some(e) if !e.is_empty() => e,
2257 _ => return None,
2258 };
2259 if stem.is_empty() {
2260 return None;
2261 }
2262 let ext_lower = ext.to_ascii_lowercase();
2263 if mbr_core::repo::is_markdown_extension(&ext_lower, markdown_extensions) {
2265 return None;
2266 }
2267 if !UPLOAD_ALLOWED_EXTENSIONS.contains(&ext_lower.as_str()) {
2269 return None;
2270 }
2271 Some(name.to_string())
2272}
2273
2274fn dedupe_name(dir: &Path, stem: &str, ext: &str, exists: impl Fn(&Path) -> bool) -> PathBuf {
2281 std::iter::once(dir.join(format!("{stem}.{ext}")))
2282 .chain((1u64..).map(|n| dir.join(format!("{stem}-{n}.{ext}"))))
2283 .find(|candidate| !exists(candidate))
2284 .expect("candidate sequence is infinite, so a free path always exists")
2285}
2286
2287#[derive(serde::Serialize)]
2295struct SiteJson<'a> {
2296 index_file: &'a str,
2297 markdown_files: &'a mbr_core::repo::MarkdownFiles,
2298 sort: &'a [SortField],
2299 sidebar_style: &'a str,
2300 sidebar_max_items: usize,
2301}
2302
2303struct SiteJsonParams {
2308 sort: Vec<SortField>,
2309 sidebar_style: String,
2310 sidebar_max_items: usize,
2311 relationship_tracking: bool,
2312}
2313
2314fn render_site_json(
2320 repo: &Repo,
2321 params: &SiteJsonParams,
2322) -> Result<axum::body::Bytes, serde_json::Error> {
2323 let mut value = serde_json::to_value(SiteJson {
2324 index_file: &repo.index_file,
2325 markdown_files: &repo.markdown_files,
2326 sort: ¶ms.sort,
2327 sidebar_style: ¶ms.sidebar_style,
2328 sidebar_max_items: params.sidebar_max_items,
2329 })?;
2330
2331 if params.relationship_tracking {
2333 repo.relationship_index.inject_into_site_json(&mut value);
2334 }
2335
2336 Ok(axum::body::Bytes::from(serde_json::to_vec(&value)?))
2337}
2338
2339impl Server {
2340 pub fn init(config: ServerConfig) -> Result<Self, ServerError> {
2342 let ServerConfig {
2343 ip,
2344 port,
2345 base_dir,
2346 static_folder,
2347 markdown_extensions,
2348 ignore_dirs,
2349 ignore_globs,
2350 watcher_ignore_dirs,
2351 explicit_hidden_dirs,
2352 symlink_mounts,
2353 index_file,
2354 oembed_timeout_ms,
2355 oembed_cache_size,
2356 #[cfg(feature = "media-metadata")]
2357 media_cache_size,
2358 template_folder,
2359 sort,
2360 gui_mode,
2361 theme,
2362 log_filter,
2363 link_tracking,
2364 relationship_tracking,
2365 relationship_types,
2366 tag_sources,
2367 sidebar_style,
2368 sidebar_max_items,
2369 graph_depth,
2370 flashcards_concentric_threshold,
2371 flashcards_progress_indicators,
2372 title_prefix,
2373 title_suffix,
2374 mark_incomplete,
2375 incomplete_markers,
2376 tasks_enabled,
2377 review_enabled,
2378 tasks_stamp_done,
2379 tasks_default_include,
2380 tasks_ignore_globs,
2381 edit_enabled,
2382 edit_require_token_on_loopback,
2383 edit_token_hash,
2384 upload_max_bytes,
2385 #[cfg(feature = "media-metadata")]
2386 transcode_enabled,
2387 embedded,
2388 config_problem,
2389 } = config;
2390
2391 let oembed_cache = Arc::new(OembedCache::new(oembed_cache_size));
2392
2393 #[cfg(feature = "media-metadata")]
2398 let video_metadata_cache = Arc::new(VideoMetadataCache::new(media_cache_size));
2399
2400 #[cfg(feature = "media-metadata")]
2401 let hls_cache = Arc::new(HlsCache::new(DEFAULT_HLS_CACHE_SIZE));
2402
2403 #[cfg(feature = "media-metadata")]
2405 let metadata_inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>> =
2406 Arc::new(papaya::HashMap::new());
2407 #[cfg(feature = "media-metadata")]
2408 let video_resolution_cache: Arc<
2409 papaya::HashMap<String, mbr_core::video_transcode::VideoResolution>,
2410 > = Arc::new(papaya::HashMap::new());
2411 #[cfg(feature = "media-metadata")]
2412 let media_compat_cache: Arc<
2413 papaya::HashMap<String, mbr_core::video_metadata::PlaybackCompatibility>,
2414 > = Arc::new(papaya::HashMap::new());
2415
2416 let listing_caches = ListingCaches {
2420 sibling_nav_cache: Arc::new(papaya::HashMap::new()),
2421 subdir_cache: Arc::new(papaya::HashMap::new()),
2422 site_json_cache: Arc::new(parking_lot::RwLock::new(SiteJsonCache::default())),
2423 };
2424
2425 let default_filter = log_filter.as_deref().unwrap_or("mbr=warn,tower_http=warn");
2428 let _ = tracing_subscriber::registry()
2429 .with(
2430 tracing_subscriber::EnvFilter::try_from_default_env()
2431 .unwrap_or_else(|_| default_filter.into()),
2432 )
2433 .with(tracing_subscriber::fmt::layer())
2434 .try_init();
2435
2436 let templates = templates::Templates::new(base_dir.as_path(), template_folder.as_deref())
2437 .map_err(ServerError::TemplateInit)?;
2438
2439 let is_embedded = embedded.is_some();
2440 let (repo, snapshot) = match embedded {
2441 None => (
2442 Repo::init(
2443 &base_dir,
2444 &static_folder,
2445 &markdown_extensions,
2446 &ignore_dirs,
2447 &ignore_globs,
2448 &index_file,
2449 &tag_sources,
2450 &relationship_types,
2451 )
2452 .with_explicit_hidden_dirs(&explicit_hidden_dirs)
2453 .with_symlink_mounts(symlink_mounts),
2454 None,
2455 ),
2456 Some(EmbeddedStorage { vault, snapshot }) => (
2457 Repo::init_with_vault(
2458 vault,
2459 static_folder.as_str(),
2460 &markdown_extensions,
2461 &ignore_dirs,
2462 &ignore_globs,
2463 index_file.as_str(),
2464 &tag_sources,
2465 &relationship_types,
2466 )
2467 .with_explicit_hidden_dirs(&explicit_hidden_dirs)
2468 .with_change_tracking(),
2469 snapshot,
2470 ),
2471 };
2472 let repo = Arc::new(repo);
2473 let restored_files = snapshot.and_then(|snapshot| match repo.restore_snapshot(snapshot) {
2477 Ok(count) => {
2478 tracing::info!("Restored {count} files from the saved index");
2479 Some(count)
2480 }
2481 Err(e) => {
2482 tracing::info!("Saved index not used ({e}); scanning");
2483 None
2484 }
2485 });
2486
2487 #[cfg(feature = "watcher")]
2492 let watcher_handle: WatcherHandle = Arc::new(std::sync::Mutex::new(None));
2493 #[cfg(feature = "watcher")]
2494 let watcher_handle_for_scan = Arc::clone(&watcher_handle);
2495 let repo_for_scan = Arc::clone(&repo);
2496 let restored = restored_files.is_some();
2497 tokio::task::spawn_blocking(move || {
2498 if !restored && let Err(e) = repo_for_scan.scan_all() {
2501 tracing::error!("Background scan failed: {e}");
2502 }
2503 repo_for_scan.build_relationship_index();
2505 repo_for_scan.build_wikilink_index();
2508 repo_for_scan.mark_scan_complete();
2509
2510 if !restored && let Err(e) = repo_for_scan.scan_static_folder() {
2512 tracing::error!("Background static scan failed: {e}");
2513 }
2514 #[cfg(feature = "watcher")]
2516 sync_watched_mounts(&watcher_handle_for_scan, &repo_for_scan);
2517
2518 repo_for_scan.populate_basic_metadata();
2520
2521 repo_for_scan.populate_media_metadata();
2523 repo_for_scan.notify_media_populated();
2524
2525 repo_for_scan.ensure_text_extracted();
2527 });
2528
2529 let (file_change_tx, _rx) = tokio::sync::broadcast::channel::<
2531 mbr_core::change_event::FileChangeEvent,
2532 >(mbr_core::change_event::BROADCAST_CAPACITY);
2533
2534 #[cfg(feature = "watcher")]
2538 let watcher_handle = if is_embedded {
2539 watcher_handle
2540 } else {
2541 spawn_file_watcher(
2542 watcher_handle,
2543 Arc::clone(&repo),
2544 base_dir.clone(),
2545 template_folder.clone(),
2546 watcher_ignore_dirs,
2547 ignore_globs.clone(),
2548 explicit_hidden_dirs.clone(),
2549 file_change_tx.clone(),
2550 )
2551 };
2552 #[cfg(not(feature = "watcher"))]
2553 let _ = is_embedded;
2554 #[cfg(not(feature = "watcher"))]
2556 let _ = watcher_ignore_dirs;
2557
2558 let templates_for_reload = templates.clone();
2560 let templates_for_server = templates.clone();
2561 let template_folder_for_reload = template_folder
2566 .clone()
2567 .map(|tf| tf.canonicalize().unwrap_or(tf));
2568 let mut template_change_rx = file_change_tx.subscribe();
2569 tokio::spawn(async move {
2570 loop {
2571 let event = match live_reload_action(template_change_rx.recv().await) {
2576 LiveReloadAction::Forward(event) => event,
2577 LiveReloadAction::Skip => continue,
2578 LiveReloadAction::Close => break,
2579 };
2580
2581 if !event.path.ends_with(".html") {
2583 continue;
2584 }
2585
2586 if should_reload_template(&event.path, template_folder_for_reload.as_deref()) {
2589 tracing::debug!("Template file changed: {}", event.path);
2590 reload_templates(&templates_for_reload);
2591 }
2592 }
2593 });
2594
2595 let link_cache = Arc::new(LinkCache::new(DEFAULT_LINK_CACHE_SIZE));
2601 let inbound_link_cache = Arc::new(InboundLinkCache::new(
2602 DEFAULT_INBOUND_LINK_CACHE_SIZE,
2603 INBOUND_LINK_CACHE_TTL_SECS,
2604 ));
2605
2606 let inbound_index = Arc::new(InboundIndex::new());
2611 let index_lock = Arc::new(tokio::sync::Mutex::new(()));
2615 let link_index_config = LinkIndexConfig {
2616 base_dir: base_dir.clone(),
2617 index_file: index_file.clone(),
2618 markdown_extensions: markdown_extensions.clone(),
2619 valid_tag_sources: mbr_core::config::tag_sources_to_set(&tag_sources),
2620 };
2621 if link_tracking {
2622 let repo_for_index = Arc::clone(&repo);
2623 let index_for_build = Arc::clone(&inbound_index);
2624 let cfg_for_build = link_index_config.clone();
2625 let index_lock_for_build = Arc::clone(&index_lock);
2626 tokio::spawn(async move {
2627 repo_for_index.wait_for_scan().await;
2628 let _guard = index_lock_for_build.lock().await;
2629 let repo = Arc::clone(&repo_for_index);
2630 tokio::task::spawn_blocking(move || {
2631 populate_inbound_index(&repo, &index_for_build, &cfg_for_build);
2632 })
2633 .await
2634 .unwrap_or_else(|e| tracing::error!("Backlink index build task failed: {e}"));
2635 });
2636 }
2637
2638 let task_index = Arc::new(mbr_core::task_index::TaskIndex::with_markers(
2653 &tasks_ignore_globs,
2654 if mark_incomplete {
2655 &incomplete_markers
2656 } else {
2657 &[]
2658 },
2659 ));
2660
2661 let change_applier = ChangeApplier {
2667 repo: Arc::clone(&repo),
2668 base_dir: base_dir.clone(),
2669 markdown_extensions: markdown_extensions.clone(),
2670 listing_caches: listing_caches.clone(),
2671 link_cache: Arc::clone(&link_cache),
2672 inbound_link_cache: Arc::clone(&inbound_link_cache),
2673 inbound_index: Arc::clone(&inbound_index),
2674 link_index_config: link_index_config.clone(),
2675 index_lock: Arc::clone(&index_lock),
2676 task_index: Arc::clone(&task_index),
2677 #[cfg(feature = "watcher")]
2678 watcher_handle: Arc::clone(&watcher_handle),
2679 };
2680
2681 let applier_for_task = change_applier.clone();
2686 let mut repo_change_rx = file_change_tx.subscribe();
2687 tokio::spawn(async move {
2688 loop {
2689 let first_event = match repo_change_rx.recv().await {
2691 Ok(e) => e,
2692 Err(broadcast::error::RecvError::Closed) => break,
2693 Err(broadcast::error::RecvError::Lagged(_)) => continue,
2694 };
2695
2696 let mut pending_events = vec![first_event];
2698 let deadline = tokio::time::Instant::now() + DEBOUNCE_DURATION;
2699
2700 loop {
2701 tokio::select! {
2702 result = repo_change_rx.recv() => {
2703 match result {
2704 Ok(event) => pending_events.push(event),
2705 Err(broadcast::error::RecvError::Closed) => break,
2706 Err(broadcast::error::RecvError::Lagged(_)) => {
2707 pending_events.clear();
2709 pending_events.push(mbr_core::change_event::FileChangeEvent {
2710 path: String::new(),
2711 relative_path: String::new(),
2712 event: mbr_core::change_event::ChangeEventType::Created,
2713 });
2714 for _ in 0..SURGICAL_THRESHOLD {
2716 pending_events.push(mbr_core::change_event::FileChangeEvent {
2717 path: String::new(),
2718 relative_path: String::new(),
2719 event: mbr_core::change_event::ChangeEventType::Created,
2720 });
2721 }
2722 break;
2723 }
2724 }
2725 }
2726 _ = tokio::time::sleep_until(deadline) => break,
2727 }
2728 }
2729
2730 applier_for_task
2731 .apply(pending_events, ChangeSource::Events)
2732 .await;
2733 }
2734 });
2735
2736 let inbound_grep_semaphore =
2737 Arc::new(tokio::sync::Semaphore::new(INBOUND_GREP_MAX_CONCURRENCY));
2738 let inbound_grep_inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>> =
2739 Arc::new(papaya::HashMap::new());
2740
2741 let canonical_base_dir = base_dir.canonicalize().ok();
2742 let repo_for_server = Arc::clone(&repo);
2743 let state = ServerState {
2744 bind_ip: ip,
2745 base_dir,
2746 canonical_base_dir,
2747 static_folder,
2748 markdown_extensions,
2749 ignore_dirs,
2750 ignore_globs,
2751 index_file,
2752 templates,
2753 repo,
2754 oembed_timeout_ms,
2755 file_change_tx: Some(file_change_tx),
2756 template_folder,
2757 sort,
2758 gui_mode,
2759 theme,
2760 oembed_cache,
2761 #[cfg(feature = "media-metadata")]
2762 video_metadata_cache,
2763 #[cfg(feature = "media-metadata")]
2764 transcode_enabled,
2765 #[cfg(feature = "media-metadata")]
2766 hls_cache,
2767 #[cfg(feature = "media-metadata")]
2768 metadata_inflight,
2769 #[cfg(feature = "media-metadata")]
2770 video_resolution_cache,
2771 #[cfg(feature = "media-metadata")]
2772 media_compat_cache,
2773 sibling_nav_cache: listing_caches.sibling_nav_cache,
2774 subdir_cache: listing_caches.subdir_cache,
2775 site_json_cache: listing_caches.site_json_cache,
2776 link_tracking,
2777 relationship_tracking,
2778 relationship_types,
2779 link_cache,
2780 inbound_link_cache,
2781 inbound_index,
2782 inbound_grep_semaphore,
2783 inbound_grep_inflight,
2784 tag_sources,
2785 sidebar_style,
2786 sidebar_max_items,
2787 graph_depth,
2788 flashcards_concentric_threshold,
2789 flashcards_progress_indicators,
2790 title_prefix,
2791 title_suffix,
2792 mark_incomplete,
2793 incomplete_markers,
2794 tasks_enabled,
2795 review_enabled,
2796 tasks_stamp_done,
2797 tasks_default_include,
2798 task_index,
2799 file_write_locks: Arc::new(FileWriteLocks::default()),
2800 edit_enabled,
2801 edit_require_token_on_loopback,
2802 edit_token_hash,
2803 upload_max_bytes,
2804 config_problem,
2805 };
2806
2807 let router = Router::new()
2808 .route("/", get(Self::home_page))
2809 .route("/.mbr/site.json", get(Self::get_site_info))
2810 .route("/.mbr/media.json", get(Self::get_media_info))
2811 .route("/.mbr/search", post(Self::search_handler))
2812 .route("/.mbr/tasks", post(Self::tasks_handler))
2814 .route("/.mbr/raw/{*path}", get(Self::raw_markdown_handler))
2816 .route(
2817 "/.mbr/edit/{*path}",
2818 post(Self::save_markdown_handler)
2819 .layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
2821 )
2822 .route("/.mbr/task", post(Self::task_toggle_handler))
2826 .route(
2829 "/.mbr/flashcard-review",
2830 post(Self::flashcard_review_handler),
2831 )
2832 .route(
2835 "/.mbr/create/{*path}",
2836 post(Self::create_markdown_handler)
2837 .layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
2839 )
2840 .route("/.mbr/move/{*path}", post(Self::move_markdown_handler))
2841 .route("/.mbr/mkdir/{*path}", post(Self::mkdir_handler))
2842 .route(
2845 "/.mbr/upload",
2846 post(Self::upload_handler).layer(DefaultBodyLimit::max(upload_max_bytes)),
2847 )
2848 .route("/.mbr/ws/changes", get(Self::websocket_handler))
2849 .route("/.mbr/videos/", get(Self::serve_media_viewer))
2851 .route("/.mbr/pdfs/", get(Self::serve_media_viewer))
2852 .route("/.mbr/audio/", get(Self::serve_media_viewer))
2853 .route("/.mbr/images/", get(Self::serve_media_viewer))
2854 .route("/.mbr/{*path}", get(Self::serve_mbr_assets))
2855 .route("/{*path}", get(Self::handle))
2856 .layer(CompressionLayer::new().compress_when(compression_predicate()))
2857 .layer(TraceLayer::new_for_http().on_failure(on_request_failure))
2858 .with_state(state);
2859
2860 Ok(Server {
2861 router,
2862 ip,
2863 port,
2864 gui_mode,
2865 #[cfg(feature = "watcher")]
2866 _watcher_handle: watcher_handle,
2867 repo: repo_for_server,
2868 changes: change_applier,
2869 restored_files,
2870 templates: templates_for_server,
2871 })
2872 }
2873
2874 pub fn reload_templates(&self) -> bool {
2879 reload_templates(&self.templates)
2880 }
2881
2882 pub fn repo(&self) -> &Arc<Repo> {
2884 &self.repo
2885 }
2886
2887 pub fn restored_files(&self) -> Option<usize> {
2891 self.restored_files
2892 }
2893
2894 pub async fn refresh(&self) -> mbr_core::repo::RepoDiff {
2905 let repo = Arc::clone(&self.repo);
2906 let diff = tokio::task::spawn_blocking(move || repo.diff_vault())
2907 .await
2908 .unwrap_or_default();
2909 if !diff.is_empty() {
2910 let root = self.repo.vault().root().to_path_buf();
2911 self.changes
2912 .apply(diff.events(&root), ChangeSource::Diff)
2913 .await;
2914 }
2915 self.repo.record_diff(&diff);
2916 diff
2917 }
2918
2919 fn announce_listening(&self, local_addr: SocketAddr) {
2934 tracing::debug!("listening on {}", local_addr);
2935 if self.gui_mode {
2936 tracing::info!("Server running at http://{}/", local_addr);
2937 } else {
2938 println!("Server running at http://{}/", local_addr);
2939 }
2940 }
2941
2942 pub async fn start(&self) -> Result<(), ServerError> {
2943 self.start_with_ready_signal(None).await
2944 }
2945
2946 pub async fn start_with_ready_signal(
2949 &self,
2950 ready_tx: Option<tokio::sync::oneshot::Sender<()>>,
2951 ) -> Result<(), ServerError> {
2952 let addr = SocketAddr::from((self.ip, self.port));
2953 let listener =
2954 tokio::net::TcpListener::bind(addr)
2955 .await
2956 .map_err(|e| ServerError::BindFailed {
2957 addr: addr.to_string(),
2958 source: e,
2959 })?;
2960 let local_addr = listener
2961 .local_addr()
2962 .map_err(ServerError::LocalAddrFailed)?;
2963 self.announce_listening(local_addr);
2964
2965 if let Some(tx) = ready_tx
2967 && tx.send(()).is_err()
2968 {
2969 tracing::debug!("Ready signal receiver dropped (shutdown in progress)");
2970 }
2971
2972 axum::serve(
2973 listener,
2974 self.router
2975 .clone()
2976 .into_make_service_with_connect_info::<SocketAddr>(),
2977 )
2978 .await
2979 .map_err(ServerError::StartFailed)?;
2980 Ok(())
2981 }
2982
2983 pub async fn start_with_port_retry(
2992 &mut self,
2993 ready_tx: Option<tokio::sync::oneshot::Sender<u16>>,
2994 max_retries: u16,
2995 ) -> Result<(), ServerError> {
2996 let mut attempts = 0;
2997
2998 loop {
2999 let addr = SocketAddr::from((self.ip, self.port));
3000 match tokio::net::TcpListener::bind(addr).await {
3001 Ok(listener) => {
3002 let local_addr = listener
3003 .local_addr()
3004 .map_err(ServerError::LocalAddrFailed)?;
3005 self.announce_listening(local_addr);
3006
3007 if let Some(tx) = ready_tx
3009 && tx.send(self.port).is_err()
3010 {
3011 tracing::debug!("Port signal receiver dropped (shutdown in progress)");
3012 }
3013
3014 axum::serve(
3015 listener,
3016 self.router
3017 .clone()
3018 .into_make_service_with_connect_info::<SocketAddr>(),
3019 )
3020 .await
3021 .map_err(ServerError::StartFailed)?;
3022 return Ok(());
3023 }
3024 Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && attempts < max_retries => {
3025 let old_port = self.port;
3026 if self.port == 65535 {
3028 return Err(ServerError::BindFailed {
3029 addr: "port range exhausted (reached port 65535)".into(),
3030 source: e,
3031 });
3032 }
3033 self.port += 1;
3034 attempts += 1;
3035 eprintln!(
3036 "Warning: Port {} already in use, trying port {}",
3037 old_port, self.port
3038 );
3039 tracing::warn!(
3040 "Port {} already in use, trying port {}",
3041 old_port,
3042 self.port
3043 );
3044 }
3045 Err(e) => {
3046 return Err(ServerError::BindFailed {
3047 addr: addr.to_string(),
3048 source: e,
3049 });
3050 }
3051 }
3052 }
3053 }
3054
3055 pub async fn websocket_handler(
3066 ws: WebSocketUpgrade,
3067 State(config): State<ServerState>,
3068 headers: HeaderMap,
3069 ) -> Response {
3070 if !headers.contains_key(header::ORIGIN) || !Self::is_same_origin(&headers) {
3071 tracing::warn!("Blocked cross-origin live-reload WebSocket upgrade");
3072 return (
3073 StatusCode::FORBIDDEN,
3074 "Cross-origin WebSocket upgrade blocked",
3075 )
3076 .into_response();
3077 }
3078 ws.on_upgrade(|socket| Self::handle_websocket(socket, config))
3079 .into_response()
3080 }
3081
3082 async fn handle_websocket(socket: axum::extract::ws::WebSocket, config: ServerState) {
3083 let (mut sender, mut receiver) = socket.split();
3084
3085 let Some(file_change_tx) = config.file_change_tx else {
3087 tracing::warn!("WebSocket connection attempted but file watcher is disabled");
3088 if let Err(e) = sender
3089 .send(axum::extract::ws::Message::Text(
3090 r#"{"error":"File watcher not available"}"#.into(),
3091 ))
3092 .await
3093 {
3094 tracing::debug!("Failed to send error to WebSocket client: {e}");
3095 }
3096 return;
3097 };
3098
3099 let mut rx = file_change_tx.subscribe();
3101
3102 tracing::info!("WebSocket client connected for live reload");
3103
3104 if sender
3106 .send(axum::extract::ws::Message::Text(
3107 r#"{"status":"connected"}"#.to_string().into(),
3108 ))
3109 .await
3110 .is_err()
3111 {
3112 return;
3113 }
3114
3115 loop {
3117 tokio::select! {
3118 result = rx.recv() => {
3124 let change_event = match live_reload_action(result) {
3125 LiveReloadAction::Forward(event) => event,
3126 LiveReloadAction::Skip => continue,
3127 LiveReloadAction::Close => break,
3128 };
3129
3130 let json = match serde_json::to_string(&change_event) {
3131 Ok(j) => j,
3132 Err(e) => {
3133 tracing::error!("Failed to serialize change event: {}", e);
3134 continue;
3135 }
3136 };
3137
3138 if sender
3139 .send(axum::extract::ws::Message::Text(json.into()))
3140 .await
3141 .is_err()
3142 {
3143 tracing::info!("WebSocket client disconnected");
3144 break;
3145 }
3146 }
3147
3148 msg = receiver.next() => {
3150 match msg {
3151 Some(Ok(axum::extract::ws::Message::Close(_))) => {
3152 tracing::info!("WebSocket client closed connection");
3153 break;
3154 }
3155 #[allow(clippy::collapsible_match)]
3156 Some(Ok(axum::extract::ws::Message::Ping(data))) => {
3157 if sender
3158 .send(axum::extract::ws::Message::Pong(data))
3159 .await
3160 .is_err()
3161 {
3162 break;
3163 }
3164 }
3165 Some(Err(e)) => {
3166 tracing::error!("WebSocket error: {}", e);
3167 break;
3168 }
3169 None => {
3170 tracing::info!("WebSocket stream ended");
3171 break;
3172 }
3173 _ => {}
3174 }
3175 }
3176 }
3177 }
3178 }
3179
3180 pub async fn get_site_info(
3187 State(config): State<ServerState>,
3188 ) -> Result<impl IntoResponse, StatusCode> {
3189 if !config.repo.is_scan_complete() {
3191 tracing::debug!("get_site_info: waiting for background scan...");
3192 config.repo.wait_for_scan().await;
3193 }
3194
3195 let (generation, cached) = config.site_json_cache.read().snapshot();
3196 let body = match cached {
3197 Some(body) => body,
3198 None => {
3199 let json_start = std::time::Instant::now();
3200 let repo = Arc::clone(&config.repo);
3201 let params = SiteJsonParams {
3202 sort: config.sort.clone(),
3203 sidebar_style: config.sidebar_style.clone(),
3204 sidebar_max_items: config.sidebar_max_items,
3205 relationship_tracking: config.relationship_tracking,
3206 };
3207 let built = tokio::task::spawn_blocking(move || render_site_json(&repo, ¶ms))
3208 .await
3209 .map_err(|e| {
3210 tracing::error!("site.json render task failed: {e}");
3211 StatusCode::INTERNAL_SERVER_ERROR
3212 })?
3213 .inspect_err(|e| tracing::error!("Error serializing site json: {e}"))
3214 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
3215 config
3220 .site_json_cache
3221 .write()
3222 .store(generation, built.clone());
3223 tracing::debug!(
3224 "get_site_info JSON serialization: {:?}",
3225 json_start.elapsed()
3226 );
3227 built
3228 }
3229 };
3230
3231 Response::builder()
3232 .status(StatusCode::OK)
3233 .header("Content-Type", "application/json")
3234 .body(Body::from(body))
3235 .inspect_err(|e| tracing::error!("Error rendering site file: {e}"))
3236 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
3237 .map(IntoResponse::into_response)
3238 }
3239
3240 pub async fn get_media_info(
3245 State(config): State<ServerState>,
3246 ) -> Result<impl IntoResponse, StatusCode> {
3247 let start = std::time::Instant::now();
3248
3249 if !config.repo.is_scan_complete() {
3251 tracing::debug!("get_media_info: waiting for background scan...");
3252 config.repo.wait_for_scan().await;
3253 }
3254 if !config.repo.is_media_populated() {
3255 tracing::debug!("get_media_info: waiting for media metadata...");
3256 config.repo.wait_for_media().await;
3257 }
3258
3259 let json_start = std::time::Instant::now();
3261 let media_data = serde_json::json!({
3262 "other_files": &config.repo.other_files,
3263 });
3264
3265 let resp = Response::builder()
3266 .status(StatusCode::OK)
3267 .header("Content-Type", "application/json")
3268 .body(
3269 serde_json::to_string(&media_data)
3270 .inspect_err(|e| tracing::error!("Error serializing media json: {e}"))
3271 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
3272 )
3273 .inspect_err(|e| tracing::error!("Error rendering media file: {e}"))
3274 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
3275 tracing::debug!(
3276 "get_media_info completed in {:?} (JSON: {:?})",
3277 start.elapsed(),
3278 json_start.elapsed()
3279 );
3280 Ok(resp.into_response())
3281 }
3282
3283 pub async fn search_handler(
3308 State(config): State<ServerState>,
3309 Json(query): Json<SearchQuery>,
3310 ) -> impl IntoResponse {
3311 tracing::debug!("Search request: q={:?}, scope={:?}", query.q, query.scope);
3312
3313 let scan_in_progress = !config.repo.is_scan_complete();
3315
3316 if config.repo.is_scan_complete()
3318 && (query.filetype.as_deref() == Some("all")
3319 || (query.filetype.is_some() && query.filetype.as_deref() != Some("markdown")))
3320 {
3321 config.repo.ensure_text_extracted();
3322 }
3323
3324 let repo = config.repo.clone();
3325
3326 let query_str = query.q.clone();
3328
3329 let search_result = tokio::task::spawn_blocking(move || {
3331 let engine = SearchEngine::new(repo.clone());
3332 let mut response = engine.search(&query)?;
3333
3334 if query.filetype.as_deref() == Some("all")
3336 || (query.filetype.is_some() && query.filetype.as_deref() != Some("markdown"))
3337 {
3338 let other_results = search_other_files(
3339 &repo,
3340 &query.q,
3341 query.folder.as_deref(),
3342 query.filetype.as_deref(),
3343 query.limit,
3344 );
3345
3346 response.results.extend(other_results);
3348 response.results.sort_by_key(|r| std::cmp::Reverse(r.score));
3349 response.results.truncate(query.limit);
3350 response.total_matches = response.results.len();
3351 }
3352
3353 Ok::<_, mbr_core::errors::SearchError>(response)
3354 })
3355 .await;
3356
3357 let mut response = match search_result {
3358 Ok(Ok(r)) => r,
3359 Ok(Err(e)) => {
3360 tracing::error!("Search error: {e}");
3361 return (
3362 StatusCode::INTERNAL_SERVER_ERROR,
3363 Json(serde_json::json!({
3364 "error": format!("Search failed: {}", e),
3365 "query": query_str,
3366 "total_matches": 0,
3367 "results": [],
3368 "duration_ms": 0
3369 })),
3370 );
3371 }
3372 Err(e) => {
3373 tracing::error!("Search task panicked: {e}");
3374 return (
3375 StatusCode::INTERNAL_SERVER_ERROR,
3376 Json(serde_json::json!({
3377 "error": "Search task failed",
3378 "query": query_str,
3379 "total_matches": 0,
3380 "results": [],
3381 "duration_ms": 0
3382 })),
3383 );
3384 }
3385 };
3386
3387 response.scan_in_progress = scan_in_progress;
3388
3389 tracing::debug!(
3390 "Search completed: {} results in {}ms",
3391 response.total_matches,
3392 response.duration_ms
3393 );
3394
3395 match serde_json::to_value(&response) {
3396 Ok(value) => (StatusCode::OK, Json(value)),
3397 Err(e) => {
3398 tracing::error!("Failed to serialize search response: {e}");
3399 (
3400 StatusCode::INTERNAL_SERVER_ERROR,
3401 Json(serde_json::json!({
3402 "error": "Failed to serialize search response",
3403 "query": query_str,
3404 "total_matches": 0,
3405 "results": [],
3406 "duration_ms": 0
3407 })),
3408 )
3409 }
3410 }
3411 }
3412
3413 pub async fn tasks_handler(
3440 State(config): State<ServerState>,
3441 Json(query): Json<mbr_core::task_query::TaskQuery>,
3442 ) -> Response<Body> {
3443 if !config.tasks_enabled {
3444 return Self::tasks_error(StatusCode::NOT_FOUND, "Task browsing is disabled");
3445 }
3446
3447 let scan_in_progress = !config.repo.is_scan_complete();
3448
3449 if let Err(e) = config.task_index.ensure_built(&config.repo).await {
3450 tracing::error!("Task index build failed: {e}");
3451 return Self::tasks_error(
3452 StatusCode::INTERNAL_SERVER_ERROR,
3453 "Failed to build the task index",
3454 );
3455 }
3456
3457 let files = config.task_index.snapshot();
3461 let today = chrono::Local::now().date_naive();
3462 let result = tokio::task::spawn_blocking(move || {
3463 mbr_core::task_query::run_query(&files, &query, today)
3464 })
3465 .await;
3466
3467 let mut response = match result {
3468 Ok(response) => response,
3469 Err(e) => {
3470 tracing::error!("Task query task panicked: {e}");
3471 return Self::tasks_error(
3472 StatusCode::INTERNAL_SERVER_ERROR,
3473 "Task query task failed",
3474 );
3475 }
3476 };
3477 response.scan_in_progress = scan_in_progress;
3478
3479 tracing::debug!(
3480 "Task query completed: {} matches in {} group(s), {}ms",
3481 response.total_matches,
3482 response.groups.len(),
3483 response.duration_ms
3484 );
3485
3486 match serde_json::to_vec(&response) {
3487 Ok(body) => build_response_or_500(
3488 Response::builder()
3489 .status(StatusCode::OK)
3490 .header("Content-Type", "application/json")
3491 .body(Body::from(body)),
3492 ),
3493 Err(e) => {
3494 tracing::error!("Failed to serialize task response: {e}");
3495 Self::tasks_error(
3496 StatusCode::INTERNAL_SERVER_ERROR,
3497 "Failed to serialize task response",
3498 )
3499 }
3500 }
3501 }
3502
3503 fn tasks_error(status: StatusCode, message: &str) -> Response<Body> {
3506 let body = serde_json::json!({
3507 "error": message,
3508 "groups": [],
3509 "folders": [],
3510 "total_matches": 0,
3511 "duration_ms": 0,
3512 "scan_in_progress": false,
3513 });
3514 build_response_or_500(
3515 Response::builder()
3516 .status(status)
3517 .header("Content-Type", "application/json")
3518 .body(Body::from(body.to_string())),
3519 )
3520 }
3521
3522 fn check_edit_access(
3546 config: &ServerState,
3547 headers: &HeaderMap,
3548 peer_ip: std::net::IpAddr,
3549 ) -> Result<(), (StatusCode, &'static str)> {
3550 if !config.edit_enabled {
3551 return Err((StatusCode::FORBIDDEN, "Editing is not enabled"));
3552 }
3553
3554 let csrf_ok = headers
3557 .get("x-mbr-edit")
3558 .and_then(|v| v.to_str().ok())
3559 .map(|v| v == "1")
3560 .unwrap_or(false);
3561 if !csrf_ok {
3562 return Err((
3563 StatusCode::FORBIDDEN,
3564 "Missing or invalid X-MBR-Edit header",
3565 ));
3566 }
3567
3568 if !Self::is_same_origin(headers) {
3569 return Err((StatusCode::FORBIDDEN, "Cross-origin edit request blocked"));
3570 }
3571
3572 let token_configured = config.edit_token_hash.is_some();
3575 if !Self::host_may_edit(config, headers) {
3576 return Err((
3577 StatusCode::FORBIDDEN,
3578 "Host header does not name this server",
3579 ));
3580 }
3581
3582 let caller_is_loopback = peer_ip.is_loopback();
3583 let require_token =
3584 token_configured || !caller_is_loopback || config.edit_require_token_on_loopback;
3585 if require_token {
3586 let provided = headers
3587 .get(header::AUTHORIZATION)
3588 .and_then(|v| v.to_str().ok())
3589 .and_then(|v| v.strip_prefix("Bearer "))
3590 .map(str::trim);
3591 let ok = match (&config.edit_token_hash, provided) {
3592 (Some(hash), Some(token)) => mbr_core::edit_auth::verify_token(hash, token),
3593 _ => false,
3594 };
3595 if !ok {
3596 return Err((StatusCode::UNAUTHORIZED, "A valid edit token is required"));
3597 }
3598 }
3599
3600 Ok(())
3601 }
3602
3603 fn host_may_edit(config: &ServerState, headers: &HeaderMap) -> bool {
3612 config.edit_token_hash.is_some() || host_header_is_allowed(headers, config.bind_ip)
3613 }
3614
3615 fn page_edit_enabled(config: &ServerState, headers: &HeaderMap) -> bool {
3622 config.edit_enabled && Self::host_may_edit(config, headers)
3623 }
3624
3625 fn is_same_origin(headers: &HeaderMap) -> bool {
3633 if let Some(sfs) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) {
3634 return sfs == "same-origin" || sfs == "none";
3635 }
3636 match (
3637 headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()),
3638 headers.get(header::HOST).and_then(|v| v.to_str().ok()),
3639 ) {
3640 (Some(origin), Some(host)) => origin
3641 .split_once("://")
3642 .map(|(_, origin_host)| origin_host == host)
3643 .unwrap_or(false),
3644 (None, _) => true,
3646 _ => false,
3647 }
3648 }
3649
3650 fn resolve_editable_markdown(
3656 config: &ServerState,
3657 path: &str,
3658 ) -> Result<PathBuf, (StatusCode, &'static str)> {
3659 let tag_url_sources = mbr_core::config::tag_sources_to_url_sources(&config.tag_sources);
3660 let resolver_config = config.resolver_config(&tag_url_sources);
3661
3662 match resolve_request_path(&resolver_config, path) {
3663 ResolvedPath::MarkdownFile(md_path) => {
3664 let vault = config.vault();
3668 match vault
3669 .vault_path(&md_path)
3670 .and_then(|path| vault.canonicalize(&path))
3671 {
3672 Ok(canonical) if vault.is_file(&canonical) => Ok(vault.key(&canonical)),
3673 _ => Err((StatusCode::BAD_REQUEST, "Invalid path")),
3674 }
3675 }
3676 _ => Err((StatusCode::NOT_FOUND, "Not an editable markdown file")),
3677 }
3678 }
3679
3680 async fn resolve_editable_markdown_settled(
3693 config: &ServerState,
3694 path: &str,
3695 ) -> Result<PathBuf, (StatusCode, &'static str)> {
3696 const RETRIES: usize = 3;
3697 const PAUSE: std::time::Duration = std::time::Duration::from_millis(20);
3698 let mut result = Self::resolve_editable_markdown(config, path);
3699 for _ in 0..RETRIES {
3700 if result.is_ok() {
3701 break;
3702 }
3703 tokio::time::sleep(PAUSE).await;
3704 result = Self::resolve_editable_markdown(config, path);
3705 }
3706 result
3707 }
3708
3709 pub async fn raw_markdown_handler(
3712 extract::Path(path): extract::Path<String>,
3713 State(config): State<ServerState>,
3714 ConnectInfo(peer): ConnectInfo<SocketAddr>,
3715 headers: HeaderMap,
3716 ) -> Response {
3717 if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
3718 return err.into_response();
3719 }
3720 let md_path = match Self::resolve_editable_markdown_settled(&config, &path).await {
3721 Ok(p) => p,
3722 Err(err) => return err.into_response(),
3723 };
3724
3725 match Self::read_editable_bytes(&config, &md_path, "editing").await {
3726 Ok(bytes) => {
3727 let hash = mbr_core::edit_auth::content_hash(&bytes);
3728 let mut resp = (StatusCode::OK, bytes).into_response();
3729 resp.headers_mut().insert(
3730 header::CONTENT_TYPE,
3731 HeaderValue::from_static("text/markdown; charset=utf-8"),
3732 );
3733 if let Ok(hv) = HeaderValue::from_str(&hash) {
3734 resp.headers_mut().insert("x-mbr-content-hash", hv);
3735 }
3736 resp
3737 }
3738 Err(error) => error.into_response(),
3739 }
3740 }
3741
3742 pub async fn save_markdown_handler(
3746 extract::Path(path): extract::Path<String>,
3747 State(config): State<ServerState>,
3748 ConnectInfo(peer): ConnectInfo<SocketAddr>,
3749 headers: HeaderMap,
3750 Json(req): Json<EditRequest>,
3751 ) -> Response {
3752 if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
3753 return err.into_response();
3754 }
3755 let md_path = match Self::resolve_editable_markdown_settled(&config, &path).await {
3756 Ok(p) => p,
3757 Err(err) => return err.into_response(),
3758 };
3759 if let Err(err) = config.refuse_read_only(&md_path) {
3760 return err.into_response();
3761 }
3762 let _write_guard = config.file_write_locks.lock(&md_path).await;
3765
3766 let current = match Self::read_editable_bytes(&config, &md_path, "save").await {
3768 Ok(bytes) => bytes,
3769 Err(error) => return error.into_response(),
3770 };
3771 if let Some(conflict) = Self::base_hash_conflict(¤t, Some(&req.base_hash)) {
3772 return conflict;
3773 }
3774
3775 let new_bytes = req.content.into_bytes();
3776 let new_hash = mbr_core::edit_auth::content_hash(&new_bytes);
3777
3778 match Self::atomic_write_file_async(&config, &md_path, new_bytes).await {
3779 Ok(()) => {}
3780 Err(e @ FileOpError::ReadOnly) => return e.into_response(),
3781 Err(e) => {
3782 tracing::error!("Failed to save markdown: {e:?}");
3783 return (StatusCode::INTERNAL_SERVER_ERROR, "Write failed").into_response();
3784 }
3785 }
3786
3787 if let Some(tx) = &config.file_change_tx {
3789 let relative =
3790 pathdiff::diff_paths(&md_path, &config.base_dir).unwrap_or_else(|| md_path.clone());
3791 let _ = tx.send(mbr_core::change_event::FileChangeEvent {
3792 path: md_path.to_string_lossy().to_string(),
3793 relative_path: relative.to_string_lossy().to_string(),
3794 event: mbr_core::change_event::ChangeEventType::Modified,
3795 });
3796 }
3797
3798 let mut resp = (StatusCode::OK, "Saved").into_response();
3799 if let Ok(hv) = HeaderValue::from_str(&new_hash) {
3800 resp.headers_mut().insert("x-mbr-content-hash", hv);
3801 }
3802 resp
3803 }
3804
3805 pub async fn task_toggle_handler(
3844 State(config): State<ServerState>,
3845 ConnectInfo(peer): ConnectInfo<SocketAddr>,
3846 headers: HeaderMap,
3847 Json(req): Json<TaskToggleRequest>,
3848 ) -> Response {
3849 if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
3850 return err.into_response();
3851 }
3852 let md_path = match Self::resolve_editable_markdown_settled(&config, &req.path).await {
3853 Ok(p) => p,
3854 Err(err) => return err.into_response(),
3855 };
3856 if let Err(err) = config.refuse_read_only(&md_path) {
3857 return err.into_response();
3858 }
3859 let _write_guard = config.file_write_locks.lock(&md_path).await;
3860
3861 let source = match Self::read_markdown_source(&config, &md_path, "task toggle").await {
3862 Ok(source) => source,
3863 Err(error) => return error.into_response(),
3864 };
3865 if let Some(conflict) =
3866 Self::base_hash_conflict(source.as_bytes(), req.base_hash.as_deref())
3867 {
3868 return conflict;
3869 }
3870
3871 let stamp = config
3873 .tasks_stamp_done
3874 .then(|| chrono::Local::now().naive_local());
3875 let patched =
3876 match mbr_core::tasks::patch_task_line(&source, req.line, &req.expected, req.to, stamp)
3877 {
3878 Ok(patched) => patched,
3879 Err(e) => {
3880 let status = match e {
3881 TaskPatchError::Mismatch { .. } => StatusCode::CONFLICT,
3882 TaskPatchError::LineOutOfRange { .. } | TaskPatchError::NotATask { .. } => {
3883 StatusCode::BAD_REQUEST
3884 }
3885 };
3886 return (status, e.to_string()).into_response();
3887 }
3888 };
3889
3890 let hash = mbr_core::edit_auth::content_hash(patched.source.as_bytes());
3891 if let Err(e) =
3892 Self::atomic_write_file_async(&config, &md_path, patched.source.into_bytes()).await
3893 {
3894 tracing::error!("Failed to write task toggle: {e:?}");
3895 return e.into_response();
3896 }
3897
3898 Self::announce_in_place_edit(&config, &md_path);
3899
3900 Json(TaskToggleResponse {
3901 line: req.line,
3902 text: patched.text,
3903 hash,
3904 })
3905 .into_response()
3906 }
3907
3908 fn base_hash_conflict(current: &[u8], base_hash: Option<&str>) -> Option<Response> {
3917 let base_hash = base_hash?;
3918 (mbr_core::edit_auth::content_hash(current) != base_hash).then(|| {
3919 (
3920 StatusCode::CONFLICT,
3921 "File changed on disk since it was loaded",
3922 )
3923 .into_response()
3924 })
3925 }
3926
3927 async fn read_markdown_source(
3933 config: &ServerState,
3934 md_path: &Path,
3935 action: &str,
3936 ) -> Result<String, EditReadError> {
3937 let bytes = Self::read_editable_bytes(config, md_path, action).await?;
3938 String::from_utf8(bytes).map_err(|_| EditReadError::NotUtf8)
3939 }
3940
3941 fn announce_in_place_edit(config: &ServerState, md_path: &Path) {
3947 Self::broadcast_change(
3948 config,
3949 md_path,
3950 mbr_core::change_event::ChangeEventType::Modified,
3951 );
3952 config.task_index.invalidate_file(
3953 md_path,
3954 &mbr_core::change_event::ChangeEventType::Modified,
3955 &config.repo,
3956 );
3957 }
3958
3959 pub async fn flashcard_review_handler(
3995 State(config): State<ServerState>,
3996 ConnectInfo(peer): ConnectInfo<SocketAddr>,
3997 headers: HeaderMap,
3998 Json(req): Json<FlashcardReviewRequest>,
3999 ) -> Response {
4000 if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
4001 return err.into_response();
4002 }
4003 let md_path = match Self::resolve_editable_markdown_settled(&config, &req.path).await {
4004 Ok(p) => p,
4005 Err(err) => return err.into_response(),
4006 };
4007 if let Err(err) = config.refuse_read_only(&md_path) {
4008 return err.into_response();
4009 }
4010 let reviewed_at = match mbr_core::flashcards::parse_review_time(
4011 &req.at,
4012 chrono::Utc::now().naive_utc(),
4013 ) {
4014 Ok(time) => time,
4015 Err(e) => return (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()).into_response(),
4016 };
4017 let _write_guard = config.file_write_locks.lock(&md_path).await;
4018 let source = match Self::read_markdown_source(&config, &md_path, "flashcard review").await {
4019 Ok(source) => source,
4020 Err(error) => return error.into_response(),
4021 };
4022 if let Some(conflict) =
4023 Self::base_hash_conflict(source.as_bytes(), req.base_hash.as_deref())
4024 {
4025 return conflict;
4026 }
4027
4028 let patch = match mbr_core::flashcards::append_review(
4029 &source,
4030 req.line,
4031 &req.expected,
4032 req.rating,
4033 reviewed_at,
4034 ) {
4035 Ok(patch) => patch,
4036 Err(e) => return (StatusCode::CONFLICT, e.to_string()).into_response(),
4037 };
4038
4039 let entry_line = patch.entry_line();
4040 let hash = mbr_core::edit_auth::content_hash(patch.source.as_bytes());
4041 if let Err(e) =
4042 Self::atomic_write_file_async(&config, &md_path, patch.source.into_bytes()).await
4043 {
4044 tracing::error!("Failed to write flashcard review: {e:?}");
4045 return e.into_response();
4046 }
4047 Self::announce_in_place_edit(&config, &md_path);
4048
4049 Json(FlashcardReviewResponse {
4050 line: entry_line,
4051 entry: patch.entry,
4052 inserted_at: patch.inserted_at,
4053 inserted: patch.inserted,
4054 hash,
4055 })
4056 .into_response()
4057 }
4058
4059 fn resolve_new_target(config: &ServerState, rel: &str) -> Result<PathBuf, FileOpError> {
4068 let vault = config.vault();
4069 resolve_new_target_path(vault, rel).map(|path| vault.key(&path))
4070 }
4071
4072 fn path_has_markdown_extension(path: &Path, exts: &[String]) -> bool {
4074 path.extension()
4075 .and_then(|e| e.to_str())
4076 .map(|e| mbr_core::repo::is_markdown_extension(&e.to_lowercase(), exts))
4077 .unwrap_or(false)
4078 }
4079
4080 fn path_is_index(path: &Path, index_file: &str) -> bool {
4082 path.file_name()
4083 .and_then(|n| n.to_str())
4084 .is_some_and(|n| n == index_file)
4085 }
4086
4087 fn rel_path_string(path: &Path, base_dir: &Path) -> String {
4093 let relative = pathdiff::diff_paths(path, base_dir).unwrap_or_else(|| path.to_path_buf());
4094 mbr_core::url_path::path_to_url(&relative)
4095 }
4096
4097 fn atomic_write_file(
4103 config: &ServerState,
4104 path: &Path,
4105 bytes: &[u8],
4106 ) -> Result<(), FileOpError> {
4107 let target = config.vault_path(path)?;
4108 Ok(config.vault().write_atomic(&target, bytes)?)
4109 }
4110
4111 async fn atomic_write_file_async(
4113 config: &ServerState,
4114 path: &Path,
4115 bytes: Vec<u8>,
4116 ) -> Result<(), FileOpError> {
4117 let vault = Arc::clone(config.repo.vault());
4118 let target = config.vault_path(path)?;
4119 tokio::task::spawn_blocking(move || vault.write_atomic(&target, &bytes))
4120 .await
4121 .map_err(|e| FileOpError::Io(std::io::Error::other(e)))?
4122 .map_err(FileOpError::from)
4123 }
4124
4125 async fn read_editable_bytes(
4145 config: &ServerState,
4146 path: &Path,
4147 action: &str,
4148 ) -> Result<Vec<u8>, EditReadError> {
4149 let vault = Arc::clone(config.repo.vault());
4150 let source = vault.vault_path(path).map_err(|e| {
4151 tracing::error!("Failed to read markdown for {action}: {e}");
4152 EditReadError::NotFound
4153 })?;
4154 let deadline = Instant::now() + MBR_ASSET_DOWNLOAD_WAIT;
4155 let mut requested = false;
4156 loop {
4157 if vault.availability(&source) != Availability::NotDownloaded {
4158 let (reader, target) = (Arc::clone(&vault), source.clone());
4159 match tokio::task::spawn_blocking(move || reader.read(&target)).await {
4160 Ok(Ok(bytes)) => return Ok(bytes),
4161 Ok(Err(VaultError::NotDownloaded { .. })) => {}
4162 Ok(Err(e)) => {
4163 tracing::error!("Failed to read markdown for {action}: {e}");
4164 return Err(EditReadError::NotFound);
4165 }
4166 Err(e) => {
4167 tracing::error!("Failed to read markdown for {action}: {e}");
4168 return Err(EditReadError::Failed);
4169 }
4170 }
4171 }
4172 if !requested {
4173 requested = true;
4174 vault.request_download(std::slice::from_ref(&source));
4175 continue; }
4177 if Instant::now() >= deadline {
4178 tracing::debug!(
4179 "{source} not downloaded after {}ms for {action}; asking for a retry",
4180 MBR_ASSET_DOWNLOAD_WAIT.as_millis()
4181 );
4182 return Err(EditReadError::NotDownloaded);
4183 }
4184 tokio::time::sleep(MBR_ASSET_DOWNLOAD_POLL).await;
4185 }
4186 }
4187
4188 fn broadcast_change(
4190 config: &ServerState,
4191 abs_path: &Path,
4192 event: mbr_core::change_event::ChangeEventType,
4193 ) {
4194 if let Some(tx) = &config.file_change_tx {
4195 let relative = pathdiff::diff_paths(abs_path, &config.base_dir)
4196 .unwrap_or_else(|| abs_path.to_path_buf());
4197 let _ = tx.send(mbr_core::change_event::FileChangeEvent {
4198 path: abs_path.to_string_lossy().to_string(),
4199 relative_path: relative.to_string_lossy().to_string(),
4200 event,
4201 });
4202 }
4203 }
4204
4205 pub async fn create_markdown_handler(
4208 extract::Path(path): extract::Path<String>,
4209 State(config): State<ServerState>,
4210 ConnectInfo(peer): ConnectInfo<SocketAddr>,
4211 headers: HeaderMap,
4212 Json(req): Json<CreateRequest>,
4213 ) -> Response {
4214 if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
4215 return err.into_response();
4216 }
4217 let result =
4218 tokio::task::spawn_blocking(move || Self::do_create(&config, &path, req)).await;
4219 match result {
4220 Ok(Ok(resp)) => (StatusCode::OK, Json(resp)).into_response(),
4221 Ok(Err(e)) => e.into_response(),
4222 Err(e) => {
4223 tracing::error!("create task panicked: {e}");
4224 (StatusCode::INTERNAL_SERVER_ERROR, "Create failed").into_response()
4225 }
4226 }
4227 }
4228
4229 fn do_create(
4231 config: &ServerState,
4232 rel: &str,
4233 req: CreateRequest,
4234 ) -> Result<CreateResponse, FileOpError> {
4235 let dst = Self::resolve_new_target(config, rel)?;
4236 config.refuse_read_only(&dst)?;
4237 if !Self::path_has_markdown_extension(&dst, &config.markdown_extensions) {
4238 return Err(FileOpError::NotMarkdown);
4239 }
4240 if config.key_exists(&dst) {
4241 return Err(FileOpError::AlreadyExists);
4242 }
4243 let parent = dst.parent().unwrap_or_else(|| Path::new("."));
4244 if !config.key_exists(parent) {
4245 if req.create_dirs {
4246 config.vault().create_dir_all(&config.vault_path(parent)?)?;
4247 } else {
4248 return Err(FileOpError::ParentMissing);
4249 }
4250 }
4251 Self::atomic_write_file(config, &dst, req.content.as_bytes())?;
4252
4253 let url_path =
4254 mbr_core::repo::build_markdown_url_path(&dst, &config.base_dir, &config.index_file);
4255 let rel_path = Self::rel_path_string(&dst, &config.base_dir);
4256
4257 config
4260 .repo
4261 .invalidate_file(&dst, &mbr_core::change_event::ChangeEventType::Created);
4262 config.repo.build_relationship_index();
4263 config.repo.build_wikilink_index();
4264 ListingCaches::from(config).invalidate();
4265 config.inbound_link_cache.invalidate_all();
4266
4267 Self::broadcast_change(
4268 config,
4269 &dst,
4270 mbr_core::change_event::ChangeEventType::Created,
4271 );
4272
4273 Ok(CreateResponse {
4274 url_path,
4275 path: rel_path,
4276 })
4277 }
4278
4279 pub async fn mkdir_handler(
4282 extract::Path(path): extract::Path<String>,
4283 State(config): State<ServerState>,
4284 ConnectInfo(peer): ConnectInfo<SocketAddr>,
4285 headers: HeaderMap,
4286 ) -> Response {
4287 if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
4288 return err.into_response();
4289 }
4290 let result = tokio::task::spawn_blocking(move || Self::do_mkdir(&config, &path)).await;
4291 match result {
4292 Ok(Ok(resp)) => (StatusCode::OK, Json(resp)).into_response(),
4293 Ok(Err(e)) => e.into_response(),
4294 Err(e) => {
4295 tracing::error!("mkdir task panicked: {e}");
4296 (StatusCode::INTERNAL_SERVER_ERROR, "Mkdir failed").into_response()
4297 }
4298 }
4299 }
4300
4301 fn do_mkdir(config: &ServerState, rel: &str) -> Result<MkdirResponse, FileOpError> {
4303 let target = Self::resolve_new_target(config, rel)?;
4304 config.refuse_read_only(&target)?;
4305 let rel_path = Self::rel_path_string(&target, &config.base_dir);
4306 if config.key_is_dir(&target) {
4307 return Ok(MkdirResponse { path: rel_path });
4309 }
4310 if config.key_exists(&target) {
4311 return Err(FileOpError::AlreadyExists);
4313 }
4314 config
4315 .vault()
4316 .create_dir_all(&config.vault_path(&target)?)?;
4317 Self::broadcast_change(
4318 config,
4319 &target,
4320 mbr_core::change_event::ChangeEventType::Created,
4321 );
4322 Ok(MkdirResponse { path: rel_path })
4323 }
4324
4325 pub async fn upload_handler(
4331 State(config): State<ServerState>,
4332 ConnectInfo(peer): ConnectInfo<SocketAddr>,
4333 headers: HeaderMap,
4334 extract::Query(params): extract::Query<UploadParams>,
4335 body: axum::body::Bytes,
4336 ) -> Response {
4337 if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
4338 return err.into_response();
4339 }
4340 let result = tokio::task::spawn_blocking(move || {
4341 Self::do_upload(&config, ¶ms.dir, ¶ms.name, &body)
4342 })
4343 .await;
4344 match result {
4345 Ok(Ok(resp)) => (StatusCode::OK, Json(resp)).into_response(),
4346 Ok(Err(e)) => e.into_response(),
4347 Err(e) => {
4348 tracing::error!("upload task panicked: {e}");
4349 (StatusCode::INTERNAL_SERVER_ERROR, "Upload failed").into_response()
4350 }
4351 }
4352 }
4353
4354 fn do_upload(
4356 config: &ServerState,
4357 dir: &str,
4358 name: &str,
4359 bytes: &[u8],
4360 ) -> Result<UploadResponse, FileOpError> {
4361 let safe_name = sanitize_upload_name(name, &config.markdown_extensions)
4362 .ok_or(FileOpError::InvalidUploadName)?;
4363
4364 let dir_clean = dir.trim_matches('/');
4367 let rel = if dir_clean.is_empty() {
4368 safe_name.clone()
4369 } else {
4370 format!("{dir_clean}/{safe_name}")
4371 };
4372 let target = Self::resolve_new_target(config, &rel)?;
4373 config.refuse_read_only(&target)?;
4374
4375 if is_template_folder_path(
4380 &target,
4381 config.base_dir.as_path(),
4382 config.canonical_base_dir.as_deref(),
4383 config.template_folder.as_deref(),
4384 ) {
4385 return Err(FileOpError::ForbiddenUploadDir);
4386 }
4387
4388 let dest_dir = target
4392 .parent()
4393 .map(Path::to_path_buf)
4394 .unwrap_or_else(|| PathBuf::from("."));
4395 if !config.key_exists(&dest_dir) {
4396 config
4397 .vault()
4398 .create_dir_all(&config.vault_path(&dest_dir)?)?;
4399 }
4400
4401 let name_path = Path::new(&safe_name);
4403 let stem = name_path
4404 .file_stem()
4405 .and_then(|s| s.to_str())
4406 .unwrap_or(&safe_name);
4407 let ext = name_path.extension().and_then(|e| e.to_str()).unwrap_or("");
4408 let final_path = dedupe_name(&dest_dir, stem, ext, |p| config.key_exists(p));
4409
4410 Self::atomic_write_file(config, &final_path, bytes)?;
4411
4412 let root_abs = mbr_core::repo::build_static_url_path(
4416 &final_path,
4417 &config.base_dir,
4418 &config.static_folder,
4419 );
4420 let url = utf8_percent_encode(&root_abs, UPLOAD_URL_ENCODE_SET).to_string();
4421 let rel_path = Self::rel_path_string(&final_path, &config.base_dir);
4422 let final_name = final_path
4423 .file_name()
4424 .and_then(|n| n.to_str())
4425 .unwrap_or(&safe_name)
4426 .to_string();
4427
4428 Self::broadcast_change(
4431 config,
4432 &final_path,
4433 mbr_core::change_event::ChangeEventType::Created,
4434 );
4435
4436 Ok(UploadResponse {
4437 url,
4438 path: rel_path,
4439 name: final_name,
4440 })
4441 }
4442
4443 pub async fn move_markdown_handler(
4447 extract::Path(path): extract::Path<String>,
4448 State(config): State<ServerState>,
4449 ConnectInfo(peer): ConnectInfo<SocketAddr>,
4450 headers: HeaderMap,
4451 Json(req): Json<MoveRequest>,
4452 ) -> Response {
4453 if let Err(err) = Self::check_edit_access(&config, &headers, peer.ip()) {
4454 return err.into_response();
4455 }
4456
4457 let _permit = match config.inbound_grep_semaphore.clone().acquire_owned().await {
4460 Ok(p) => p,
4461 Err(_) => {
4462 return (StatusCode::INTERNAL_SERVER_ERROR, "Server shutting down").into_response();
4463 }
4464 };
4465
4466 let result = tokio::task::spawn_blocking(move || Self::do_move(&config, &path, req)).await;
4467 match result {
4468 Ok(Ok(resp)) => (StatusCode::OK, Json(resp)).into_response(),
4469 Ok(Err(e)) => e.into_response(),
4470 Err(e) => {
4471 tracing::error!("move task panicked: {e}");
4472 (StatusCode::INTERNAL_SERVER_ERROR, "Move failed").into_response()
4473 }
4474 }
4475 }
4476
4477 fn do_move(
4479 config: &ServerState,
4480 from: &str,
4481 req: MoveRequest,
4482 ) -> Result<MoveResponse, FileOpError> {
4483 let src = Self::resolve_editable_markdown(config, from).map_err(|(status, _)| {
4485 if status == StatusCode::NOT_FOUND {
4486 FileOpError::SourceNotFound
4487 } else {
4488 FileOpError::Traversal
4489 }
4490 })?;
4491 config.refuse_read_only(&src)?;
4494 let dst = Self::resolve_new_target(config, &req.to)?;
4495 config.refuse_read_only(&dst)?;
4496 if !Self::path_has_markdown_extension(&dst, &config.markdown_extensions) {
4497 return Err(FileOpError::NotMarkdown);
4498 }
4499
4500 let vault = config.vault();
4503 let src_path = config.vault_path(&src)?;
4504 let dst_path = config.vault_path(&dst)?;
4505 let dst_exists = config.key_exists(&dst);
4506 let case_only =
4507 dst_exists && vault.canonicalize(&dst_path).ok().as_ref() == Some(&src_path);
4508 if dst_exists && !case_only {
4509 return Err(FileOpError::AlreadyExists);
4510 }
4511
4512 let parent = dst.parent().unwrap_or_else(|| Path::new("."));
4514 let mut created_dirs = false;
4515 if !config.key_exists(parent) {
4516 if req.create_dirs {
4517 vault.create_dir_all(&config.vault_path(parent)?)?;
4518 created_dirs = true;
4519 } else {
4520 return Err(FileOpError::ParentMissing);
4521 }
4522 }
4523
4524 let old_url =
4526 mbr_core::repo::build_markdown_url_path(&src, &config.base_dir, &config.index_file);
4527 let new_url =
4528 mbr_core::repo::build_markdown_url_path(&dst, &config.base_dir, &config.index_file);
4529 let old_is_index = Self::path_is_index(&src, &config.index_file);
4530 let new_is_index = Self::path_is_index(&dst, &config.index_file);
4531
4532 let move_guards = config
4541 .file_write_locks
4542 .lock_all_blocking(&[src.as_path(), dst.as_path()]);
4543
4544 let src_meta = mbr_core::markdown::extract_metadata_from_vault(vault, &src_path).ok();
4547 let old_stem = src
4548 .file_stem()
4549 .and_then(|s| s.to_str())
4550 .unwrap_or_default()
4551 .to_string();
4552 let new_stem = dst
4553 .file_stem()
4554 .and_then(|s| s.to_str())
4555 .unwrap_or_default()
4556 .to_string();
4557 let delta = Self::wikilink_delta_names(
4558 src_meta.as_ref().map(|m| &m.metadata),
4559 &old_stem,
4560 &new_stem,
4561 );
4562
4563 let src_content = vault.read_to_string(&src_path)?;
4566 let moved_content = crate::link_rewrite::rewrite_moved_file_outbound_links(
4567 &old_url,
4568 old_is_index,
4569 &new_url,
4570 new_is_index,
4571 &config.markdown_extensions,
4572 &src_content,
4573 );
4574
4575 match (vault.local_path(&src_path), vault.local_path(&dst_path)) {
4579 (Some(local_src), Some(local_dst)) => {
4580 Self::replace_moved_file_locally(
4581 &local_src,
4582 &local_dst,
4583 &moved_content,
4584 case_only,
4585 )?;
4586 }
4587 _ => {
4590 if case_only {
4591 vault.remove_file(&src_path)?;
4592 vault.write_atomic(&dst_path, moved_content.as_bytes())?;
4593 } else {
4594 vault.write_atomic(&dst_path, moved_content.as_bytes())?;
4595 vault.remove_file(&src_path)?;
4596 }
4597 }
4598 }
4599 drop(move_guards);
4600
4601 let mut skip: HashSet<PathBuf> = HashSet::new();
4604 skip.insert(dst.clone());
4605 if let Ok(c) = vault.canonicalize(&dst_path) {
4606 skip.insert(vault.key(&c));
4607 }
4608
4609 let rewritten_paths = crate::link_rewrite::rewrite_inbound_links_for_move(
4611 &old_url,
4612 &new_url,
4613 vault,
4614 &config.markdown_extensions,
4615 &config.ignore_dirs,
4616 &config.ignore_globs,
4617 &skip,
4618 Some(&config.file_write_locks),
4619 )
4620 .map_err(FileOpError::Io)?;
4621
4622 let wiki_paths = crate::link_rewrite::rewrite_bare_wikilinks_for_rename(
4624 &delta,
4625 &old_url,
4626 vault,
4627 &config.markdown_extensions,
4628 &config.ignore_dirs,
4629 &config.ignore_globs,
4630 &config.index_file,
4631 &config.repo.wikilink_index,
4632 &skip,
4633 Some(&config.file_write_locks),
4634 )
4635 .map_err(FileOpError::Io)?;
4636
4637 config
4639 .repo
4640 .invalidate_file(&src, &mbr_core::change_event::ChangeEventType::Deleted);
4641 config
4642 .repo
4643 .invalidate_file(&dst, &mbr_core::change_event::ChangeEventType::Created);
4644 let mut changed_union: Vec<PathBuf> = Vec::new();
4645 for p in rewritten_paths.iter().chain(wiki_paths.iter()) {
4646 if !changed_union.iter().any(|q| q == p) {
4647 changed_union.push(p.clone());
4648 }
4649 }
4650 for p in &changed_union {
4651 config
4652 .repo
4653 .invalidate_file(p, &mbr_core::change_event::ChangeEventType::Modified);
4654 }
4655 config.repo.build_relationship_index();
4656 config.repo.build_wikilink_index();
4657 config.repo.rebuild_tag_index();
4658 ListingCaches::from(config).invalidate();
4659 config.inbound_link_cache.invalidate_all();
4660 config.link_cache.invalidate_all();
4661
4662 Self::broadcast_change(
4663 config,
4664 &src,
4665 mbr_core::change_event::ChangeEventType::Deleted,
4666 );
4667 Self::broadcast_change(
4668 config,
4669 &dst,
4670 mbr_core::change_event::ChangeEventType::Created,
4671 );
4672 for p in &changed_union {
4673 Self::broadcast_change(config, p, mbr_core::change_event::ChangeEventType::Modified);
4674 }
4675
4676 let to_urls = |paths: &[PathBuf]| -> Vec<String> {
4677 paths
4678 .iter()
4679 .map(|p| {
4680 mbr_core::repo::build_markdown_url_path(p, &config.base_dir, &config.index_file)
4681 })
4682 .collect()
4683 };
4684
4685 Ok(MoveResponse {
4686 from_url: old_url,
4687 url_path: new_url,
4688 path: Self::rel_path_string(&dst, &config.base_dir),
4689 rewritten: to_urls(&rewritten_paths),
4690 wikilinks_rewritten: to_urls(&wiki_paths),
4691 created_dirs,
4692 })
4693 }
4694
4695 fn replace_moved_file_locally(
4702 src: &Path,
4703 dst: &Path,
4704 moved_content: &str,
4705 case_only: bool,
4706 ) -> Result<(), FileOpError> {
4707 let parent_dir = dst.parent().unwrap_or_else(|| Path::new("."));
4708 let dst_name = dst
4709 .file_name()
4710 .and_then(|n| n.to_str())
4711 .unwrap_or("file.md");
4712 let (tmp, mut tmp_file) =
4713 create_unique_temp_file(parent_dir, dst_name).map_err(FileOpError::Io)?;
4714 let written = std::io::Write::write_all(&mut tmp_file, moved_content.as_bytes())
4716 .and_then(|()| std::fs::metadata(src))
4717 .and_then(|meta| tmp_file.set_permissions(meta.permissions()));
4718 drop(tmp_file);
4719 if let Err(e) = written {
4720 let _ = std::fs::remove_file(&tmp);
4721 return Err(FileOpError::Io(e));
4722 }
4723 let rename_result = if case_only {
4724 std::fs::remove_file(src).and_then(|()| std::fs::rename(&tmp, dst))
4725 } else {
4726 std::fs::rename(&tmp, dst).and_then(|()| std::fs::remove_file(src))
4727 };
4728 if let Err(e) = rename_result {
4729 let _ = std::fs::remove_file(&tmp);
4730 return Err(FileOpError::Io(e));
4731 }
4732 Ok(())
4733 }
4734
4735 fn wikilink_delta_names(
4740 frontmatter: Option<&mbr_core::markdown::SimpleMetadata>,
4741 old_stem: &str,
4742 new_stem: &str,
4743 ) -> Vec<(String, String)> {
4744 let title_for = |stem: &str| -> String {
4745 frontmatter
4746 .and_then(|fm| fm.get("title"))
4747 .and_then(|v| v.as_str())
4748 .map(|s| s.to_string())
4749 .unwrap_or_else(|| stem.to_string())
4750 };
4751 let aliases: Vec<String> = frontmatter
4752 .map(mbr_core::contact::alias_names_in)
4753 .unwrap_or_default();
4754
4755 let old_title = title_for(old_stem);
4756 let new_title = title_for(new_stem);
4757
4758 let mut new_names: HashSet<String> = HashSet::new();
4761 new_names.insert(mbr_core::relationships::normalize_name(new_stem));
4762 new_names.insert(mbr_core::relationships::normalize_name(&new_title));
4763 for a in &aliases {
4764 new_names.insert(mbr_core::relationships::normalize_name(a));
4765 }
4766
4767 let mut out = Vec::new();
4768 let mut seen = HashSet::new();
4769 for cand in std::iter::once(old_stem.to_string())
4770 .chain(std::iter::once(old_title))
4771 .chain(aliases.iter().cloned())
4772 {
4773 let norm = mbr_core::relationships::normalize_name(&cand);
4774 if new_names.contains(&norm) || !seen.insert(norm) {
4775 continue;
4776 }
4777 out.push((cand, new_stem.to_string()));
4778 }
4779 out
4780 }
4781
4782 pub async fn serve_media_viewer(
4793 State(config): State<ServerState>,
4794 OriginalUri(uri): OriginalUri,
4795 extract::Query(query): extract::Query<MediaViewerQuery>,
4796 ) -> impl IntoResponse {
4797 use serde_json::json;
4798
4799 let route_path = uri.path();
4801 let media_type = match MediaViewerType::from_route(route_path) {
4802 Some(mt) => mt,
4803 None => {
4804 tracing::error!("Invalid media viewer route: {}", route_path);
4805 return Self::render_error_page(
4806 &config.templates,
4807 StatusCode::NOT_FOUND,
4808 "Not Found",
4809 Some("Invalid media viewer route"),
4810 route_path,
4811 config.gui_mode,
4812 &config.sidebar_style,
4813 config.sidebar_max_items,
4814 config.graph_depth,
4815 config.tasks_enabled,
4816 config.tasks_default_include,
4817 );
4818 }
4819 };
4820
4821 let media_path = match &query.path {
4823 Some(p) if !p.is_empty() => p,
4824 _ => {
4825 tracing::warn!("Media viewer called without path parameter");
4826 return Self::render_error_page(
4827 &config.templates,
4828 StatusCode::BAD_REQUEST,
4829 "Bad Request",
4830 Some("Missing required 'path' query parameter"),
4831 route_path,
4832 config.gui_mode,
4833 &config.sidebar_style,
4834 config.sidebar_max_items,
4835 config.graph_depth,
4836 config.tasks_enabled,
4837 config.tasks_default_include,
4838 );
4839 }
4840 };
4841
4842 let validated_path = match validate_media_path_with_mounts(
4844 media_path,
4845 &config.base_dir,
4846 &config.static_folder,
4847 &config.mount_targets(),
4848 ) {
4849 Ok(p) => p,
4850 Err(MbrError::DirectoryTraversal) => {
4851 tracing::warn!("Directory traversal attempt: {}", media_path);
4852 return Self::render_error_page(
4853 &config.templates,
4854 StatusCode::FORBIDDEN,
4855 "Forbidden",
4856 Some("Access denied: Invalid path"),
4857 route_path,
4858 config.gui_mode,
4859 &config.sidebar_style,
4860 config.sidebar_max_items,
4861 config.graph_depth,
4862 config.tasks_enabled,
4863 config.tasks_default_include,
4864 );
4865 }
4866 Err(MbrError::InvalidMediaPath(msg)) => {
4867 tracing::warn!("Invalid media path: {} - {}", media_path, msg);
4868 return Self::render_error_page(
4869 &config.templates,
4870 StatusCode::NOT_FOUND,
4871 "Not Found",
4872 Some(&format!("Media file not found: {}", msg)),
4873 route_path,
4874 config.gui_mode,
4875 &config.sidebar_style,
4876 config.sidebar_max_items,
4877 config.graph_depth,
4878 config.tasks_enabled,
4879 config.tasks_default_include,
4880 );
4881 }
4882 Err(e) => {
4883 tracing::error!("Unexpected error validating media path: {}", e);
4884 return Self::render_error_page(
4885 &config.templates,
4886 StatusCode::INTERNAL_SERVER_ERROR,
4887 "Internal Server Error",
4888 Some("Failed to validate media path"),
4889 route_path,
4890 config.gui_mode,
4891 &config.sidebar_style,
4892 config.sidebar_max_items,
4893 config.graph_depth,
4894 config.tasks_enabled,
4895 config.tasks_default_include,
4896 );
4897 }
4898 };
4899
4900 let title = validated_path
4902 .file_stem()
4903 .and_then(|s| s.to_str())
4904 .unwrap_or("Media Viewer")
4905 .to_string();
4906
4907 let url_path = std::path::Path::new(media_path);
4910 let breadcrumbs =
4911 generate_breadcrumbs(url_path.parent().unwrap_or(std::path::Path::new("")));
4912 let breadcrumbs_json = page_context::breadcrumbs_to_json(&breadcrumbs, &UrlMode::Absolute);
4913
4914 let parent_path = url_path.parent().and_then(|p| p.to_str()).map(|p| {
4916 if p.is_empty() || p == "/" {
4917 "/".to_string()
4918 } else {
4919 let clean = p.trim_start_matches('/');
4921 format!("/{}/", clean)
4922 }
4923 });
4924
4925 let mut context = std::collections::HashMap::new();
4927 context.insert("media_type".to_string(), json!(media_type.as_str()));
4928 context.insert("title".to_string(), json!(title));
4929 context.insert("media_path".to_string(), json!(media_path));
4930 context.insert("breadcrumbs".to_string(), json!(breadcrumbs_json));
4931 if let Some(parent) = parent_path {
4932 context.insert("parent_path".to_string(), json!(parent));
4933 }
4934 page_context::insert_page_chrome(
4935 &mut context,
4936 &PageChrome {
4937 mode: ModeFlags::Server {
4938 gui_mode: Some(config.gui_mode),
4939 mbr_base: true,
4940 },
4941 sidebar_style: &config.sidebar_style,
4942 sidebar_max_items: config.sidebar_max_items,
4943 graph_depth: config.graph_depth,
4944 tasks_enabled: config.tasks_enabled,
4945 review_enabled: config.review_enabled,
4946 tasks_default_include: config.tasks_default_include,
4947 title_affixes: Some((&config.title_prefix, &config.title_suffix)),
4948 root_dir: Some(&config.base_dir),
4949 },
4950 );
4951
4952 match config.templates.render_media_viewer(context) {
4954 Ok(html) => {
4955 let etag = generate_etag(html.as_bytes());
4956 build_response_or_500(
4957 Response::builder()
4958 .status(StatusCode::OK)
4959 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
4960 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_STORE)
4961 .header(header::ETAG, etag)
4962 .body(Body::from(html)),
4963 )
4964 }
4965 Err(e) => {
4966 tracing::error!("Failed to render media viewer template: {}", e);
4967 Self::render_error_page(
4968 &config.templates,
4969 StatusCode::INTERNAL_SERVER_ERROR,
4970 "Internal Server Error",
4971 Some("Failed to render media viewer"),
4972 route_path,
4973 config.gui_mode,
4974 &config.sidebar_style,
4975 config.sidebar_max_items,
4976 config.graph_depth,
4977 config.tasks_enabled,
4978 config.tasks_default_include,
4979 )
4980 }
4981 }
4982 }
4983
4984 pub async fn serve_mbr_assets(
4999 extract::Path(path): extract::Path<String>,
5000 State(config): State<ServerState>,
5001 ) -> Result<impl IntoResponse, StatusCode> {
5002 tracing::debug!("serve_mbr_assets: {}", path);
5003
5004 let asset_path = if path.starts_with('/') {
5006 path.clone()
5007 } else {
5008 format!("/{}", path)
5009 };
5010
5011 if !is_servable_mbr_asset(&asset_path) {
5014 tracing::debug!("serve_mbr_assets: not a servable asset: {}", asset_path);
5015 return Err(StatusCode::NOT_FOUND);
5016 }
5017
5018 if let Some(ref template_folder) = config.template_folder {
5020 let relative_path = if asset_path.starts_with("/components/") {
5022 let component_name = asset_path
5023 .strip_prefix("/components/")
5024 .unwrap_or(&asset_path);
5025 format!("components-js/{}", component_name)
5026 } else {
5027 asset_path.trim_start_matches('/').to_string()
5028 };
5029
5030 tracing::trace!("Checking template folder for: {}", relative_path);
5031
5032 if let Some(file_path) = safe_join_asset(template_folder, &relative_path) {
5033 return Self::serve_mbr_file(&config, &file_path).await;
5034 }
5035 }
5036
5037 let mbr_dir = config.base_dir.join(MBR_TEMPLATE_DIR);
5039 tracing::trace!("Checking .mbr dir for: {}", asset_path);
5040
5041 if let Some(file_path) = safe_join_asset(&mbr_dir, &asset_path) {
5042 return Self::serve_mbr_file(&config, &file_path).await;
5043 }
5044
5045 if asset_path == "/pico.min.css" {
5047 return Self::serve_themed_pico(&config.theme);
5048 }
5049
5050 Self::serve_default_file(&asset_path)
5052 }
5053
5054 async fn serve_mbr_file(
5071 config: &ServerState,
5072 path: &Path,
5073 ) -> Result<Response<Body>, StatusCode> {
5074 let vault = config.repo.vault();
5075 let in_vault = vault.vault_path(path).ok();
5076 let deadline = Instant::now() + MBR_ASSET_DOWNLOAD_WAIT;
5077 let mut requested = false;
5078 loop {
5079 let pending = in_vault
5080 .as_ref()
5081 .is_some_and(|p| vault.availability(p) == Availability::NotDownloaded);
5082 if !pending {
5083 match tokio::fs::read(path).await {
5084 Ok(bytes) => return Self::file_response(path, bytes).await,
5085 Err(e) => {
5086 let error = VaultError::from_io(path.display(), e);
5087 if !matches!(error, VaultError::NotDownloaded { .. }) {
5088 tracing::error!("Failed to read file {}: {error}", path.display());
5089 return Err(StatusCode::INTERNAL_SERVER_ERROR);
5090 }
5091 }
5092 }
5093 }
5094 if !requested {
5095 requested = true;
5096 if let Some(vault_path) = &in_vault {
5097 vault.request_download(std::slice::from_ref(vault_path));
5098 continue; }
5100 }
5101 if Instant::now() >= deadline {
5102 tracing::debug!(
5103 "{} not downloaded after {}ms; asking for a retry",
5104 path.display(),
5105 MBR_ASSET_DOWNLOAD_WAIT.as_millis()
5106 );
5107 return Ok(not_downloaded_response(None));
5108 }
5109 tokio::time::sleep(MBR_ASSET_DOWNLOAD_POLL).await;
5110 }
5111 }
5112
5113 async fn file_response(path: &Path, bytes: Vec<u8>) -> Result<Response<Body>, StatusCode> {
5115 let mime = Self::guess_mime_type(path);
5116
5117 let etag = generate_etag(&bytes);
5119
5120 let last_modified = tokio::fs::metadata(path)
5122 .await
5123 .ok()
5124 .and_then(|m| m.modified().ok())
5125 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
5126 .and_then(|d| generate_last_modified(d.as_secs()));
5127
5128 let mut builder = Response::builder()
5129 .status(StatusCode::OK)
5130 .header(header::CONTENT_TYPE, mime)
5131 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
5132 .header(header::ETAG, etag);
5133
5134 if let Some(lm) = last_modified {
5135 builder = builder.header(header::LAST_MODIFIED, lm);
5136 }
5137
5138 builder
5139 .body(axum::body::Body::from(bytes))
5140 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
5141 }
5142
5143 fn serve_themed_pico(theme: &str) -> Result<Response<Body>, StatusCode> {
5151 match embedded_pico::get_pico_css(theme) {
5152 Some(bytes) => {
5153 let etag = generate_etag(bytes);
5154 Response::builder()
5155 .status(StatusCode::OK)
5156 .header(header::CONTENT_TYPE, "text/css")
5157 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
5158 .header(header::ETAG, etag)
5159 .body(Body::from(bytes.to_vec()))
5160 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
5161 }
5162 None => {
5163 eprintln!(
5164 "Warning: Invalid theme '{}'. Valid themes: {}",
5165 theme,
5166 embedded_pico::valid_themes_display()
5167 );
5168 Err(StatusCode::NOT_FOUND)
5169 }
5170 }
5171 }
5172
5173 fn guess_mime_type(path: &std::path::Path) -> &'static str {
5175 match path.extension().and_then(|e| e.to_str()) {
5176 Some("html") => "text/html",
5177 Some("css") => "text/css",
5178 Some("js") => "application/javascript",
5179 Some("json") => "application/json",
5180 Some("map") => "application/json",
5181 Some("svg") => "image/svg+xml",
5182 Some("png") => "image/png",
5183 Some("jpg") | Some("jpeg") => "image/jpeg",
5184 Some("gif") => "image/gif",
5185 Some("woff") => "font/woff",
5186 Some("woff2") => "font/woff2",
5187 Some("ttf") => "font/ttf",
5188 Some("eot") => "application/vnd.ms-fontobject",
5189 _ => "application/octet-stream",
5190 }
5191 }
5192
5193 #[allow(clippy::too_many_arguments)]
5196 fn render_error_page(
5197 templates: &templates::Templates,
5198 status_code: StatusCode,
5199 error_title: &str,
5200 error_message: Option<&str>,
5201 requested_url: &str,
5202 gui_mode: bool,
5203 sidebar_style: &str,
5204 sidebar_max_items: usize,
5205 graph_depth: usize,
5206 tasks_enabled: bool,
5207 tasks_default_include: IncludeFilter,
5208 ) -> Response<Body> {
5209 Self::render_error_page_with(
5210 templates,
5211 status_code,
5212 error_title,
5213 error_message,
5214 requested_url,
5215 gui_mode,
5216 sidebar_style,
5217 sidebar_max_items,
5218 graph_depth,
5219 tasks_enabled,
5220 tasks_default_include,
5221 Vec::new(),
5222 )
5223 }
5224
5225 #[allow(clippy::too_many_arguments)]
5229 fn render_error_page_with(
5230 templates: &templates::Templates,
5231 status_code: StatusCode,
5232 error_title: &str,
5233 error_message: Option<&str>,
5234 requested_url: &str,
5235 gui_mode: bool,
5236 sidebar_style: &str,
5237 sidebar_max_items: usize,
5238 graph_depth: usize,
5239 tasks_enabled: bool,
5240 tasks_default_include: IncludeFilter,
5241 extra: Vec<(&'static str, serde_json::Value)>,
5242 ) -> Response<Body> {
5243 use std::collections::HashMap;
5244
5245 let mut context: HashMap<String, serde_json::Value> = HashMap::new();
5246 page_context::insert_error_keys(
5247 &mut context,
5248 status_code.as_u16(),
5249 error_title,
5250 error_message,
5251 );
5252 context.insert(
5253 "requested_url".to_string(),
5254 serde_json::Value::String(requested_url.to_string()),
5255 );
5256 page_context::insert_page_chrome(
5258 &mut context,
5259 &PageChrome {
5260 mode: ModeFlags::Server {
5261 gui_mode: Some(gui_mode),
5262 mbr_base: false,
5263 },
5264 sidebar_style,
5265 sidebar_max_items,
5266 graph_depth,
5267 tasks_enabled,
5268 review_enabled: false,
5274 tasks_default_include,
5275 title_affixes: None,
5276 root_dir: None,
5280 },
5281 );
5282
5283 context.extend(
5284 extra
5285 .into_iter()
5286 .map(|(key, value)| (key.to_string(), value)),
5287 );
5288
5289 match templates.render_error(context) {
5290 Ok(html) => build_response_or_500(
5291 Response::builder()
5292 .status(status_code)
5293 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
5294 .body(Body::from(html)),
5295 ),
5296 Err(e) => {
5297 tracing::error!("Failed to render error page: {}", e);
5298 build_response_or_500(
5299 Response::builder()
5300 .status(status_code)
5301 .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
5302 .body(Body::from(format!(
5303 "{} {}",
5304 status_code.as_u16(),
5305 error_title
5306 ))),
5307 )
5308 }
5309 }
5310 }
5311
5312 fn serve_default_file(path: &str) -> Result<Response<Body>, StatusCode> {
5314 let file = mbr_core::assets::default_file(path)
5316 .or_else(|| {
5318 embedded_katex::KATEX_FILES
5319 .iter()
5320 .find(|(name, _, _)| path == *name)
5321 .map(|(_, bytes, mime)| (*bytes, *mime))
5322 });
5323
5324 if let Some((bytes, mime)) = file {
5325 tracing::debug!("found default file");
5326
5327 let etag = generate_etag(bytes);
5329
5330 Response::builder()
5331 .status(StatusCode::OK)
5332 .header(header::CONTENT_TYPE, mime)
5333 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
5334 .header(header::ETAG, etag)
5335 .body(axum::body::Body::from(bytes))
5336 .inspect_err(|e| tracing::error!("Error rendering default file: {e}"))
5337 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
5338 } else {
5339 tracing::debug!("no default found for: {}", path);
5340 Err(StatusCode::NOT_FOUND)
5341 }
5342 }
5343
5344 async fn handle(
5345 extract::Path(path): extract::Path<String>,
5346 State(config): State<ServerState>,
5347 req: extract::Request<Body>,
5348 ) -> Result<impl IntoResponse, StatusCode> {
5349 tracing::debug!("handle: {}", &path);
5350
5351 let tag_url_sources = mbr_core::config::tag_sources_to_url_sources(&config.tag_sources);
5352 let resolver_config = config.resolver_config(&tag_url_sources);
5353
5354 let resolved = match resolve_request_path(&resolver_config, &path) {
5359 ResolvedPath::StaticFile(resolved_path)
5360 | ResolvedPath::MarkdownFile(resolved_path)
5361 | ResolvedPath::DirectoryListing(resolved_path)
5362 if !config.is_served(&resolved_path) =>
5363 {
5364 tracing::warn!(
5365 "Blocked request for a path outside the repository root: {}",
5366 resolved_path.display()
5367 );
5368 ResolvedPath::NotFound
5369 }
5370 other => other,
5371 };
5372
5373 match resolved {
5374 ResolvedPath::StaticFile(file_path) => {
5375 #[cfg(feature = "media-metadata")]
5377 if let Some(response) =
5378 Self::try_serve_pdf_cover_sidecar(&path, &file_path, &config).await
5379 {
5380 return Ok(response);
5381 }
5382 tracing::debug!("serving static file: {:?}", &file_path);
5383 let Some((vault, vault_path)) = config.repo.locate_shared(&file_path) else {
5384 return Err(StatusCode::NOT_FOUND);
5385 };
5386 if vault.availability(&vault_path) == Availability::NotDownloaded {
5391 vault.request_download(std::slice::from_ref(&vault_path));
5392 return Ok(not_downloaded_response(None));
5393 }
5394 match vault.local_path(&vault_path) {
5398 Some(local) => Self::serve_static_file(local, req).await,
5399 None => Self::serve_vault_file(vault, vault_path, req).await,
5400 }
5401 }
5402 ResolvedPath::MarkdownFile(md_path) => {
5403 if let Some(canonical) = mbr_core::path_resolver::canonical_page_redirect(
5416 &path,
5417 &mbr_core::repo::build_markdown_url_path(
5418 &md_path,
5419 config
5420 .canonical_base_dir
5421 .as_deref()
5422 .unwrap_or(config.base_dir.as_path()),
5423 &config.index_file,
5424 ),
5425 ) {
5426 return Ok(canonical_redirect_response(&canonical, req.uri().query()));
5427 }
5428 tracing::debug!("rendering markdown: {:?}", &md_path);
5429 let edit_enabled = Self::page_edit_enabled(&config, req.headers());
5430 match Self::markdown_to_html(&md_path, &config, edit_enabled).await {
5431 Ok(response) => Ok(response),
5432 Err(e) if is_not_downloaded(&e) => {
5435 if let Some((vault, path)) = config.repo.locate(&md_path) {
5436 vault.request_download(std::slice::from_ref(&path));
5437 }
5438 Ok(Self::not_downloaded_page(&config, &path, &md_path))
5439 }
5440 Err(e) => {
5441 tracing::error!("Error rendering markdown: {e}");
5442 Err(StatusCode::INTERNAL_SERVER_ERROR)
5443 }
5444 }
5445 }
5446 ResolvedPath::DirectoryListing(dir_path) => {
5447 tracing::debug!("generating directory listing: {:?}", &dir_path);
5448 let edit_enabled = Self::page_edit_enabled(&config, req.headers());
5449 Self::directory_to_html(
5450 &dir_path,
5451 &config.templates,
5452 config.base_dir.as_path(),
5453 &config,
5454 edit_enabled,
5455 )
5456 .await
5457 .map_err(|e| {
5458 tracing::error!("Error generating directory listing: {e}");
5459 StatusCode::INTERNAL_SERVER_ERROR
5460 })
5461 }
5462 ResolvedPath::TagPage { source, value } => {
5463 tracing::debug!("generating tag page: source={}, value={}", source, value);
5464 Self::tag_page_to_html(&source, &value, &config)
5465 .await
5466 .map_err(|e| {
5467 tracing::error!("Error generating tag page: {e}");
5468 StatusCode::INTERNAL_SERVER_ERROR
5469 })
5470 }
5471 ResolvedPath::TagSourceIndex { source } => {
5472 tracing::debug!("generating tag source index: source={}", source);
5473 Self::tag_source_index_to_html(&source, &config)
5474 .await
5475 .map_err(|e| {
5476 tracing::error!("Error generating tag source index: {e}");
5477 StatusCode::INTERNAL_SERVER_ERROR
5478 })
5479 }
5480 ResolvedPath::Redirect(canonical_url) => Ok(canonical_redirect_response(
5481 &canonical_url,
5482 req.uri().query(),
5483 )),
5484 ResolvedPath::NotFound => {
5485 #[cfg(feature = "media-metadata")]
5487 if config.transcode_enabled
5488 && let Some(response) = Self::try_serve_hls_content(&path, &config).await
5489 {
5490 return Ok(response);
5491 }
5492
5493 #[cfg(feature = "media-metadata")]
5498 if let Some(response) = Self::try_serve_remux_content(&path, &config).await {
5499 return Ok(response);
5500 }
5501
5502 #[cfg(feature = "media-metadata")]
5504 if let Some(response) = Self::try_serve_video_metadata(&path, &config).await {
5505 return Ok(response);
5506 }
5507
5508 #[cfg(feature = "media-metadata")]
5510 if let Some(response) = Self::try_serve_pdf_cover(&path, &config).await {
5511 return Ok(response);
5512 }
5513
5514 if let Some(response) = Self::try_serve_errors_json(&path, &config).await {
5517 return Ok(response);
5518 }
5519
5520 if let Some(response) = Self::try_serve_links_json(&path, &config).await {
5522 return Ok(response);
5523 }
5524
5525 tracing::debug!("resource not found: {}", &path);
5526 let requested_url = format!("/{}", path);
5527 Ok(Self::render_error_page(
5528 &config.templates,
5529 StatusCode::NOT_FOUND,
5530 "Not Found",
5531 Some("The requested page could not be found."),
5532 &requested_url,
5533 config.gui_mode,
5534 &config.sidebar_style,
5535 config.sidebar_max_items,
5536 config.graph_depth,
5537 config.tasks_enabled,
5538 config.tasks_default_include,
5539 ))
5540 }
5541 }
5542 }
5543
5544 async fn serve_vault_file(
5548 vault: Arc<dyn Vault>,
5549 path: VaultPath,
5550 req: extract::Request<Body>,
5551 ) -> Result<Response, StatusCode> {
5552 let stat_vault = Arc::clone(&vault);
5553 let stat_path = path.clone();
5554 let entry = tokio::task::spawn_blocking(move || stat_vault.stat(&stat_path))
5555 .await
5556 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
5557 let entry = match entry {
5558 Ok(Some(entry)) if entry.is_file() => entry,
5559 Ok(_) => return Err(StatusCode::NOT_FOUND),
5560 Err(e) => {
5561 tracing::error!("Error serving {path}: {e}");
5562 return Err(StatusCode::INTERNAL_SERVER_ERROR);
5563 }
5564 };
5565 let content_type = mime_guess::from_path(path.as_str())
5566 .first_raw()
5567 .unwrap_or("application/octet-stream");
5568 let last_modified = entry
5569 .epoch_secs()
5570 .and_then(|(_, modified)| generate_last_modified(modified));
5571 let mut response = crate::vault_body::vault_file_response(
5572 vault,
5573 path,
5574 entry.size,
5575 content_type,
5576 last_modified,
5577 req.headers(),
5578 req.method() == axum::http::Method::HEAD,
5579 );
5580 response.headers_mut().insert(
5581 header::CACHE_CONTROL,
5582 HeaderValue::from_static(CACHE_CONTROL_NO_CACHE),
5583 );
5584 Ok(response)
5585 }
5586
5587 fn not_downloaded_page(config: &ServerState, path: &str, md_path: &Path) -> Response<Body> {
5598 let note_name = md_path
5599 .file_stem()
5600 .and_then(|stem| stem.to_str())
5601 .unwrap_or("this note")
5602 .to_string();
5603 let provider = cloud_provider_name(md_path);
5604 let mut response = Self::render_error_page_with(
5605 &config.templates,
5606 StatusCode::SERVICE_UNAVAILABLE,
5607 "Downloading",
5608 Some(
5609 "This note is stored in the cloud and is not on this device yet. \
5610 It is downloading now and will open as soon as it arrives.",
5611 ),
5612 &format!("/{path}"),
5613 config.gui_mode,
5614 &config.sidebar_style,
5615 config.sidebar_max_items,
5616 config.graph_depth,
5617 config.tasks_enabled,
5618 config.tasks_default_include,
5619 vec![
5620 ("downloading", serde_json::Value::Bool(true)),
5621 ("title", serde_json::Value::String(note_name.clone())),
5625 ("note_name", serde_json::Value::String(note_name)),
5626 (
5627 "download_provider",
5628 provider.map_or(serde_json::Value::Null, |name| {
5629 serde_json::Value::String(name.to_string())
5630 }),
5631 ),
5632 ],
5633 );
5634 add_retry_headers(response.headers_mut());
5635 response
5636 }
5637
5638 async fn serve_static_file(
5641 file_path: std::path::PathBuf,
5642 req: extract::Request<Body>,
5643 ) -> Result<Response, StatusCode> {
5644 let static_service = ServeFile::new(file_path);
5645 let mut response = static_service
5646 .oneshot(req)
5647 .await
5648 .map(|r| r.into_response())
5649 .map_err(|e| {
5650 tracing::error!("Error serving static file: {e}");
5651 StatusCode::INTERNAL_SERVER_ERROR
5652 })?;
5653
5654 response.headers_mut().insert(
5656 header::CACHE_CONTROL,
5657 HeaderValue::from_static(CACHE_CONTROL_NO_CACHE),
5658 );
5659
5660 Ok(response)
5661 }
5662
5663 #[cfg(feature = "media-metadata")]
5666 fn metadata_response_from_cache(
5667 cached: mbr_core::video_metadata_cache::CachedMetadata,
5668 ) -> Option<Response<Body>> {
5669 use mbr_core::video_metadata_cache::CachedMetadata;
5670 match cached {
5671 CachedMetadata::Cover(bytes) => Some(Self::build_jpg_response(bytes)),
5672 CachedMetadata::Chapters(vtt) | CachedMetadata::Captions(vtt) => {
5673 Some(Self::build_vtt_response(vtt))
5674 }
5675 CachedMetadata::NotAvailable => None,
5676 }
5677 }
5678
5679 #[cfg(feature = "media-metadata")]
5684 async fn extract_video_metadata_and_cache(
5685 video_file: std::path::PathBuf,
5686 metadata_type: mbr_core::video_metadata::MetadataType,
5687 key: String,
5688 config: &ServerState,
5689 ) -> Option<Response<Body>> {
5690 use mbr_core::video_metadata::{
5691 MetadataType, extract_captions, extract_chapters, extract_cover,
5692 };
5693 use mbr_core::video_metadata_cache::CachedMetadata;
5694
5695 let extracted = tokio::task::spawn_blocking(move || match metadata_type {
5698 MetadataType::Cover => extract_cover(&video_file).map(CachedMetadata::Cover),
5699 MetadataType::Chapters => extract_chapters(&video_file).map(CachedMetadata::Chapters),
5700 MetadataType::Captions => extract_captions(&video_file).map(CachedMetadata::Captions),
5701 })
5702 .await;
5703
5704 match extracted {
5705 Ok(Ok(cached)) => {
5706 config.video_metadata_cache.insert(key, cached.clone());
5707 Self::metadata_response_from_cache(cached)
5708 }
5709 Ok(Err(e)) => {
5710 tracing::debug!("Failed to extract video metadata: {}", e);
5711 config
5712 .video_metadata_cache
5713 .insert(key, CachedMetadata::NotAvailable);
5714 None
5715 }
5716 Err(e) => {
5717 tracing::warn!("Video metadata extraction task panicked: {}", e);
5718 None
5719 }
5720 }
5721 }
5722
5723 #[cfg(feature = "media-metadata")]
5728 async fn try_serve_video_metadata(path: &str, config: &ServerState) -> Option<Response<Body>> {
5729 use mbr_core::video_metadata::{MetadataType, parse_metadata_request};
5730 use mbr_core::video_metadata_cache::cache_key_with_mtime;
5731
5732 let (video_url_path, metadata_type) = parse_metadata_request(path)?;
5734
5735 let cache_type_str = match metadata_type {
5736 MetadataType::Cover => "cover",
5737 MetadataType::Chapters => "chapters",
5738 MetadataType::Captions => "captions",
5739 };
5740
5741 let Some(video_file) = resolve_media_source_file(
5745 video_url_path,
5746 &config.base_dir,
5747 &config.static_folder,
5748 &config.mount_targets(),
5749 ) else {
5750 tracing::debug!(
5751 "Video file not found for metadata generation: {}",
5752 video_url_path
5753 );
5754 return None;
5755 };
5756
5757 let key = cache_key_with_mtime(&video_file, cache_type_str);
5760
5761 if let Some(cached) = config.video_metadata_cache.get(&key) {
5763 return Self::metadata_response_from_cache(cached);
5764 }
5765
5766 match claim_inflight(&config.metadata_inflight, &key) {
5771 InflightClaim::Produce(notify) => {
5772 tracing::debug!(
5773 "Generating {} for: {}",
5774 cache_type_str,
5775 video_file.display()
5776 );
5777 let _slot =
5785 InflightSlot::new(Arc::clone(&config.metadata_inflight), key.clone(), notify);
5786 Self::extract_video_metadata_and_cache(
5787 video_file,
5788 metadata_type,
5789 key.clone(),
5790 config,
5791 )
5792 .await
5793 }
5794 InflightClaim::Wait(notify) => {
5795 let notified = notify.notified();
5798 tokio::pin!(notified);
5799 notified.as_mut().enable();
5800 if let Some(cached) = config.video_metadata_cache.get(&key) {
5801 return Self::metadata_response_from_cache(cached);
5802 }
5803 match tokio::time::timeout(METADATA_WAIT_TIMEOUT, notified).await {
5804 Ok(()) => config
5805 .video_metadata_cache
5806 .get(&key)
5807 .and_then(Self::metadata_response_from_cache),
5808 Err(_) => {
5809 tracing::warn!(
5810 "Timed out waiting for in-progress metadata decode: {}",
5811 key
5812 );
5813 None
5814 }
5815 }
5816 }
5817 }
5818 }
5819
5820 #[cfg(feature = "media-metadata")]
5830 async fn try_serve_pdf_cover_sidecar(
5831 url_path: &str,
5832 sidecar_file_path: &std::path::Path,
5833 config: &ServerState,
5834 ) -> Option<Response<Body>> {
5835 use mbr_core::pdf_metadata::parse_pdf_cover_request;
5836 use mbr_core::video_metadata_cache::{CachedMetadata, cache_key_with_mtime};
5837
5838 let _pdf_url_path = parse_pdf_cover_request(url_path)?;
5840
5841 let pdf_file = {
5844 let sidecar_str = sidecar_file_path.to_str()?;
5845 let pdf_path_str = sidecar_str.strip_suffix(".cover.jpg")?;
5846 std::path::PathBuf::from(pdf_path_str)
5847 };
5848
5849 let key = cache_key_with_mtime(&pdf_file, "pdf_cover");
5852
5853 if let Some(cached) = config.video_metadata_cache.get(&key) {
5855 return match cached {
5856 CachedMetadata::Cover(bytes) => Some(Self::build_jpg_response(bytes)),
5857 CachedMetadata::NotAvailable => None, _ => None, };
5860 }
5861
5862 if !pdf_file.is_file() {
5864 if let Ok(bytes) = tokio::fs::read(sidecar_file_path).await {
5866 tracing::debug!(
5867 "Serving PDF cover sidecar (orphaned, no PDF): {}",
5868 sidecar_file_path.display()
5869 );
5870 config
5871 .video_metadata_cache
5872 .insert(key, CachedMetadata::Cover(bytes.clone()));
5873 return Some(Self::build_jpg_response(bytes));
5874 }
5875 return None;
5876 }
5877
5878 let pdf_meta = tokio::fs::metadata(&pdf_file).await.ok()?;
5880 let sidecar_meta = tokio::fs::metadata(sidecar_file_path).await.ok()?;
5881 let pdf_mtime = pdf_meta.modified().ok()?;
5882 let sidecar_mtime = sidecar_meta.modified().ok()?;
5883
5884 if pdf_mtime > sidecar_mtime {
5885 tracing::debug!(
5887 "Sidecar is stale (PDF modified after sidecar), regenerating: {}",
5888 sidecar_file_path.display()
5889 );
5890
5891 match mbr_core::pdf_metadata::extract_cover_async(&pdf_file).await {
5893 Ok(bytes) => {
5894 config
5895 .video_metadata_cache
5896 .insert(key, CachedMetadata::Cover(bytes.clone()));
5897 return Some(Self::build_jpg_response(bytes));
5898 }
5899 Err(e) => {
5900 tracing::debug!("Failed to regenerate PDF cover: {}", e);
5901 }
5903 }
5904 }
5905
5906 if let Ok(bytes) = tokio::fs::read(sidecar_file_path).await {
5908 tracing::debug!(
5909 "Serving PDF cover from fresh sidecar: {}",
5910 sidecar_file_path.display()
5911 );
5912 config
5913 .video_metadata_cache
5914 .insert(key, CachedMetadata::Cover(bytes.clone()));
5915 return Some(Self::build_jpg_response(bytes));
5916 }
5917
5918 None
5920 }
5921
5922 #[cfg(feature = "media-metadata")]
5935 async fn try_serve_pdf_cover(path: &str, config: &ServerState) -> Option<Response<Body>> {
5936 use mbr_core::pdf_metadata::parse_pdf_cover_request;
5937 use mbr_core::video_metadata_cache::{CachedMetadata, cache_key_with_mtime};
5938
5939 let pdf_url_path = parse_pdf_cover_request(path)?;
5941
5942 let Some(pdf_file) = resolve_media_source_file(
5946 pdf_url_path,
5947 &config.base_dir,
5948 &config.static_folder,
5949 &config.mount_targets(),
5950 ) else {
5951 tracing::debug!("PDF file not found for cover generation: {}", pdf_url_path);
5952 return None;
5953 };
5954
5955 let key = cache_key_with_mtime(&pdf_file, "pdf_cover");
5958
5959 if let Some(cached) = config.video_metadata_cache.get(&key) {
5961 return match cached {
5962 CachedMetadata::Cover(bytes) => Some(Self::build_jpg_response(bytes)),
5963 CachedMetadata::NotAvailable => None, _ => None, };
5966 }
5967
5968 let sidecar_path = {
5970 let mut sidecar = pdf_file.clone();
5971 let file_name = sidecar.file_name()?.to_str()?;
5972 sidecar.set_file_name(format!("{}.cover.jpg", file_name));
5973 sidecar
5974 };
5975
5976 if let Some(bytes) = Self::try_serve_from_sidecar(&pdf_file, &sidecar_path).await {
5978 tracing::debug!("Serving PDF cover from sidecar: {}", sidecar_path.display());
5979 config
5981 .video_metadata_cache
5982 .insert(key, CachedMetadata::Cover(bytes.clone()));
5983 return Some(Self::build_jpg_response(bytes));
5984 }
5985
5986 tracing::debug!("Generating PDF cover for: {}", pdf_file.display());
5988
5989 match mbr_core::pdf_metadata::extract_cover_async(&pdf_file).await {
5991 Ok(bytes) => {
5992 config
5993 .video_metadata_cache
5994 .insert(key, CachedMetadata::Cover(bytes.clone()));
5995 Some(Self::build_jpg_response(bytes))
5996 }
5997 Err(mbr_core::errors::PdfMetadataError::PasswordProtected { .. }) => {
5998 tracing::debug!("PDF is password-protected: {}", pdf_file.display());
5999 config
6000 .video_metadata_cache
6001 .insert(key, CachedMetadata::NotAvailable);
6002 None
6003 }
6004 Err(e) => {
6005 tracing::debug!("Failed to extract PDF cover: {}", e);
6006 config
6007 .video_metadata_cache
6008 .insert(key, CachedMetadata::NotAvailable);
6009 None
6010 }
6011 }
6012 }
6013
6014 #[cfg(feature = "media-metadata")]
6023 async fn try_serve_from_sidecar(
6024 pdf_path: &std::path::Path,
6025 sidecar_path: &std::path::Path,
6026 ) -> Option<Vec<u8>> {
6027 if let Some(pdf_dir) = pdf_path.parent() {
6031 if let Some(sidecar_dir) = sidecar_path.parent() {
6035 if sidecar_dir != pdf_dir {
6036 tracing::warn!(
6037 "Sidecar path is not in the same directory as PDF; skipping sidecar. \
6038 pdf_dir='{}', sidecar_dir='{}'",
6039 pdf_dir.display(),
6040 sidecar_dir.display()
6041 );
6042 return None;
6043 }
6044 } else {
6045 tracing::warn!(
6047 "Sidecar path has no parent directory; skipping sidecar: {}",
6048 sidecar_path.display()
6049 );
6050 return None;
6051 }
6052
6053 if validate_path_containment(sidecar_path, pdf_dir).is_none() {
6056 tracing::warn!(
6057 "Sidecar path failed containment validation: {}",
6058 sidecar_path.display()
6059 );
6060 return None;
6061 }
6062 } else {
6063 tracing::warn!(
6065 "PDF path has no parent directory; skipping sidecar: {}",
6066 pdf_path.display()
6067 );
6068 return None;
6069 }
6070
6071 let sidecar_meta = tokio::fs::metadata(sidecar_path).await.ok()?;
6073
6074 let pdf_meta = tokio::fs::metadata(pdf_path).await.ok()?;
6076 let pdf_mtime = pdf_meta.modified().ok()?;
6077 let sidecar_mtime = sidecar_meta.modified().ok()?;
6078
6079 if pdf_mtime > sidecar_mtime {
6081 tracing::debug!(
6082 "Sidecar is stale (PDF modified after sidecar): {}",
6083 sidecar_path.display()
6084 );
6085 return None;
6086 }
6087
6088 tokio::fs::read(sidecar_path).await.ok()
6090 }
6091
6092 async fn try_serve_links_json(path: &str, config: &ServerState) -> Option<Response<Body>> {
6097 use mbr_core::link_index::PageLinks;
6098
6099 if !path.ends_with("links.json") {
6101 return None;
6102 }
6103
6104 if !config.link_tracking {
6106 tracing::debug!("links.json requested but link tracking is disabled");
6107 return None;
6108 }
6109
6110 let page_path = path.strip_suffix("links.json")?;
6119 let page_url_path = if page_path.is_empty() || page_path == "/" {
6120 "/".to_string()
6121 } else {
6122 let normalized = page_path.trim_end_matches('/').trim_start_matches('/');
6123 format!("/{}/", normalized)
6124 };
6125
6126 tracing::debug!("links.json request for page: {}", page_url_path);
6127
6128 let outbound = if let Some(cached) = config.link_cache.get(&page_url_path) {
6131 cached
6132 } else {
6133 let tag_url_sources = mbr_core::config::tag_sources_to_url_sources(&config.tag_sources);
6135 let resolver_config = config.resolver_config(&tag_url_sources);
6136
6137 let request_path = page_url_path.trim_matches('/');
6140
6141 match resolve_request_path(&resolver_config, request_path) {
6142 ResolvedPath::MarkdownFile(md_path) => {
6143 tracing::debug!(
6144 "links.json: rendering page to extract links: {:?}",
6145 &md_path
6146 );
6147
6148 let is_index_file = md_path
6150 .file_name()
6151 .and_then(|f| f.to_str())
6152 .is_some_and(|f| f == config.index_file);
6153
6154 let link_transform_config = LinkTransformConfig {
6155 markdown_extensions: config.markdown_extensions.clone(),
6156 index_file: config.index_file.clone(),
6157 is_index_file,
6158 url_depth: None,
6159 current_page_url: page_url_path.clone(),
6160 markdown_page_probe: None,
6161 };
6162
6163 let valid_tag_sources =
6164 mbr_core::config::tag_sources_to_set(&config.tag_sources);
6165 match markdown::render_from_vault_with_cache(
6166 Arc::clone(config.repo.vault()),
6167 md_path,
6168 &config.base_dir,
6169 config.oembed_timeout_ms,
6170 link_transform_config,
6171 Some(config.oembed_cache.clone()),
6172 true, false, valid_tag_sources,
6175 markdown::ReviewLines::Omit,
6178 false, &config.incomplete_markers,
6180 Some(config.repo.wikilink_index.clone()),
6181 )
6182 .await
6183 {
6184 Ok(render_result) => {
6185 let resolved_links = resolve_outbound_links(
6187 &page_url_path,
6188 render_result.outbound_links,
6189 is_index_file,
6190 );
6191 config
6193 .link_cache
6194 .insert(page_url_path.clone(), resolved_links.clone());
6195 resolved_links
6196 }
6197 Err(e) => {
6198 tracing::error!("links.json: failed to render page: {}", e);
6199 return None;
6200 }
6201 }
6202 }
6203 ResolvedPath::TagPage { source, value } => {
6204 tracing::debug!(
6205 "links.json: building tag page links for {}/{}",
6206 source,
6207 value
6208 );
6209 build_tag_page_outbound_links(
6210 &source,
6211 &value,
6212 &config.repo.tag_index,
6213 &config.tag_sources,
6214 )
6215 }
6216 ResolvedPath::TagSourceIndex { source } => {
6217 tracing::debug!("links.json: building tag index links for {}", source);
6218 build_tag_index_outbound_links(&source, &config.repo.tag_index)
6219 }
6220 _ => {
6221 tracing::debug!("links.json: page not found: {}", page_url_path);
6223 return None;
6224 }
6225 }
6226 };
6227
6228 let inbound = if config.inbound_index.is_ready() {
6234 config.inbound_index.get(&page_url_path)
6238 } else if let Some(cached) = config.inbound_link_cache.get(&page_url_path) {
6239 cached
6240 } else {
6241 match claim_inflight(&config.inbound_grep_inflight, &page_url_path) {
6242 InflightClaim::Produce(notify) => {
6243 let _slot = InflightSlot::new(
6249 Arc::clone(&config.inbound_grep_inflight),
6250 page_url_path.clone(),
6251 notify,
6252 );
6253 let links = match config.inbound_link_cache.get(&page_url_path) {
6257 Some(cached) => Some(cached),
6258 None => Self::grep_inbound_links_bounded(&page_url_path, config).await,
6259 };
6260 links?
6261 }
6262 InflightClaim::Wait(notify) => {
6263 let notified = notify.notified();
6266 tokio::pin!(notified);
6267 notified.as_mut().enable();
6268 if let Some(cached) = config.inbound_link_cache.get(&page_url_path) {
6269 cached
6270 } else {
6271 match tokio::time::timeout(INBOUND_GREP_WAIT_TIMEOUT, notified).await {
6272 Ok(()) => config.inbound_link_cache.get(&page_url_path)?,
6273 Err(_) => {
6274 tracing::warn!(
6275 "Timed out waiting for in-progress inbound link grep: {}",
6276 page_url_path
6277 );
6278 return None;
6279 }
6280 }
6281 }
6282 }
6283 }
6284 };
6285
6286 let relationships = if config.relationship_tracking {
6290 config.repo.relationship_index.get(&page_url_path)
6291 } else {
6292 Vec::new()
6293 };
6294
6295 let page_links = PageLinks {
6296 inbound,
6297 outbound,
6298 relationships,
6299 };
6300
6301 let json = match serde_json::to_string(&page_links) {
6302 Ok(j) => j,
6303 Err(e) => {
6304 tracing::error!("Failed to serialize links.json: {}", e);
6305 return None;
6306 }
6307 };
6308
6309 Some(build_response_or_500(
6310 Response::builder()
6311 .status(StatusCode::OK)
6312 .header(header::CONTENT_TYPE, "application/json")
6313 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
6314 .body(Body::from(json)),
6315 ))
6316 }
6317
6318 async fn grep_inbound_links_bounded(
6322 page_url_path: &str,
6323 config: &ServerState,
6324 ) -> Option<Vec<mbr_core::link_index::InboundLink>> {
6325 use crate::link_grep::find_inbound_links;
6326
6327 let _permit = config.inbound_grep_semaphore.acquire().await.ok()?;
6330
6331 let target = page_url_path.to_string();
6335 let vault = Arc::clone(config.repo.vault());
6336 let markdown_extensions = config.markdown_extensions.clone();
6337 let ignore_dirs = config.ignore_dirs.clone();
6338 let ignore_globs = config.ignore_globs.clone();
6339 let index_file = config.index_file.clone();
6340 let links = tokio::task::spawn_blocking(move || {
6341 find_inbound_links(
6342 &target,
6343 vault.as_ref(),
6344 &markdown_extensions,
6345 &ignore_dirs,
6346 &ignore_globs,
6347 &index_file,
6348 )
6349 })
6350 .await
6351 .inspect_err(|e| tracing::error!("inbound link grep task failed: {e}"))
6352 .ok()?;
6353 config
6355 .inbound_link_cache
6356 .insert(page_url_path.to_string(), links.clone());
6357 Some(links)
6358 }
6359
6360 async fn try_serve_errors_json(path: &str, config: &ServerState) -> Option<Response<Body>> {
6373 use mbr_core::page_errors::{
6374 PageErrors, ambiguous_relationship_endpoint_errors, ambiguous_wikilink_errors,
6375 contact_problem_errors, detect_unresolved_wikilinks, frontmatter_parse_errors,
6376 relationship_cycle_errors, validate_internal_links, validate_media_references,
6377 validate_rendered_links,
6378 };
6379
6380 if !path.ends_with("errors.json") {
6384 return None;
6385 }
6386
6387 if !config.link_tracking {
6392 tracing::debug!("errors.json requested but link tracking is disabled");
6393 return None;
6394 }
6395
6396 let page_path = path.strip_suffix("errors.json")?;
6401 let page_url_path = if page_path.is_empty() || page_path == "/" {
6402 "/".to_string()
6403 } else {
6404 let normalized = page_path.trim_end_matches('/').trim_start_matches('/');
6405 format!("/{}/", normalized)
6406 };
6407
6408 tracing::debug!("errors.json request for page: {}", page_url_path);
6409
6410 let tag_url_sources = mbr_core::config::tag_sources_to_url_sources(&config.tag_sources);
6411 let resolver_config = config.resolver_config(&tag_url_sources);
6412
6413 let request_path = page_url_path.trim_matches('/');
6414
6415 let (
6420 outbound_links,
6421 html_for_scan,
6422 frontmatter_error,
6423 ambiguous_wikilinks,
6424 contact_problems,
6425 ): (
6426 Vec<mbr_core::link_index::OutboundLink>,
6427 String,
6428 Option<String>,
6429 Vec<mbr_core::wikilink_index::AmbiguousWikilink>,
6430 Vec<mbr_core::contact::ContactProblem>,
6431 ) = match resolve_request_path(&resolver_config, request_path) {
6432 ResolvedPath::MarkdownFile(md_path) => {
6433 let is_index_file = md_path
6434 .file_name()
6435 .and_then(|f| f.to_str())
6436 .is_some_and(|f| f == config.index_file);
6437
6438 let link_transform_config = LinkTransformConfig {
6439 markdown_extensions: config.markdown_extensions.clone(),
6440 index_file: config.index_file.clone(),
6441 is_index_file,
6442 url_depth: None,
6443 current_page_url: page_url_path.clone(),
6444 markdown_page_probe: Some(
6450 mbr_core::link_transform::filesystem_markdown_page_probe(
6451 owned_resolver_config(config),
6452 ),
6453 ),
6454 };
6455
6456 let valid_tag_sources = mbr_core::config::tag_sources_to_set(&config.tag_sources);
6457
6458 match markdown::render_from_vault_with_cache(
6459 Arc::clone(config.repo.vault()),
6460 md_path,
6461 &config.base_dir,
6462 config.oembed_timeout_ms,
6463 link_transform_config,
6464 Some(config.oembed_cache.clone()),
6465 true, false, valid_tag_sources,
6468 markdown::ReviewLines::Omit,
6470 false, &config.incomplete_markers,
6472 Some(config.repo.wikilink_index.clone()),
6473 )
6474 .await
6475 {
6476 Ok(render_result) => {
6477 let resolved_links = resolve_outbound_links(
6478 &page_url_path,
6479 render_result.outbound_links,
6480 is_index_file,
6481 );
6482 config
6485 .link_cache
6486 .insert(page_url_path.clone(), resolved_links.clone());
6487 (
6488 resolved_links,
6489 render_result.html,
6490 render_result.frontmatter_error,
6491 render_result.ambiguous_wikilinks,
6492 render_result
6493 .contact
6494 .map(|c| c.problems)
6495 .unwrap_or_default(),
6496 )
6497 }
6498 Err(e) if is_not_downloaded_markdown(&e) => {
6503 log_render_failure("errors.json", &e, true);
6504 return Some(Self::not_downloaded_errors_json(config, page_url_path));
6505 }
6506 Err(e) => {
6507 tracing::error!("errors.json: failed to render page: {}", e);
6508 return None;
6509 }
6510 }
6511 }
6512 ResolvedPath::TagPage { source, value } => {
6513 let outbound = build_tag_page_outbound_links(
6517 &source,
6518 &value,
6519 &config.repo.tag_index,
6520 &config.tag_sources,
6521 );
6522 (outbound, String::new(), None, Vec::new(), Vec::new())
6523 }
6524 ResolvedPath::TagSourceIndex { source } => {
6525 let outbound = build_tag_index_outbound_links(&source, &config.repo.tag_index);
6526 (outbound, String::new(), None, Vec::new(), Vec::new())
6527 }
6528 _ => {
6529 tracing::debug!("errors.json: page not found: {}", page_url_path);
6530 return None;
6531 }
6532 };
6533
6534 let mut errors = Vec::new();
6535 if let Some(message) = &config.config_problem {
6536 errors.push(mbr_core::page_errors::PageError::ConfigParseError {
6537 message: message.clone(),
6538 });
6539 }
6540 errors.extend(frontmatter_parse_errors(&frontmatter_error));
6541 errors.extend(contact_problem_errors(&contact_problems));
6542 if html_for_scan.is_empty() {
6543 errors.extend(validate_internal_links(&outbound_links, &resolver_config));
6547 } else {
6548 errors.extend(validate_rendered_links(
6554 &html_for_scan,
6555 &resolver_config,
6556 &page_url_path,
6557 ));
6558 errors.extend(validate_media_references(
6559 &html_for_scan,
6560 &resolver_config,
6561 &page_url_path,
6562 ));
6563 errors.extend(detect_unresolved_wikilinks(&html_for_scan));
6564 #[cfg(feature = "media-metadata")]
6565 errors.extend(
6566 Self::detect_unplayable_media(
6567 &html_for_scan,
6568 &resolver_config,
6569 &page_url_path,
6570 config,
6571 )
6572 .await,
6573 );
6574 }
6575 errors.extend(ambiguous_wikilink_errors(&ambiguous_wikilinks));
6576
6577 if config.relationship_tracking {
6582 let index = &config.repo.relationship_index;
6583 errors.extend(relationship_cycle_errors(&index.cycles_for(&page_url_path)));
6584 errors.extend(ambiguous_relationship_endpoint_errors(
6585 &index.ambiguous_endpoints_for(&page_url_path),
6586 ));
6587 }
6588
6589 let payload = PageErrors {
6590 page_url: page_url_path,
6591 errors,
6592 not_downloaded: false,
6593 };
6594
6595 let json = match serde_json::to_string(&payload) {
6596 Ok(j) => j,
6597 Err(e) => {
6598 tracing::error!("Failed to serialize errors.json: {}", e);
6599 return None;
6600 }
6601 };
6602
6603 Some(build_response_or_500(
6604 Response::builder()
6605 .status(StatusCode::OK)
6606 .header(header::CONTENT_TYPE, "application/json")
6607 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
6608 .body(Body::from(json)),
6609 ))
6610 }
6611
6612 fn not_downloaded_errors_json(config: &ServerState, page_url: String) -> Response<Body> {
6619 let errors = config
6620 .config_problem
6621 .iter()
6622 .map(
6623 |message| mbr_core::page_errors::PageError::ConfigParseError {
6624 message: message.clone(),
6625 },
6626 )
6627 .collect();
6628 let payload = mbr_core::page_errors::PageErrors {
6629 page_url,
6630 errors,
6631 not_downloaded: true,
6632 };
6633 match serde_json::to_string(&payload) {
6634 Ok(json) => build_response_or_500(
6635 Response::builder()
6636 .status(StatusCode::OK)
6637 .header(header::CONTENT_TYPE, "application/json")
6638 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_STORE)
6639 .body(Body::from(json)),
6640 ),
6641 Err(e) => {
6642 tracing::error!("Failed to serialize errors.json: {}", e);
6643 StatusCode::INTERNAL_SERVER_ERROR.into_response()
6644 }
6645 }
6646 }
6647
6648 #[cfg(feature = "media-metadata")]
6656 async fn probe_playback_compat_cached(
6657 media_file: &std::path::Path,
6658 config: &ServerState,
6659 ) -> Option<mbr_core::video_metadata::PlaybackCompatibility> {
6660 use mbr_core::video_metadata::probe_playback_compatibility;
6661 use mbr_core::video_metadata_cache::cache_key_with_mtime;
6662
6663 let key = cache_key_with_mtime(media_file, "playback-compat");
6664 if let Some(compat) = config.media_compat_cache.pin().get(&key).cloned() {
6665 return Some(compat);
6666 }
6667
6668 let path = media_file.to_path_buf();
6669 let probed = tokio::task::spawn_blocking(move || probe_playback_compatibility(&path))
6670 .await
6671 .inspect_err(|e| tracing::warn!("playback-compat probe task failed: {e}"))
6672 .ok()?
6673 .inspect_err(|e| tracing::debug!("playback-compat probe of {media_file:?}: {e}"))
6674 .ok()?;
6675
6676 config.media_compat_cache.pin().insert(key, probed.clone());
6677 Some(probed)
6678 }
6679
6680 #[cfg(feature = "media-metadata")]
6690 async fn detect_unplayable_media(
6691 html: &str,
6692 resolver_config: &PathResolverConfig<'_>,
6693 page_url: &str,
6694 config: &ServerState,
6695 ) -> Vec<mbr_core::page_errors::PageError> {
6696 use futures::stream::StreamExt;
6697 use itertools::Itertools;
6698 use mbr_core::page_errors::{MediaKind, PageError, collect_media_references};
6699 use mbr_core::video_metadata::{PlaybackCompatibility, has_video_extension};
6700
6701 let references: Vec<_> = collect_media_references(html, resolver_config, page_url)
6704 .into_iter()
6705 .filter(|reference| has_video_extension(&reference.path.to_string_lossy()))
6706 .collect();
6707
6708 if references.is_empty() {
6709 return Vec::new();
6710 }
6711
6712 let unique_paths: Vec<PathBuf> = references
6720 .iter()
6721 .map(|reference| reference.path.clone())
6722 .unique()
6723 .collect();
6724
6725 let probes: std::collections::HashMap<PathBuf, PlaybackCompatibility> =
6726 futures::stream::iter(unique_paths)
6727 .map(|path| async move {
6728 let compat = Self::probe_playback_compat_cached(&path, config).await?;
6729 Some((path, compat))
6730 })
6731 .buffered(MEDIA_COMPAT_PROBE_CONCURRENCY)
6732 .filter_map(|probe| async move { probe })
6733 .collect()
6734 .await;
6735
6736 references
6737 .into_iter()
6738 .filter_map(|reference| {
6739 let compat = probes.get(&reference.path)?;
6740 let reason = compat.reason()?;
6741 Some(PageError::UnplayableMedia {
6742 src: reference.src,
6743 kind: MediaKind::Video,
6746 reason,
6747 remedy: compat.remedy(),
6748 advisory: true,
6750 })
6751 })
6752 .collect()
6753 }
6754
6755 #[cfg(feature = "media-metadata")]
6757 fn build_jpg_response(bytes: Vec<u8>) -> Response<Body> {
6758 build_response_or_500(
6759 Response::builder()
6760 .status(StatusCode::OK)
6761 .header(header::CONTENT_TYPE, "image/jpeg")
6762 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
6763 .body(Body::from(bytes)),
6764 )
6765 }
6766
6767 #[cfg(feature = "media-metadata")]
6769 fn build_vtt_response(vtt: String) -> Response<Body> {
6770 build_response_or_500(
6771 Response::builder()
6772 .status(StatusCode::OK)
6773 .header(header::CONTENT_TYPE, "text/vtt; charset=utf-8")
6774 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
6775 .body(Body::from(vtt)),
6776 )
6777 }
6778
6779 #[cfg(feature = "media-metadata")]
6781 fn build_hls_playlist_response(playlist: Arc<Vec<u8>>) -> Response<Body> {
6782 build_response_or_500(
6783 Response::builder()
6784 .status(StatusCode::OK)
6785 .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl")
6786 .header(header::CONTENT_LENGTH, playlist.len())
6787 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
6788 .body(Body::from(playlist.as_ref().clone())),
6789 )
6790 }
6791
6792 #[cfg(feature = "media-metadata")]
6794 fn build_hls_segment_response(segment: Arc<Vec<u8>>) -> Response<Body> {
6795 build_response_or_500(
6796 Response::builder()
6797 .status(StatusCode::OK)
6798 .header(header::CONTENT_TYPE, "video/mp2t")
6799 .header(header::CONTENT_LENGTH, segment.len())
6800 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
6801 .body(Body::from(segment.as_ref().clone())),
6802 )
6803 }
6804
6805 #[cfg(feature = "media-metadata")]
6807 fn build_remux_response(
6808 part: mbr_core::video_remux::RemuxPart,
6809 data: Arc<Vec<u8>>,
6810 ) -> Response<Body> {
6811 use mbr_core::video_remux::{
6812 INIT_CONTENT_TYPE, PLAYLIST_CONTENT_TYPE, RemuxPart, SEGMENT_CONTENT_TYPE,
6813 };
6814
6815 let content_type = match part {
6816 RemuxPart::Playlist => PLAYLIST_CONTENT_TYPE,
6817 RemuxPart::Init => INIT_CONTENT_TYPE,
6818 RemuxPart::Segment(_) => SEGMENT_CONTENT_TYPE,
6819 };
6820
6821 build_response_or_500(
6822 Response::builder()
6823 .status(StatusCode::OK)
6824 .header(header::CONTENT_TYPE, content_type)
6825 .header(header::CONTENT_LENGTH, data.len())
6826 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
6827 .body(Body::from(data.as_ref().clone())),
6828 )
6829 }
6830
6831 #[cfg(feature = "media-metadata")]
6848 async fn generate_remux_part<F>(
6849 cache: &Arc<mbr_core::video_transcode_cache::HlsCache>,
6850 key: mbr_core::video_transcode_cache::HlsCacheKey,
6851 generate: F,
6852 ) -> Result<Arc<Vec<u8>>, Box<Response<Body>>>
6853 where
6854 F: FnOnce() -> Result<Vec<u8>, mbr_core::video_transcode::TranscodeError> + Send + 'static,
6855 {
6856 use mbr_core::video_transcode_cache::{HLS_WAIT_TIMEOUT, HlsCache, HlsCacheStartResult};
6857
6858 match cache.start_generation(key.clone()) {
6859 HlsCacheStartResult::Started(notify) => {
6860 let handle = HlsCache::spawn_generation(cache, key.clone(), notify, generate);
6861 match handle.await {
6862 Ok(Ok(data)) => Ok(data),
6863 Ok(Err(error)) => Err(Box::new(Self::build_remux_error_response(&error))),
6864 Err(join_error) => {
6867 tracing::warn!("remux generation task did not finish: {join_error}");
6868 Err(Box::new(Self::build_remux_retry_response(
6869 "generation did not complete",
6870 )))
6871 }
6872 }
6873 }
6874 HlsCacheStartResult::AlreadyInProgress(notify) => {
6875 match cache
6876 .wait_for_completion(&key, notify, HLS_WAIT_TIMEOUT)
6877 .await
6878 {
6879 Some(data) => Ok(data),
6880 None => Err(Box::new(Self::remux_incomplete_response(cache, &key))),
6884 }
6885 }
6886 HlsCacheStartResult::AlreadyComplete(data) => Ok(data),
6887 HlsCacheStartResult::PreviouslyFailed(message) => {
6888 tracing::debug!("previous remux generation failed: {message}");
6889 Err(Box::new(Self::build_remux_unprocessable_response(&message)))
6890 }
6891 HlsCacheStartResult::CacheDisabled => {
6894 let notify = Arc::new(tokio::sync::Notify::new());
6895 let handle = HlsCache::spawn_generation(cache, key, notify, generate);
6896 match handle.await {
6897 Ok(Ok(data)) => Ok(data),
6898 Ok(Err(error)) => Err(Box::new(Self::build_remux_error_response(&error))),
6899 Err(join_error) => {
6900 tracing::warn!("remux generation task did not finish: {join_error}");
6901 Err(Box::new(Self::build_remux_retry_response(
6902 "generation did not complete",
6903 )))
6904 }
6905 }
6906 }
6907 }
6908 }
6909
6910 #[cfg(feature = "media-metadata")]
6917 fn remux_incomplete_response(
6918 cache: &mbr_core::video_transcode_cache::HlsCache,
6919 key: &mbr_core::video_transcode_cache::HlsCacheKey,
6920 ) -> Response<Body> {
6921 use mbr_core::video_transcode_cache::HlsCacheState;
6922
6923 match cache.get_state(key) {
6924 Some(HlsCacheState::Failed(message)) => {
6925 Self::build_remux_unprocessable_response(&message)
6926 }
6927 _ => Self::build_remux_retry_response("generation is not available yet"),
6928 }
6929 }
6930
6931 #[cfg(feature = "media-metadata")]
6933 fn build_remux_unprocessable_response(message: &str) -> Response<Body> {
6934 build_response_or_500(
6935 Response::builder()
6936 .status(StatusCode::UNPROCESSABLE_ENTITY)
6937 .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
6938 .body(Body::from(format!("Cannot serve this variant: {message}"))),
6939 )
6940 }
6941
6942 #[cfg(feature = "media-metadata")]
6948 fn build_remux_retry_response(reason: &str) -> Response<Body> {
6949 build_response_or_500(
6950 Response::builder()
6951 .status(StatusCode::SERVICE_UNAVAILABLE)
6952 .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
6953 .header(header::RETRY_AFTER, "1")
6954 .body(Body::from(format!("{reason}; please retry"))),
6955 )
6956 }
6957
6958 #[cfg(feature = "media-metadata")]
6963 fn build_remux_error_response(
6964 error: &mbr_core::video_transcode::TranscodeError,
6965 ) -> Response<Body> {
6966 use mbr_core::video_transcode::TranscodeError;
6967
6968 let status = match error {
6969 TranscodeError::NoVideoStream { .. }
6971 | TranscodeError::NoKeyframeIndex
6972 | TranscodeError::UnsupportedFormat => StatusCode::UNPROCESSABLE_ENTITY,
6973 TranscodeError::SegmentOutOfRange { .. } => StatusCode::NOT_FOUND,
6975 _ => StatusCode::INTERNAL_SERVER_ERROR,
6976 };
6977
6978 tracing::warn!("remux failed ({status}): {error}");
6979 build_response_or_500(
6980 Response::builder()
6981 .status(status)
6982 .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
6983 .body(Body::from(error.to_string())),
6984 )
6985 }
6986
6987 #[cfg(feature = "media-metadata")]
6998 async fn try_serve_remux_content(path: &str, config: &ServerState) -> Option<Response<Body>> {
6999 use mbr_core::video_metadata_cache::cache_key_with_mtime;
7000 use mbr_core::video_remux::{
7001 RemuxPart, generate_remux_init, generate_remux_playlist, generate_remux_segment,
7002 parse_remux_request,
7003 };
7004 use mbr_core::video_transcode_cache::HlsCacheKey;
7005
7006 let request = parse_remux_request(path)?;
7007
7008 let Some(video_file) = resolve_media_source_file(
7011 request.video_path,
7012 &config.base_dir,
7013 &config.static_folder,
7014 &config.mount_targets(),
7015 ) else {
7016 tracing::debug!("no video file for remux request: {}", request.video_path);
7017 return None;
7018 };
7019
7020 let cache_key =
7021 HlsCacheKey::remux(cache_key_with_mtime(&video_file, "remux"), request.part);
7022
7023 let base_name = std::path::Path::new(request.video_path)
7026 .file_name()
7027 .and_then(|name| name.to_str())
7028 .unwrap_or("video")
7029 .to_string();
7030
7031 let part = request.part;
7032 let source = video_file.clone();
7033 let generate = move || match part {
7034 RemuxPart::Playlist => {
7035 generate_remux_playlist(&source, &base_name).map(String::into_bytes)
7036 }
7037 RemuxPart::Init => generate_remux_init(&source),
7038 RemuxPart::Segment(index) => generate_remux_segment(&source, index),
7039 };
7040
7041 tracing::debug!("remux request for {:?}: {:?}", video_file, part);
7042 Some(
7043 match Self::generate_remux_part(&config.hls_cache, cache_key, generate).await {
7044 Ok(data) => Self::build_remux_response(part, data),
7045 Err(response) => *response,
7046 },
7047 )
7048 }
7049
7050 #[cfg(feature = "media-metadata")]
7057 async fn probe_resolution_cached(
7058 video_file: &std::path::Path,
7059 config: &ServerState,
7060 ) -> Option<mbr_core::video_transcode::VideoResolution> {
7061 use mbr_core::video_metadata_cache::cache_key_with_mtime;
7062 use mbr_core::video_transcode::probe_video_resolution;
7063
7064 let key = cache_key_with_mtime(video_file, "resolution");
7065 if let Some(res) = config.video_resolution_cache.pin().get(&key).cloned() {
7066 return Some(res);
7067 }
7068
7069 let path = video_file.to_path_buf();
7070 let probed = tokio::task::spawn_blocking(move || probe_video_resolution(&path))
7071 .await
7072 .inspect_err(|e| tracing::warn!("resolution probe task failed: {e}"))
7073 .ok()?
7074 .ok()?;
7075 config
7076 .video_resolution_cache
7077 .pin()
7078 .insert(key, probed.clone());
7079 Some(probed)
7080 }
7081
7082 #[cfg(feature = "media-metadata")]
7087 async fn try_serve_hls_content(path: &str, config: &ServerState) -> Option<Response<Body>> {
7088 use mbr_core::video_transcode::{
7089 HlsRequest, TranscodeError, generate_hls_playlist, parse_hls_request, should_transcode,
7090 transcode_segment,
7091 };
7092 use mbr_core::video_transcode_cache::{
7093 HLS_WAIT_TIMEOUT, HlsCacheKey, HlsCacheStartResult, HlsCacheState,
7094 };
7095
7096 fn build_transcode_error_response(error: &TranscodeError) -> Option<Response<Body>> {
7098 match error {
7099 TranscodeError::SourceTooSmall {
7100 source_height,
7101 target_height,
7102 } => Some(build_response_or_500(
7103 Response::builder()
7104 .status(StatusCode::UNPROCESSABLE_ENTITY)
7105 .header(header::CONTENT_TYPE, "text/plain")
7106 .body(Body::from(format!(
7107 "Cannot transcode: source ({}p) not larger than target ({}p)",
7108 source_height, target_height
7109 ))),
7110 )),
7111 TranscodeError::SegmentOutOfRange {
7112 segment_index,
7113 video_duration,
7114 } => Some(build_response_or_500(
7115 Response::builder()
7116 .status(StatusCode::NOT_FOUND)
7117 .header(header::CONTENT_TYPE, "text/plain")
7118 .body(Body::from(format!(
7119 "Segment {} is out of range (video duration: {:.1}s)",
7120 segment_index, video_duration
7121 ))),
7122 )),
7123 _ => None,
7125 }
7126 }
7127
7128 let hls_request = parse_hls_request(path)?;
7130
7131 let (video_path, target) = match &hls_request {
7133 HlsRequest::Playlist { video_path, target } => (video_path.clone(), *target),
7134 HlsRequest::Segment {
7135 video_path, target, ..
7136 } => (video_path.clone(), *target),
7137 };
7138
7139 tracing::debug!("HLS request: {:?}", hls_request);
7140
7141 let Some(video_file) = resolve_media_source_file(
7143 &video_path,
7144 &config.base_dir,
7145 &config.static_folder,
7146 &config.mount_targets(),
7147 ) else {
7148 tracing::debug!("Original video file not found for HLS: {}", video_path);
7149 return None;
7150 };
7151
7152 let content_key = match &hls_request {
7156 HlsRequest::Playlist { .. } => HlsCacheKey::playlist(video_file.clone(), target),
7157 HlsRequest::Segment { segment_index, .. } => {
7158 HlsCacheKey::segment(video_file.clone(), target, *segment_index)
7159 }
7160 };
7161 if let Some(HlsCacheState::Complete(data)) = config.hls_cache.get_state(&content_key) {
7162 tracing::debug!("Serving cached HLS content (pre-probe fast path)");
7163 return Some(match hls_request {
7164 HlsRequest::Playlist { .. } => Self::build_hls_playlist_response(data),
7165 HlsRequest::Segment { .. } => Self::build_hls_segment_response(data),
7166 });
7167 }
7168
7169 let resolution = Self::probe_resolution_cached(&video_file, config).await?;
7174 if !should_transcode(resolution.height, target) {
7175 tracing::debug!(
7176 "Video already at or below target resolution: {}x{} <= {}",
7177 resolution.width,
7178 resolution.height,
7179 target.height()
7180 );
7181 return Some(build_response_or_500(
7183 Response::builder()
7184 .status(StatusCode::UNPROCESSABLE_ENTITY)
7185 .header(header::CONTENT_TYPE, "text/plain")
7186 .body(Body::from(format!(
7187 "Cannot transcode: source ({}p) not larger than target ({}p)",
7188 resolution.height,
7189 target.height()
7190 ))),
7191 ));
7192 }
7193
7194 match hls_request {
7195 HlsRequest::Playlist { .. } => {
7196 let cache_key = HlsCacheKey::playlist(video_file.clone(), target);
7198
7199 let base_name = std::path::Path::new(&video_path)
7201 .file_stem()
7202 .and_then(|s| s.to_str())
7203 .unwrap_or("video");
7204
7205 match config.hls_cache.start_generation(cache_key.clone()) {
7206 HlsCacheStartResult::Started(notify) => {
7207 tracing::debug!("Generating HLS playlist for {:?}", video_file);
7208
7209 let mut slot = HlsGenerationSlot::new(
7214 &config.hls_cache,
7215 cache_key.clone(),
7216 Arc::clone(¬ify),
7217 );
7218
7219 let video_file_clone = video_file.clone();
7220 let base_name = base_name.to_string();
7221 let result = tokio::task::spawn_blocking(move || {
7222 generate_hls_playlist(&video_file_clone, target, &base_name)
7223 })
7224 .await;
7225
7226 match result {
7227 Ok(Ok(playlist)) => {
7228 config
7229 .hls_cache
7230 .complete_generation(cache_key.clone(), playlist.into_bytes());
7231 slot.settled();
7232 notify.notify_waiters();
7233
7234 if let Some(HlsCacheState::Complete(data)) =
7235 config.hls_cache.get_state(&cache_key)
7236 {
7237 return Some(Self::build_hls_playlist_response(data));
7238 }
7239 }
7240 Ok(Err(e)) => {
7241 tracing::warn!("Playlist generation failed: {}", e);
7242 config.hls_cache.fail_generation(cache_key, &e);
7243 slot.settled();
7244 notify.notify_waiters();
7245 if let Some(response) = build_transcode_error_response(&e) {
7247 return Some(response);
7248 }
7249 return None;
7250 }
7251 Err(e) => {
7252 tracing::warn!("Playlist generation task panicked: {}", e);
7254 return None;
7255 }
7256 }
7257 }
7258 HlsCacheStartResult::AlreadyInProgress(notify) => {
7259 tracing::debug!("Waiting for in-progress playlist generation");
7260 match config
7261 .hls_cache
7262 .wait_for_completion(&cache_key, notify, HLS_WAIT_TIMEOUT)
7263 .await
7264 {
7265 Some(data) => return Some(Self::build_hls_playlist_response(data)),
7266 None => return None,
7267 }
7268 }
7269 HlsCacheStartResult::AlreadyComplete(data) => {
7270 tracing::debug!("Serving cached playlist");
7271 return Some(Self::build_hls_playlist_response(data));
7272 }
7273 HlsCacheStartResult::PreviouslyFailed(msg) => {
7274 tracing::debug!("Previous playlist generation failed: {}", msg);
7275 return Some(build_response_or_500(
7277 Response::builder()
7278 .status(StatusCode::UNPROCESSABLE_ENTITY)
7279 .header(header::CONTENT_TYPE, "text/plain")
7280 .body(Body::from(format!("Transcode failed: {}", msg))),
7281 ));
7282 }
7283 HlsCacheStartResult::CacheDisabled => {
7284 let video_file_clone = video_file.clone();
7286 let base_name = base_name.to_string();
7287 let result = tokio::task::spawn_blocking(move || {
7288 generate_hls_playlist(&video_file_clone, target, &base_name)
7289 })
7290 .await;
7291
7292 match result {
7293 Ok(Ok(playlist)) => {
7294 return Some(Self::build_hls_playlist_response(Arc::new(
7295 playlist.into_bytes(),
7296 )));
7297 }
7298 Ok(Err(e)) => {
7299 tracing::warn!("Playlist generation failed: {}", e);
7300 if let Some(response) = build_transcode_error_response(&e) {
7302 return Some(response);
7303 }
7304 return None;
7305 }
7306 Err(e) => {
7307 tracing::warn!("Playlist generation task panicked: {}", e);
7308 return None;
7309 }
7310 }
7311 }
7312 }
7313 }
7314 HlsRequest::Segment { segment_index, .. } => {
7315 let cache_key = HlsCacheKey::segment(video_file.clone(), target, segment_index);
7317
7318 match config.hls_cache.start_generation(cache_key.clone()) {
7319 HlsCacheStartResult::Started(notify) => {
7320 tracing::info!(
7321 "Transcoding segment {} for {:?} @ {:?}",
7322 segment_index,
7323 video_file,
7324 target
7325 );
7326
7327 let mut slot = HlsGenerationSlot::new(
7331 &config.hls_cache,
7332 cache_key.clone(),
7333 Arc::clone(¬ify),
7334 );
7335
7336 let video_file_clone = video_file.clone();
7337 let result = tokio::task::spawn_blocking(move || {
7338 transcode_segment(&video_file_clone, target, segment_index)
7339 })
7340 .await;
7341
7342 match result {
7343 Ok(Ok(data)) => {
7344 config
7345 .hls_cache
7346 .complete_generation(cache_key.clone(), data);
7347 slot.settled();
7348 notify.notify_waiters();
7349
7350 if let Some(HlsCacheState::Complete(data)) =
7351 config.hls_cache.get_state(&cache_key)
7352 {
7353 return Some(Self::build_hls_segment_response(data));
7354 }
7355 }
7356 Ok(Err(e)) => {
7357 tracing::warn!("Segment transcode failed: {}", e);
7358 config.hls_cache.fail_generation(cache_key, &e);
7359 slot.settled();
7360 notify.notify_waiters();
7361 if let Some(response) = build_transcode_error_response(&e) {
7363 return Some(response);
7364 }
7365 return None;
7366 }
7367 Err(e) => {
7368 tracing::warn!("Segment transcode task panicked: {}", e);
7370 return None;
7371 }
7372 }
7373 }
7374 HlsCacheStartResult::AlreadyInProgress(notify) => {
7375 tracing::debug!("Waiting for in-progress segment transcode");
7376 match config
7377 .hls_cache
7378 .wait_for_completion(&cache_key, notify, HLS_WAIT_TIMEOUT)
7379 .await
7380 {
7381 Some(data) => return Some(Self::build_hls_segment_response(data)),
7382 None => return None,
7383 }
7384 }
7385 HlsCacheStartResult::AlreadyComplete(data) => {
7386 tracing::debug!("Serving cached segment");
7387 return Some(Self::build_hls_segment_response(data));
7388 }
7389 HlsCacheStartResult::PreviouslyFailed(msg) => {
7390 tracing::debug!("Previous segment transcode failed: {}", msg);
7391 return Some(build_response_or_500(
7393 Response::builder()
7394 .status(StatusCode::UNPROCESSABLE_ENTITY)
7395 .header(header::CONTENT_TYPE, "text/plain")
7396 .body(Body::from(format!("Transcode failed: {}", msg))),
7397 ));
7398 }
7399 HlsCacheStartResult::CacheDisabled => {
7400 let video_file_clone = video_file.clone();
7402 let result = tokio::task::spawn_blocking(move || {
7403 transcode_segment(&video_file_clone, target, segment_index)
7404 })
7405 .await;
7406
7407 match result {
7408 Ok(Ok(data)) => {
7409 return Some(Self::build_hls_segment_response(Arc::new(data)));
7410 }
7411 Ok(Err(e)) => {
7412 tracing::warn!("Segment transcode failed: {}", e);
7413 if let Some(response) = build_transcode_error_response(&e) {
7415 return Some(response);
7416 }
7417 return None;
7418 }
7419 Err(e) => {
7420 tracing::warn!("Segment transcode task panicked: {}", e);
7421 return None;
7422 }
7423 }
7424 }
7425 }
7426 }
7427 }
7428
7429 None
7430 }
7431
7432 async fn modified_secs(config: &ServerState, path: &Path) -> Option<u64> {
7437 let vault = Arc::clone(config.repo.vault());
7438 let source = vault.vault_path(path).ok()?;
7439 tokio::task::spawn_blocking(move || vault.stat(&source))
7440 .await
7441 .ok()?
7442 .ok()??
7443 .modified
7444 .duration_since(std::time::UNIX_EPOCH)
7445 .ok()
7446 .map(|d| d.as_secs())
7447 }
7448
7449 async fn markdown_to_html(
7450 md_path: &Path,
7451 config: &ServerState,
7452 edit_enabled: bool,
7453 ) -> Result<Response<Body>, MbrError> {
7454 let root_path = config.base_dir.as_path();
7455
7456 let is_index_file = md_path
7458 .file_name()
7459 .and_then(|f| f.to_str())
7460 .is_some_and(|f| f == config.index_file);
7461
7462 let link_transform_config = LinkTransformConfig {
7463 markdown_extensions: config.markdown_extensions.clone(),
7464 index_file: config.index_file.clone(),
7465 is_index_file,
7466 url_depth: None,
7467 current_page_url: mbr_core::repo::build_markdown_url_path(
7468 md_path,
7469 root_path,
7470 &config.index_file,
7471 ),
7472 markdown_page_probe: Some(mbr_core::link_transform::filesystem_markdown_page_probe(
7477 owned_resolver_config(config),
7478 )),
7479 };
7480
7481 #[cfg(feature = "media-metadata")]
7483 let transcode_enabled = config.transcode_enabled;
7484 #[cfg(not(feature = "media-metadata"))]
7485 let transcode_enabled = false;
7486
7487 let valid_tag_sources = mbr_core::config::tag_sources_to_set(&config.tag_sources);
7488 let source = markdown::read_source_fingerprinted(
7492 Arc::clone(config.repo.vault()),
7493 md_path.to_path_buf(),
7494 )
7495 .await
7496 .inspect_err(|e| {
7497 log_render_failure("Error rendering markdown", e, is_not_downloaded_markdown(e))
7498 })?;
7499 let render_result = markdown::render_text_with_cache(
7500 &source.text,
7501 md_path,
7502 markdown::RenderOptions {
7503 root_path,
7504 oembed_timeout_ms: config.oembed_timeout_ms,
7505 link_transform_config,
7506 oembed_cache: Some(config.oembed_cache.clone()),
7507 server_mode: true,
7508 transcode_enabled,
7509 valid_tag_sources,
7510 review: markdown::ReviewLines::for_server(
7513 config.review_enabled,
7514 config.edit_enabled,
7515 ),
7516 mark_incomplete: config.mark_incomplete,
7517 incomplete_markers: &config.incomplete_markers,
7518 wikilink_index: Some(config.repo.wikilink_index.clone()),
7519 },
7520 )
7521 .await
7522 .inspect_err(|e| {
7523 log_render_failure("Error rendering markdown", e, is_not_downloaded_markdown(e))
7524 })?;
7525 let mut frontmatter = render_result.frontmatter;
7526 let headings = render_result.headings;
7527 let inner_html_output = render_result.html;
7528 let outbound_links = render_result.outbound_links;
7529 let has_h1 = render_result.has_h1;
7530 let word_count = render_result.word_count;
7531 let contact = render_result.contact;
7532 let readability_counts = mbr_core::readability::ReadabilityCounts {
7533 words: render_result.word_count,
7534 sentences: render_result.sentence_count,
7535 syllables: render_result.syllable_count,
7536 };
7537 let readability_scores = mbr_core::readability::scores(&readability_counts);
7538 let relative_md_path =
7544 pathdiff::diff_paths(md_path, root_path).unwrap_or_else(|| md_path.to_path_buf());
7545 frontmatter.insert(
7546 "markdown_source".into(),
7547 mbr_core::url_path::path_to_url(&relative_md_path).into(),
7548 );
7549 frontmatter.insert("markdown_source_hash".into(), source.hash.into());
7554 frontmatter.insert("server_mode".into(), "true".into());
7556 frontmatter.insert(
7558 "gui_mode".into(),
7559 if config.gui_mode { "true" } else { "" }.into(),
7560 );
7561
7562 let url_path_buf = if is_index_file {
7565 relative_md_path
7568 .parent()
7569 .unwrap_or(Path::new(""))
7570 .to_path_buf()
7571 } else {
7572 let parent = relative_md_path.parent().unwrap_or(Path::new(""));
7575 let stem = relative_md_path
7576 .file_stem()
7577 .and_then(|s| s.to_str())
7578 .unwrap_or("");
7579 parent.join(stem)
7580 };
7581
7582 let current_url =
7588 format!("/{}/", mbr_core::url_path::path_to_url(&url_path_buf)).replace("//", "/");
7589
7590 if config.link_tracking {
7595 let resolved_links =
7597 resolve_outbound_links(¤t_url, outbound_links, is_index_file);
7598 config
7599 .link_cache
7600 .insert(current_url.clone(), resolved_links);
7601 }
7602
7603 let modified_secs = Self::modified_secs(config, md_path).await;
7607
7608 let parent_dir = relative_md_path.parent().unwrap_or(Path::new(""));
7611
7612 let siblings: Arc<Vec<serde_json::Value>> = cached_dir_files(config, parent_dir);
7616
7617 let mut extra_context = page_context::markdown_extra_context(
7620 &page_context::MarkdownPageParams {
7621 breadcrumb_path: &url_path_buf,
7622 headings: &headings,
7623 has_h1,
7624 word_count,
7625 readability: &readability_scores,
7626 file_path: &relative_md_path.to_string_lossy(),
7627 modified_secs,
7628 current_url: ¤t_url,
7629 siblings: &siblings,
7630 },
7631 &page_context::MarkdownContextOptions {
7632 tag_sources: &config.tag_sources,
7633 sidebar_style: &config.sidebar_style,
7634 sidebar_max_items: config.sidebar_max_items,
7635 graph_depth: config.graph_depth,
7636 flashcards_concentric_threshold: config.flashcards_concentric_threshold,
7637 flashcards_progress_indicators: config.flashcards_progress_indicators,
7638 tasks_enabled: config.tasks_enabled,
7639 review_enabled: config.review_enabled,
7640 tasks_default_include: config.tasks_default_include,
7641 title_prefix: &config.title_prefix,
7642 title_suffix: &config.title_suffix,
7643 root_dir: Some(&config.base_dir),
7644 },
7645 &page_context::UrlMode::Absolute,
7646 );
7647 extra_context.insert(
7654 "edit_enabled".to_string(),
7655 serde_json::Value::Bool(edit_enabled),
7656 );
7657 if contact.is_some() {
7658 let relationships = if config.relationship_tracking {
7659 config.repo.relationship_index.get(¤t_url)
7660 } else {
7661 Vec::new()
7662 };
7663 let backlinks = (config.link_tracking && config.inbound_index.is_ready())
7666 .then(|| config.inbound_index.source_count(¤t_url));
7667 page_context::insert_contact(
7668 &mut extra_context,
7669 contact,
7670 &relationships,
7671 backlinks,
7672 &page_context::UrlMode::Absolute,
7673 );
7674 }
7675
7676 let full_html_output = config
7677 .templates
7678 .render_markdown(&inner_html_output, frontmatter, extra_context)
7679 .inspect_err(|e| tracing::error!("Error rendering template: {e}"))?;
7680 tracing::debug!("generated the html");
7681
7682 let etag = generate_etag(full_html_output.as_bytes());
7684
7685 let last_modified = modified_secs.and_then(generate_last_modified);
7687
7688 let mut builder = Response::builder()
7689 .status(StatusCode::OK)
7690 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
7691 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
7692 .header(header::ETAG, etag);
7693
7694 if let Some(lm) = last_modified {
7695 builder = builder.header(header::LAST_MODIFIED, lm);
7696 }
7697
7698 builder
7699 .body(Body::from(full_html_output))
7700 .map_err(MbrError::from)
7701 }
7702
7703 async fn scan_directory_children(
7711 dir_path: &Path,
7712 relative_path: &Path,
7713 config: &ServerState,
7714 ) -> Result<(Vec<serde_json::Value>, Vec<serde_json::Value>), MbrError> {
7715 let dir_path = dir_path.to_path_buf();
7716 let relative_path = relative_path.to_path_buf();
7717 let static_folder = config.static_folder.clone();
7718 let markdown_extensions = config.markdown_extensions.clone();
7719 let ignore_dirs = config.ignore_dirs.clone();
7720 let ignore_globs = config.ignore_globs.clone();
7721 let index_file = config.index_file.clone();
7722 let tag_sources = config.tag_sources.clone();
7723 let relationship_types = config.relationship_types.clone();
7724 let sort = config.sort.clone();
7725
7726 let vault = Arc::clone(config.repo.vault());
7727 let static_vault = config.repo.static_vault().cloned();
7728
7729 let scan_result = tokio::task::spawn_blocking(move || {
7730 let temp_repo = Repo::init_with_vault(
7733 Arc::clone(&vault),
7734 static_folder,
7735 &markdown_extensions,
7736 &ignore_dirs,
7737 &ignore_globs,
7738 index_file.clone(),
7739 &tag_sources,
7740 &relationship_types,
7741 )
7742 .with_static_vault(static_vault);
7743
7744 let folder = match vault.vault_path(&dir_path) {
7748 Ok(folder) => folder,
7749 Err(_) => VaultPath::from_relative_native(&relative_path).unwrap_or_default(),
7750 };
7751 temp_repo.scan_vault_folder(&folder).inspect_err(|e| {
7752 tracing::error!("Error scanning directory: {e}");
7753 })?;
7754
7755 let mut files: Vec<serde_json::Value> = temp_repo
7757 .markdown_files
7758 .pin()
7759 .iter()
7760 .map(|(_, file_info)| markdown_file_to_json(file_info))
7761 .collect();
7762
7763 sort_files(&mut files, &sort);
7765
7766 let mut subdirs: Vec<serde_json::Value> = temp_repo
7771 .queued_folders
7772 .pin()
7773 .iter()
7774 .filter_map(|(abs_path, queued)| {
7775 let parent = abs_path.parent()?;
7777 if parent == dir_path.as_path() {
7778 let name = abs_path.file_name()?.to_str()?.to_string();
7779 let mut url_path = queued.path.as_str().to_string();
7780 if !url_path.starts_with('/') {
7781 url_path = "/".to_string() + &url_path;
7782 }
7783 if !url_path.ends_with('/') {
7784 url_path.push('/');
7785 }
7786 let index_fm = queued
7787 .path
7788 .join(&index_file)
7789 .ok()
7790 .filter(|p| vault.is_file(p))
7791 .and_then(|p| {
7792 markdown::extract_metadata_from_vault(vault.as_ref(), &p).ok()
7793 })
7794 .map(|m| m.metadata);
7795 Some(mbr_core::sorting::folder_entry(
7796 &name,
7797 url_path,
7798 index_fm.as_ref(),
7799 ))
7800 } else {
7801 None
7802 }
7803 })
7804 .collect();
7805 mbr_core::sorting::sort_folders(&mut subdirs, &sort);
7806
7807 Ok::<_, mbr_core::errors::RepoError>((files, subdirs))
7808 })
7809 .await;
7810
7811 match scan_result {
7812 Ok(Ok(pair)) => Ok(pair),
7813 Ok(Err(e)) => Err(e.into()),
7814 Err(e) => {
7815 tracing::error!("directory scan task failed: {e}");
7816 Err(e.into())
7817 }
7818 }
7819 }
7820
7821 async fn directory_to_html(
7825 dir_path: &Path,
7826 templates: &mbr_core::templates::Templates,
7827 root_path: &Path,
7828 config: &ServerState,
7829 edit_enabled: bool,
7830 ) -> Result<Response<Body>, MbrError> {
7831 use serde_json::json;
7832
7833 let relative_path = pathdiff::diff_paths(dir_path, root_path)
7835 .unwrap_or_else(|| std::path::PathBuf::from("."));
7836
7837 let (files, subdirs) = if config.repo.is_scan_complete() {
7846 let dir_key = listing_dir_key(&relative_path);
7847 (
7848 cached_dir_files(config, &dir_key).as_ref().clone(),
7849 cached_dir_subdirs(config, &dir_key, root_path)
7850 .as_ref()
7851 .clone(),
7852 )
7853 } else {
7854 Self::scan_directory_children(dir_path, &relative_path, config).await?
7855 };
7856
7857 let breadcrumbs = generate_breadcrumbs(&relative_path);
7859 let breadcrumbs_json = page_context::breadcrumbs_to_json(&breadcrumbs, &UrlMode::Absolute);
7860
7861 let current_dir_name = get_current_dir_name(&relative_path);
7862 let parent_path = get_parent_path(&relative_path);
7863
7864 let mut context = std::collections::HashMap::new();
7866 context.insert("files".to_string(), serde_json::Value::Array(files));
7869 context.insert("subdirs".to_string(), serde_json::Value::Array(subdirs));
7870 context.insert("breadcrumbs".to_string(), json!(breadcrumbs_json));
7871 context.insert("current_dir_name".to_string(), json!(current_dir_name));
7872 context.insert(
7873 "current_path".to_string(),
7874 json!(relative_path.to_string_lossy()),
7875 );
7876 if let Some(parent) = parent_path {
7877 context.insert("parent_path".to_string(), json!(parent));
7878 }
7879
7880 context.insert(
7882 "config".to_string(),
7883 json!({
7884 "static_folder": config.static_folder,
7885 "markdown_extensions": config.markdown_extensions,
7886 "index_file": config.index_file,
7887 "oembed_timeout_ms": config.oembed_timeout_ms,
7888 }),
7889 );
7890
7891 context.insert(
7893 "tag_sources".to_string(),
7894 json!(page_context::tag_sources_json(&config.tag_sources)),
7895 );
7896
7897 page_context::insert_page_chrome(
7899 &mut context,
7900 &PageChrome {
7901 mode: ModeFlags::Server {
7902 gui_mode: Some(config.gui_mode),
7903 mbr_base: false,
7904 },
7905 sidebar_style: &config.sidebar_style,
7906 sidebar_max_items: config.sidebar_max_items,
7907 graph_depth: config.graph_depth,
7908 tasks_enabled: config.tasks_enabled,
7909 review_enabled: config.review_enabled,
7910 tasks_default_include: config.tasks_default_include,
7911 title_affixes: Some((&config.title_prefix, &config.title_suffix)),
7912 root_dir: Some(&config.base_dir),
7913 },
7914 );
7915
7916 let is_root =
7918 relative_path.as_os_str().is_empty() || relative_path == std::path::Path::new(".");
7919
7920 context.insert("is_home".to_string(), json!(is_root));
7922 context.insert("edit_enabled".to_string(), json!(edit_enabled));
7926
7927 let full_html_output = if is_root {
7928 templates
7929 .render_home(context)
7930 .inspect_err(|e| tracing::error!("Error rendering home template: {e}"))?
7931 } else {
7932 templates
7933 .render_section(context)
7934 .inspect_err(|e| tracing::error!("Error rendering section template: {e}"))?
7935 };
7936
7937 tracing::debug!("generated directory listing html");
7938
7939 let etag = generate_etag(full_html_output.as_bytes());
7941
7942 Response::builder()
7944 .status(StatusCode::OK)
7945 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
7946 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_STORE)
7947 .header(header::ETAG, etag)
7948 .body(Body::from(full_html_output))
7949 .map_err(MbrError::from)
7950 }
7951
7952 async fn tag_page_to_html(
7954 source: &str,
7955 value: &str,
7956 config: &ServerState,
7957 ) -> Result<Response<Body>, MbrError> {
7958 let (label, label_plural) =
7960 page_context::tag_labels(&config.tag_sources, source, &capitalize_first(source));
7961
7962 let pages = config.repo.tag_index.get_pages(source, value);
7964
7965 let display_value = config
7967 .repo
7968 .tag_index
7969 .get_tag_display(source, value)
7970 .unwrap_or_else(|| value.to_string());
7971
7972 let mut context = std::collections::HashMap::new();
7974 page_context::insert_tag_page_keys(
7975 &mut context,
7976 source,
7977 &display_value,
7978 &label,
7979 &label_plural,
7980 &pages,
7981 &UrlMode::Absolute,
7982 );
7983 page_context::insert_page_chrome(
7984 &mut context,
7985 &PageChrome {
7986 mode: ModeFlags::Server {
7987 gui_mode: None,
7988 mbr_base: true,
7989 },
7990 sidebar_style: &config.sidebar_style,
7991 sidebar_max_items: config.sidebar_max_items,
7992 graph_depth: config.graph_depth,
7993 tasks_enabled: config.tasks_enabled,
7994 review_enabled: config.review_enabled,
7995 tasks_default_include: config.tasks_default_include,
7996 title_affixes: Some((&config.title_prefix, &config.title_suffix)),
7997 root_dir: Some(&config.base_dir),
7998 },
7999 );
8000
8001 let html_output = config.templates.render_tag(context)?;
8002
8003 let etag = generate_etag(html_output.as_bytes());
8004
8005 Response::builder()
8006 .status(StatusCode::OK)
8007 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
8008 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_STORE)
8009 .header(header::ETAG, etag)
8010 .body(Body::from(html_output))
8011 .map_err(MbrError::from)
8012 }
8013
8014 async fn tag_source_index_to_html(
8016 source: &str,
8017 config: &ServerState,
8018 ) -> Result<Response<Body>, MbrError> {
8019 let (label, label_plural) =
8021 page_context::tag_labels(&config.tag_sources, source, &capitalize_first(source));
8022
8023 let tags = config.repo.tag_index.get_all_tags(source);
8025
8026 let mut context = std::collections::HashMap::new();
8028 page_context::insert_tag_index_keys(&mut context, source, &label, &label_plural, &tags);
8029 page_context::insert_page_chrome(
8030 &mut context,
8031 &PageChrome {
8032 mode: ModeFlags::Server {
8033 gui_mode: None,
8034 mbr_base: true,
8035 },
8036 sidebar_style: &config.sidebar_style,
8037 sidebar_max_items: config.sidebar_max_items,
8038 graph_depth: config.graph_depth,
8039 tasks_enabled: config.tasks_enabled,
8040 review_enabled: config.review_enabled,
8041 tasks_default_include: config.tasks_default_include,
8042 title_affixes: Some((&config.title_prefix, &config.title_suffix)),
8043 root_dir: Some(&config.base_dir),
8044 },
8045 );
8046
8047 let html_output = config.templates.render_tag_index(context)?;
8048
8049 let etag = generate_etag(html_output.as_bytes());
8050
8051 Response::builder()
8052 .status(StatusCode::OK)
8053 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
8054 .header(header::CACHE_CONTROL, CACHE_CONTROL_NO_STORE)
8055 .header(header::ETAG, etag)
8056 .body(Body::from(html_output))
8057 .map_err(MbrError::from)
8058 }
8059
8060 async fn home_page(
8063 State(config): State<ServerState>,
8064 headers: HeaderMap,
8065 ) -> Result<impl IntoResponse, StatusCode> {
8066 tracing::debug!("home_page handler");
8067
8068 let tag_url_sources = mbr_core::config::tag_sources_to_url_sources(&config.tag_sources);
8069 let resolver_config = config.resolver_config(&tag_url_sources);
8070
8071 match resolve_request_path(&resolver_config, "") {
8073 ResolvedPath::MarkdownFile(md_path) => {
8074 tracing::debug!("home: rendering index markdown: {:?}", &md_path);
8075 let edit_enabled = Self::page_edit_enabled(&config, &headers);
8076 match Self::markdown_to_html(&md_path, &config, edit_enabled).await {
8077 Ok(response) => Ok(response),
8078 Err(e) if is_not_downloaded(&e) => {
8079 if let Some((vault, path)) = config.repo.locate(&md_path) {
8080 vault.request_download(std::slice::from_ref(&path));
8081 }
8082 Ok(Self::not_downloaded_page(&config, "", &md_path))
8083 }
8084 Err(e) => {
8085 tracing::error!("Error rendering home markdown: {e}");
8086 Err(StatusCode::INTERNAL_SERVER_ERROR)
8087 }
8088 }
8089 }
8090 ResolvedPath::DirectoryListing(dir_path) => {
8091 tracing::debug!("home: generating directory listing: {:?}", &dir_path);
8092 Self::directory_to_html(
8093 &dir_path,
8094 &config.templates,
8095 config.base_dir.as_path(),
8096 &config,
8097 Self::page_edit_enabled(&config, &headers),
8098 )
8099 .await
8100 .map_err(|e| {
8101 tracing::error!("Error generating home directory listing: {e}");
8102 StatusCode::INTERNAL_SERVER_ERROR
8103 })
8104 }
8105 _ => {
8106 tracing::debug!("home: unexpected resolution, showing directory listing");
8107 Self::directory_to_html(
8108 &config.base_dir,
8109 &config.templates,
8110 config.base_dir.as_path(),
8111 &config,
8112 Self::page_edit_enabled(&config, &headers),
8113 )
8114 .await
8115 .map_err(|e| {
8116 tracing::error!("Error generating home directory listing: {e}");
8117 StatusCode::INTERNAL_SERVER_ERROR
8118 })
8119 }
8120 }
8121 }
8122}
8123
8124fn reload_templates(templates: &mbr_core::templates::Templates) -> bool {
8145 match templates.reload() {
8146 Ok(()) => true,
8147 Err(e) => {
8148 tracing::error!("Failed to reload templates: {}", e);
8149 false
8150 }
8151 }
8152}
8153
8154fn should_reload_template(event_path: &str, template_folder: Option<&Path>) -> bool {
8155 match template_folder {
8156 Some(tf) => Path::new(event_path).starts_with(tf),
8157 None => Path::new(event_path)
8163 .components()
8164 .any(|c| c.as_os_str() == ".mbr"),
8165 }
8166}
8167
8168fn compute_sibling_files<'a>(
8176 files: impl Iterator<Item = &'a MarkdownInfo>,
8177 parent_dir: &Path,
8178 sort: &[SortField],
8179) -> Vec<serde_json::Value> {
8180 let mut siblings: Vec<serde_json::Value> = files
8181 .filter_map(|info| {
8182 let file_parent = info.raw_path.parent()?;
8183 (file_parent == parent_dir).then(|| markdown_file_to_json(info))
8184 })
8185 .collect();
8186 sort_files(&mut siblings, sort);
8187 siblings
8188}
8189
8190fn listing_dir_key(relative_dir: &Path) -> PathBuf {
8198 if relative_dir == Path::new(".") {
8199 PathBuf::new()
8200 } else {
8201 relative_dir.to_path_buf()
8202 }
8203}
8204
8205fn immediate_subdir_name<'a>(file_path: &'a Path, dir: &Path) -> Option<&'a std::ffi::OsStr> {
8211 let rest = if dir.as_os_str().is_empty() {
8212 file_path
8213 } else {
8214 file_path.strip_prefix(dir).ok()?
8215 };
8216 let mut components = rest.components();
8217 let first = components.next()?;
8218 components.next()?;
8221 match first {
8222 std::path::Component::Normal(name) => Some(name),
8223 _ => None,
8224 }
8225}
8226
8227fn compute_subdir_entries<'a>(
8238 file_paths: impl Iterator<Item = &'a Path>,
8239 dir: &Path,
8240 folder_frontmatter: &std::collections::HashMap<String, mbr_core::markdown::SimpleMetadata>,
8241 sort: &[SortField],
8242) -> Vec<serde_json::Value> {
8243 let mut entries = file_paths
8244 .filter_map(|path| immediate_subdir_name(path, dir))
8245 .map(|name| name.to_string_lossy().into_owned())
8246 .collect::<std::collections::BTreeSet<String>>()
8247 .into_iter()
8248 .map(|name| {
8249 let url_path = format!("/{}/", mbr_core::url_path::path_to_url(&dir.join(&name)));
8250 let index_fm = folder_frontmatter.get(&url_path);
8251 mbr_core::sorting::folder_entry(&name, url_path, index_fm)
8252 })
8253 .collect::<Vec<_>>();
8254 mbr_core::sorting::sort_folders(&mut entries, sort);
8255 entries
8256}
8257
8258fn subfolder_index_frontmatter<'a>(
8261 files: impl Iterator<Item = &'a MarkdownInfo>,
8262 dir: &Path,
8263 index_file: &str,
8264) -> std::collections::HashMap<String, mbr_core::markdown::SimpleMetadata> {
8265 mbr_core::sorting::folder_index_frontmatter(
8266 files.filter(|info| info.raw_path.parent().and_then(Path::parent) == Some(dir)),
8267 index_file,
8268 )
8269}
8270
8271fn cached_dir_files(config: &ServerState, dir: &Path) -> Arc<Vec<serde_json::Value>> {
8278 if let Some(cached) = config.sibling_nav_cache.pin().get(dir).cloned() {
8279 return cached;
8280 }
8281 let computed = Arc::new(compute_sibling_files(
8282 config
8283 .repo
8284 .markdown_files
8285 .pin()
8286 .iter()
8287 .map(|(_, info)| info),
8288 dir,
8289 &config.sort,
8290 ));
8291 if config.repo.is_scan_complete() {
8292 config
8293 .sibling_nav_cache
8294 .pin()
8295 .insert(dir.to_path_buf(), Arc::clone(&computed));
8296 }
8297 computed
8298}
8299
8300fn cached_dir_subdirs(
8306 config: &ServerState,
8307 dir: &Path,
8308 root_path: &Path,
8309) -> Arc<Vec<serde_json::Value>> {
8310 if let Some(cached) = config.subdir_cache.pin().get(dir).cloned() {
8311 return cached;
8312 }
8313 let markdown_guard = config.repo.markdown_files.pin();
8314 let other_guard = config.repo.other_files.pin();
8315 let folder_frontmatter = subfolder_index_frontmatter(
8316 markdown_guard.iter().map(|(_, info)| info),
8317 dir,
8318 &config.index_file,
8319 );
8320 let computed = Arc::new(compute_subdir_entries(
8321 markdown_guard
8322 .iter()
8323 .map(|(_, info)| info.raw_path.as_path())
8324 .chain(
8325 other_guard
8326 .iter()
8327 .filter_map(|(abs_path, _)| abs_path.strip_prefix(root_path).ok()),
8328 ),
8329 dir,
8330 &folder_frontmatter,
8331 &config.sort,
8332 ));
8333 if config.repo.is_scan_complete() {
8334 config
8335 .subdir_cache
8336 .pin()
8337 .insert(dir.to_path_buf(), Arc::clone(&computed));
8338 }
8339 computed
8340}
8341
8342fn capitalize_first(s: &str) -> String {
8351 let mut chars = s.chars();
8352 match chars.next() {
8353 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
8354 None => String::new(),
8355 }
8356}
8357
8358fn build_response_or_500(result: Result<Response<Body>, axum::http::Error>) -> Response<Body> {
8365 result.unwrap_or_else(|e| {
8366 tracing::error!("Failed to build HTTP response: {e}");
8367 let mut response = Response::new(Body::from("Internal Server Error"));
8368 *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
8369 response
8370 })
8371}
8372
8373fn canonical_redirect_response(canonical_url: &str, query: Option<&str>) -> Response<Body> {
8383 let location = match query {
8384 Some(query) if !query.is_empty() => format!("{canonical_url}?{query}"),
8385 _ => canonical_url.to_string(),
8386 };
8387 tracing::debug!("redirecting to canonical URL: {}", &location);
8388 build_response_or_500(
8389 Response::builder()
8390 .status(StatusCode::MOVED_PERMANENTLY)
8391 .header(header::LOCATION, &location)
8392 .body(Body::empty()),
8393 )
8394}
8395
8396fn generate_etag(content: &[u8]) -> String {
8399 use std::collections::hash_map::DefaultHasher;
8400 use std::hash::{Hash, Hasher};
8401
8402 let mut hasher = DefaultHasher::new();
8403 content.hash(&mut hasher);
8404 let hash = hasher.finish();
8405 format!("W/\"{:x}\"", hash)
8406}
8407
8408fn generate_last_modified(timestamp: u64) -> Option<String> {
8410 chrono::DateTime::from_timestamp(timestamp as i64, 0)
8411 .map(|dt| dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
8412}
8413
8414const CACHE_CONTROL_NO_CACHE: &str = "no-cache";
8417
8418const CACHE_CONTROL_NO_STORE: &str = "no-store";
8420
8421fn build_tag_page_outbound_links(
8429 source: &str,
8430 value: &str,
8431 tag_index: &mbr_core::tag_index::TagIndex,
8432 tag_sources: &[TagSource],
8433) -> Vec<mbr_core::link_index::OutboundLink> {
8434 use mbr_core::link_index::OutboundLink;
8435
8436 let mut outbound = Vec::new();
8437
8438 for page in tag_index.get_pages(source, value) {
8440 outbound.push(OutboundLink {
8441 to: page.url_path,
8442 text: page.title,
8443 anchor: None,
8444 internal: true,
8445 });
8446 }
8447
8448 let label = tag_sources
8450 .iter()
8451 .find(|ts| ts.url_source() == source)
8452 .map(|ts| ts.plural_label())
8453 .unwrap_or_else(|| source.to_string());
8454
8455 outbound.push(OutboundLink {
8456 to: format!("/{}/", source),
8457 text: label,
8458 anchor: None,
8459 internal: true,
8460 });
8461
8462 outbound
8463}
8464
8465fn build_tag_index_outbound_links(
8469 source: &str,
8470 tag_index: &mbr_core::tag_index::TagIndex,
8471) -> Vec<mbr_core::link_index::OutboundLink> {
8472 use mbr_core::link_index::OutboundLink;
8473
8474 tag_index
8475 .get_all_tags(source)
8476 .into_iter()
8477 .map(|tag| OutboundLink {
8478 to: format!("/{}/{}/", source, tag.normalized),
8479 text: tag.display,
8480 anchor: None,
8481 internal: true,
8482 })
8483 .collect()
8484}
8485
8486#[cfg(test)]
8487mod tests {
8488 use super::*;
8489 use std::path::PathBuf;
8490
8491 #[test]
8492 fn test_http_build_error_converts_to_mbr_error() {
8493 let http_err = Response::builder()
8496 .header("bad header name", "x")
8497 .body(Body::empty())
8498 .unwrap_err();
8499 let inner = http_err.to_string();
8500 let err = MbrError::from(http_err);
8501 assert!(matches!(err, MbrError::Http(_)));
8502 assert_eq!(
8503 err.to_string(),
8504 format!("Failed to build HTTP response: {inner}")
8505 );
8506 let source = std::error::Error::source(&err).expect("source is kept");
8507 assert_eq!(source.to_string(), inner);
8508 }
8509
8510 #[test]
8511 fn test_capitalize_first_ascii() {
8512 assert_eq!(capitalize_first("tags"), "Tags");
8513 assert_eq!(capitalize_first("t"), "T");
8514 }
8515
8516 #[test]
8517 fn test_capitalize_first_multibyte() {
8518 assert_eq!(capitalize_first("étiquettes"), "Étiquettes");
8521 assert_eq!(capitalize_first("über"), "Über");
8522 assert_eq!(capitalize_first("日本語"), "日本語");
8524 }
8525
8526 #[test]
8527 fn test_capitalize_first_empty() {
8528 assert_eq!(capitalize_first(""), "");
8529 }
8530
8531 #[test]
8532 fn test_should_reload_template_inside_template_folder() {
8533 let tf = Path::new("/x/tmpl");
8534 assert!(should_reload_template("/x/tmpl/index.html", Some(tf)));
8535 assert!(should_reload_template(
8536 "/x/tmpl/partials/_nav.html",
8537 Some(tf)
8538 ));
8539 }
8540
8541 #[test]
8542 fn test_should_reload_template_rejects_sibling_prefix() {
8543 assert!(!should_reload_template(
8546 "/x/tmpl-backup/index.html",
8547 Some(Path::new("/x/tmpl"))
8548 ));
8549 }
8550
8551 #[test]
8552 fn test_should_reload_template_outside_template_folder() {
8553 assert!(!should_reload_template(
8554 "/other/place/index.html",
8555 Some(Path::new("/x/tmpl"))
8556 ));
8557 }
8558
8559 #[test]
8560 fn test_should_reload_template_without_folder_matches_mbr_component() {
8561 assert!(should_reload_template("/repo/.mbr/index.html", None));
8562 assert!(!should_reload_template("/repo/docs/index.html", None));
8563 assert!(!should_reload_template("/repo/.mbrx/index.html", None));
8565 }
8566
8567 #[cfg(unix)]
8571 #[test]
8572 fn test_should_reload_template_matches_through_symlinked_folder() {
8573 let temp = tempfile::tempdir().expect("create temp dir");
8574 let real = temp.path().join("real-templates");
8575 std::fs::create_dir(&real).expect("create real template dir");
8576 let link = temp.path().join("link-templates");
8577 std::os::unix::fs::symlink(&real, &link).expect("create symlink");
8578
8579 let event_path = real
8581 .canonicalize()
8582 .expect("canonicalize real dir")
8583 .join("index.html");
8584 let event_path = event_path.to_string_lossy().to_string();
8585
8586 assert!(!should_reload_template(&event_path, Some(link.as_path())));
8588
8589 let canonical = link.canonicalize().unwrap_or(link);
8591 assert!(should_reload_template(&event_path, Some(&canonical)));
8592 }
8593
8594 #[test]
8597 fn test_validate_media_path_rejects_directory_traversal() {
8598 let temp_dir = tempfile::tempdir().unwrap();
8599 let result = validate_media_path("../etc/passwd", temp_dir.path(), "");
8600 assert!(matches!(result, Err(MbrError::DirectoryTraversal)));
8601 }
8602
8603 #[test]
8604 fn cloud_provider_names_come_from_the_sync_folder() {
8605 let name = |p: &str| cloud_provider_name(std::path::Path::new(p));
8606 assert_eq!(
8607 name("/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs/Notes/a.md"),
8608 Some("iCloud")
8609 );
8610 assert_eq!(
8611 name("/Users/me/Library/Mobile Documents/com~apple~CloudDocs/a.md"),
8612 Some("iCloud")
8613 );
8614 assert_eq!(
8615 name("/Users/me/Library/CloudStorage/Dropbox/notes/a.md"),
8616 Some("Dropbox")
8617 );
8618 assert_eq!(
8619 name("/Users/me/Library/CloudStorage/GoogleDrive-me@example.com/My Drive/a.md"),
8620 Some("Google Drive")
8621 );
8622 assert_eq!(
8623 name("/Users/me/Library/CloudStorage/OneDrive-Personal/a.md"),
8624 Some("OneDrive")
8625 );
8626 assert_eq!(
8628 name("/private/var/mobile/Containers/Shared/AppGroup/1234/File Provider Storage/a.md"),
8629 None
8630 );
8631 assert_eq!(name("/Users/me/notes/Dropboxes/a.md"), None);
8632 }
8633
8634 #[test]
8635 fn test_validate_media_path_rejects_embedded_directory_traversal() {
8636 let temp_dir = tempfile::tempdir().unwrap();
8637 let result = validate_media_path("some/../../etc/passwd", temp_dir.path(), "");
8638 assert!(matches!(result, Err(MbrError::DirectoryTraversal)));
8639 }
8640
8641 #[test]
8642 fn test_validate_media_path_rejects_url_encoded_traversal() {
8643 let temp_dir = tempfile::tempdir().unwrap();
8644 let result = validate_media_path("%2e%2e/etc/passwd", temp_dir.path(), "");
8646 assert!(matches!(result, Err(MbrError::DirectoryTraversal)));
8647 }
8648
8649 #[test]
8650 fn test_validate_media_path_rejects_nonexistent_file() {
8651 let temp_dir = tempfile::tempdir().unwrap();
8652 let result = validate_media_path("nonexistent.mp4", temp_dir.path(), "");
8653 assert!(matches!(result, Err(MbrError::InvalidMediaPath(_))));
8654 }
8655
8656 #[test]
8657 fn test_validate_media_path_accepts_valid_file() {
8658 let temp_dir = tempfile::tempdir().unwrap();
8659 let test_file = temp_dir.path().join("test.mp4");
8660 std::fs::write(&test_file, "dummy content").unwrap();
8661
8662 let result = validate_media_path("test.mp4", temp_dir.path(), "");
8663 assert!(result.is_ok());
8664 assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
8665 }
8666
8667 #[test]
8668 fn test_validate_media_path_handles_leading_slash() {
8669 let temp_dir = tempfile::tempdir().unwrap();
8670 let test_file = temp_dir.path().join("test.mp4");
8671 std::fs::write(&test_file, "dummy content").unwrap();
8672
8673 let result = validate_media_path("/test.mp4", temp_dir.path(), "");
8674 assert!(result.is_ok());
8675 assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
8676 }
8677
8678 #[test]
8679 fn test_validate_media_path_handles_url_encoded_spaces() {
8680 let temp_dir = tempfile::tempdir().unwrap();
8681 let test_file = temp_dir.path().join("test file.mp4");
8682 std::fs::write(&test_file, "dummy content").unwrap();
8683
8684 let result = validate_media_path("test%20file.mp4", temp_dir.path(), "");
8686 assert!(result.is_ok());
8687 assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
8688 }
8689
8690 #[test]
8691 fn test_validate_media_path_handles_nested_paths() {
8692 let temp_dir = tempfile::tempdir().unwrap();
8693 let subdir = temp_dir.path().join("videos").join("2024");
8694 std::fs::create_dir_all(&subdir).unwrap();
8695 let test_file = subdir.join("demo.mp4");
8696 std::fs::write(&test_file, "dummy content").unwrap();
8697
8698 let result = validate_media_path("videos/2024/demo.mp4", temp_dir.path(), "");
8699 assert!(result.is_ok());
8700 assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
8701 }
8702
8703 #[test]
8706 fn test_validate_media_path_external_static_folder_works() {
8707 let parent_dir = tempfile::tempdir().unwrap();
8709 let content_dir = parent_dir.path().join("content");
8710 let static_dir = parent_dir.path().join("static");
8711 std::fs::create_dir_all(&content_dir).unwrap();
8712 std::fs::create_dir_all(static_dir.join("videos")).unwrap();
8713
8714 let video_file = static_dir.join("videos").join("test.mp4");
8716 std::fs::write(&video_file, "video content").unwrap();
8717
8718 let result = validate_media_path("videos/test.mp4", &content_dir, "../static");
8720 assert!(result.is_ok());
8721 assert_eq!(result.unwrap(), video_file.canonicalize().unwrap());
8722 }
8723
8724 #[test]
8725 fn test_validate_media_path_content_root_takes_precedence() {
8726 let parent_dir = tempfile::tempdir().unwrap();
8728 let content_dir = parent_dir.path().join("content");
8729 let static_dir = parent_dir.path().join("static");
8730 std::fs::create_dir_all(content_dir.join("videos")).unwrap();
8731 std::fs::create_dir_all(static_dir.join("videos")).unwrap();
8732
8733 let content_video = content_dir.join("videos").join("test.mp4");
8735 let static_video = static_dir.join("videos").join("test.mp4");
8736 std::fs::write(&content_video, "content version").unwrap();
8737 std::fs::write(&static_video, "static version").unwrap();
8738
8739 let result = validate_media_path("videos/test.mp4", &content_dir, "../static");
8741 assert!(result.is_ok());
8742 assert_eq!(result.unwrap(), content_video.canonicalize().unwrap());
8743 }
8744
8745 #[test]
8746 fn test_validate_media_path_rejects_traversal_in_external_static() {
8747 let parent_dir = tempfile::tempdir().unwrap();
8749 let content_dir = parent_dir.path().join("content");
8750 let static_dir = parent_dir.path().join("static");
8751 std::fs::create_dir_all(&content_dir).unwrap();
8752 std::fs::create_dir_all(&static_dir).unwrap();
8753
8754 let result = validate_media_path("../etc/passwd", &content_dir, "../static");
8756 assert!(matches!(result, Err(MbrError::DirectoryTraversal)));
8757 }
8758
8759 #[test]
8760 fn test_validate_media_path_empty_static_folder_disables_fallback() {
8761 let temp_dir = tempfile::tempdir().unwrap();
8763
8764 let result = validate_media_path("nonexistent.mp4", temp_dir.path(), "");
8766 assert!(matches!(result, Err(MbrError::InvalidMediaPath(_))));
8767 }
8768
8769 #[test]
8770 fn test_validate_media_path_external_static_nested_path() {
8771 let parent_dir = tempfile::tempdir().unwrap();
8773 let content_dir = parent_dir.path().join("content");
8774 let static_dir = parent_dir.path().join("static");
8775 std::fs::create_dir_all(&content_dir).unwrap();
8776 let nested_dir = static_dir.join("videos").join("Jay Sankey").join("2024");
8777 std::fs::create_dir_all(&nested_dir).unwrap();
8778
8779 let video_file = nested_dir.join("performance.mp4");
8781 std::fs::write(&video_file, "video content").unwrap();
8782
8783 let result = validate_media_path(
8784 "videos/Jay%20Sankey/2024/performance.mp4",
8785 &content_dir,
8786 "../static",
8787 );
8788 assert!(result.is_ok());
8789 assert_eq!(result.unwrap(), video_file.canonicalize().unwrap());
8790 }
8791
8792 #[test]
8793 fn test_validate_media_path_nonexistent_static_folder_fallback_fails() {
8794 let temp_dir = tempfile::tempdir().unwrap();
8796 let content_dir = temp_dir.path().join("content");
8797 std::fs::create_dir_all(&content_dir).unwrap();
8798
8799 let result = validate_media_path("videos/test.mp4", &content_dir, "../nonexistent");
8801 assert!(matches!(result, Err(MbrError::InvalidMediaPath(_))));
8802 }
8803
8804 #[cfg(feature = "media-metadata")]
8807 #[test]
8808 fn test_resolve_media_source_file_rejects_traversal() {
8809 let temp_dir = tempfile::tempdir().unwrap();
8810 let base = temp_dir.path().join("base");
8811 std::fs::create_dir_all(&base).unwrap();
8812 std::fs::write(temp_dir.path().join("secret.mp4"), b"secret").unwrap();
8814
8815 let result = resolve_media_source_file("../secret.mp4", &base, "static", &[]);
8816 assert!(
8817 result.is_none(),
8818 "path traversal outside base_dir must be rejected"
8819 );
8820 }
8821
8822 #[cfg(feature = "media-metadata")]
8823 #[test]
8824 fn test_resolve_media_source_file_accepts_file_in_base() {
8825 let temp_dir = tempfile::tempdir().unwrap();
8826 let video_file = temp_dir.path().join("demo.mp4");
8827 std::fs::write(&video_file, b"video content").unwrap();
8828
8829 let result = resolve_media_source_file("demo.mp4", temp_dir.path(), "static", &[]);
8830 assert_eq!(result, Some(video_file.canonicalize().unwrap()));
8831 }
8832
8833 #[cfg(feature = "media-metadata")]
8834 #[test]
8835 fn test_resolve_media_source_file_accepts_file_in_static_folder() {
8836 let temp_dir = tempfile::tempdir().unwrap();
8837 let static_dir = temp_dir.path().join("static");
8838 std::fs::create_dir_all(&static_dir).unwrap();
8839 let video_file = static_dir.join("demo.mp4");
8840 std::fs::write(&video_file, b"video content").unwrap();
8841
8842 let result = resolve_media_source_file("demo.mp4", temp_dir.path(), "static", &[]);
8843 assert_eq!(result, Some(video_file.canonicalize().unwrap()));
8844 }
8845
8846 #[test]
8849 fn test_safe_join_asset_accepts_valid_file() {
8850 let temp_dir = tempfile::tempdir().unwrap();
8851 let test_file = temp_dir.path().join("theme.css");
8852 std::fs::write(&test_file, "body {}").unwrap();
8853
8854 let result = safe_join_asset(temp_dir.path(), "theme.css");
8855 assert!(result.is_some());
8856 assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
8857 }
8858
8859 #[test]
8860 fn test_safe_join_asset_handles_leading_slash() {
8861 let temp_dir = tempfile::tempdir().unwrap();
8862 let test_file = temp_dir.path().join("theme.css");
8863 std::fs::write(&test_file, "body {}").unwrap();
8864
8865 let result = safe_join_asset(temp_dir.path(), "/theme.css");
8866 assert!(result.is_some());
8867 assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
8868 }
8869
8870 #[test]
8871 fn test_safe_join_asset_rejects_directory_traversal() {
8872 let temp_dir = tempfile::tempdir().unwrap();
8873
8874 let attacks = vec![
8876 "../etc/passwd",
8877 "../../etc/passwd",
8878 "foo/../../../etc/passwd",
8879 "../theme.css",
8880 ];
8881
8882 for attack in attacks {
8883 let result = safe_join_asset(temp_dir.path(), attack);
8884 assert!(
8885 result.is_none(),
8886 "Path traversal should be blocked for: {}",
8887 attack
8888 );
8889 }
8890 }
8891
8892 #[test]
8893 fn test_safe_join_asset_rejects_nonexistent_file() {
8894 let temp_dir = tempfile::tempdir().unwrap();
8895
8896 let result = safe_join_asset(temp_dir.path(), "nonexistent.css");
8897 assert!(result.is_none());
8898 }
8899
8900 #[test]
8901 fn test_safe_join_asset_rejects_directory() {
8902 let temp_dir = tempfile::tempdir().unwrap();
8903 let subdir = temp_dir.path().join("subdir");
8904 std::fs::create_dir(&subdir).unwrap();
8905
8906 let result = safe_join_asset(temp_dir.path(), "subdir");
8907 assert!(result.is_none(), "Directories should not be served");
8908 }
8909
8910 #[test]
8911 fn test_safe_join_asset_handles_nested_paths() {
8912 let temp_dir = tempfile::tempdir().unwrap();
8913 let nested = temp_dir.path().join("components-js").join("module");
8914 std::fs::create_dir_all(&nested).unwrap();
8915 let test_file = nested.join("app.js");
8916 std::fs::write(&test_file, "export {}").unwrap();
8917
8918 let result = safe_join_asset(temp_dir.path(), "components-js/module/app.js");
8919 assert!(result.is_some());
8920 assert_eq!(result.unwrap(), test_file.canonicalize().unwrap());
8921 }
8922
8923 #[cfg(unix)]
8924 #[test]
8925 fn test_safe_join_asset_blocks_symlink_escape() {
8926 use std::os::unix::fs::symlink;
8927
8928 let temp_dir = tempfile::tempdir().unwrap();
8929
8930 let link_path = temp_dir.path().join("escape");
8932 if symlink("/tmp", &link_path).is_ok() {
8933 let result = safe_join_asset(temp_dir.path(), "escape/some_file");
8935 assert!(result.is_none(), "Symlink escape should be blocked");
8936 }
8937 }
8938
8939 fn mk_markdown_info(raw: &str, url: &str, title: &str) -> MarkdownInfo {
8940 let mut frontmatter = mbr_core::markdown::SimpleMetadata::new();
8941 frontmatter.insert(
8942 "title".to_string(),
8943 serde_json::Value::String(title.to_string()),
8944 );
8945 MarkdownInfo {
8946 raw_path: PathBuf::from(raw),
8947 url_path: url.to_string(),
8948 frontmatter: Some(frontmatter),
8949 created: 0,
8950 modified: 0,
8951 relationships: Vec::new(),
8952 }
8953 }
8954
8955 fn title_sort() -> Vec<SortField> {
8956 vec![SortField {
8957 field: "title".to_string(),
8958 order: "asc".to_string(),
8959 compare: "string".to_string(),
8960 }]
8961 }
8962
8963 #[test]
8966 fn test_compute_sibling_files_matches_full_scan() {
8967 let files = [
8968 mk_markdown_info("docs/b.md", "/docs/b/", "Beta"),
8969 mk_markdown_info("docs/a.md", "/docs/a/", "Alpha"),
8970 mk_markdown_info("other/c.md", "/other/c/", "Gamma"),
8971 mk_markdown_info("root.md", "/root/", "Root"),
8972 ];
8973 let sort = title_sort();
8974 let parent = Path::new("docs");
8975
8976 let got = compute_sibling_files(files.iter(), parent, &sort);
8977
8978 let mut expected: Vec<serde_json::Value> = files
8980 .iter()
8981 .filter_map(|info| {
8982 let file_parent = info.raw_path.parent()?;
8983 (file_parent == parent).then(|| markdown_file_to_json(info))
8984 })
8985 .collect();
8986 sort_files(&mut expected, &sort);
8987
8988 assert_eq!(got, expected);
8989 assert_eq!(got.len(), 2);
8990 assert_eq!(got[0]["title"], "Alpha");
8992 assert_eq!(got[1]["title"], "Beta");
8993 }
8994
8995 #[test]
8997 fn test_compute_sibling_files_no_children() {
8998 let files = [mk_markdown_info("docs/a.md", "/docs/a/", "Alpha")];
8999 let got = compute_sibling_files(files.iter(), Path::new("empty"), &title_sort());
9000 assert!(got.is_empty());
9001 }
9002
9003 #[test]
9007 fn test_wikilink_delta_names_keeps_labeled_aliases() {
9008 let dir = tempfile::TempDir::new().unwrap();
9009 let path = dir.path().join("mare.md");
9010 std::fs::write(
9011 &path,
9012 "---\ntitle: Mary Doe\naliases:\n - nickname: Mare\n---\n",
9013 )
9014 .unwrap();
9015 let fm = mbr_core::markdown::extract_metadata_from_file(&path)
9016 .unwrap()
9017 .metadata;
9018 let delta = Server::wikilink_delta_names(Some(&fm), "mare", "mary");
9019 assert!(delta.is_empty(), "{delta:?}");
9020 }
9021
9022 #[test]
9027 fn test_immediate_subdir_name_rules() {
9028 let root = Path::new("");
9029 assert_eq!(
9030 immediate_subdir_name(Path::new("docs/guide.md"), root)
9031 .map(|n| n.to_string_lossy().into_owned()),
9032 Some("docs".to_string())
9033 );
9034 assert_eq!(immediate_subdir_name(Path::new("readme.md"), root), None);
9035 assert_eq!(
9036 immediate_subdir_name(Path::new("docs/deep/x.md"), Path::new("docs"))
9037 .map(|n| n.to_string_lossy().into_owned()),
9038 Some("deep".to_string())
9039 );
9040 assert_eq!(
9042 immediate_subdir_name(Path::new("other/x.md"), Path::new("docs")),
9043 None
9044 );
9045 assert_eq!(
9047 immediate_subdir_name(Path::new("docs/guide.md"), Path::new("docs")),
9048 None
9049 );
9050 }
9051
9052 #[test]
9055 fn test_compute_subdir_entries_dedupes_and_sorts() {
9056 let paths = [
9057 Path::new("docs/zeta/a.md"),
9058 Path::new("docs/alpha/b.md"),
9059 Path::new("docs/alpha/c.md"),
9060 Path::new("docs/readme.md"),
9061 Path::new("elsewhere/d.md"),
9062 ];
9063 let got = compute_subdir_entries(
9064 paths.into_iter(),
9065 Path::new("docs"),
9066 &Default::default(),
9067 &[],
9068 );
9069 assert_eq!(got.len(), 2);
9070 assert_eq!(got[0]["name"], "alpha");
9071 assert_eq!(got[0]["url_path"], "/docs/alpha/");
9072 assert_eq!(got[1]["name"], "zeta");
9073 assert_eq!(got[1]["url_path"], "/docs/zeta/");
9074 }
9075
9076 #[test]
9079 fn test_listing_dir_key_normalizes_dot_to_root() {
9080 assert_eq!(listing_dir_key(Path::new(".")), PathBuf::new());
9081 assert_eq!(listing_dir_key(Path::new("")), PathBuf::new());
9082 assert_eq!(listing_dir_key(Path::new("docs")), PathBuf::from("docs"));
9083 }
9084
9085 fn listing_fixture() -> tempfile::TempDir {
9087 let tmp = tempfile::tempdir().unwrap();
9088 let root = tmp.path();
9089 std::fs::create_dir_all(root.join("docs/deep")).unwrap();
9090 std::fs::create_dir_all(root.join("assets")).unwrap();
9091 std::fs::write(root.join("beta.md"), "---\ntitle: Beta\n---\n\nB\n").unwrap();
9092 std::fs::write(root.join("alpha.md"), "---\ntitle: Alpha\n---\n\nA\n").unwrap();
9093 std::fs::write(root.join("docs/guide.md"), "---\ntitle: Guide\n---\n\nG\n").unwrap();
9094 std::fs::write(root.join("docs/deep/x.md"), "# X\n").unwrap();
9095 std::fs::write(root.join("assets/pic.png"), b"not-really-a-png").unwrap();
9096 tmp
9097 }
9098
9099 fn fixture_repo(root: &Path) -> Repo {
9100 Repo::init(
9101 root,
9102 "static",
9103 &["md".to_string()],
9104 &[],
9105 &[],
9106 "index.md",
9107 &[],
9108 &[],
9109 )
9110 }
9111
9112 #[test]
9122 fn test_inbound_index_agrees_with_grep_backlinks() {
9123 let tmp = tempfile::tempdir().unwrap();
9124 let root = tmp.path().canonicalize().unwrap();
9125 std::fs::create_dir_all(root.join("docs")).unwrap();
9126
9127 std::fs::write(
9128 root.join("alpha.md"),
9129 "---\ntitle: Alpha\n---\n\nSee [the guide](docs/guide.md) and [beta](beta.md).\n",
9130 )
9131 .unwrap();
9132 std::fs::write(
9133 root.join("beta.md"),
9134 "---\ntitle: Beta\n---\n\nBack to [alpha](alpha.md).\n",
9135 )
9136 .unwrap();
9137 std::fs::write(
9138 root.join("docs/guide.md"),
9139 "---\ntitle: Guide\n---\n\nUp to [alpha](../alpha.md); also [beta](../beta.md).\n",
9140 )
9141 .unwrap();
9142 std::fs::write(
9144 root.join("orphan.md"),
9145 "---\ntitle: Orphan\n---\n\nAlone.\n",
9146 )
9147 .unwrap();
9148
9149 let repo = Arc::new(fixture_repo(&root));
9150 repo.scan_all().unwrap();
9151 repo.build_wikilink_index();
9152
9153 let cfg = LinkIndexConfig {
9154 base_dir: root.clone(),
9155 index_file: "index.md".to_string(),
9156 markdown_extensions: vec!["md".to_string()],
9157 valid_tag_sources: HashSet::new(),
9158 };
9159 let index = InboundIndex::new();
9160 populate_inbound_index(&repo, &index, &cfg);
9161 assert!(index.is_ready());
9162
9163 for page in ["/alpha/", "/beta/", "/docs/guide/", "/orphan/"] {
9164 let indexed: Vec<String> = index.get(page).into_iter().map(|l| l.from).collect();
9165
9166 let mut grepped = crate::link_grep::find_inbound_links(
9167 page,
9168 repo.vault().as_ref(),
9169 &["md".to_string()],
9170 &[],
9171 &[],
9172 "index.md",
9173 );
9174 mbr_core::link_index::sort_inbound_links(&mut grepped);
9175 let grepped: Vec<String> = grepped.into_iter().map(|l| l.from).collect();
9176
9177 assert_eq!(
9178 indexed, grepped,
9179 "backlink sources for {page} disagree: index={indexed:?} grep={grepped:?}"
9180 );
9181 }
9182
9183 assert_eq!(
9186 index.get("/alpha/").len(),
9187 2,
9188 "alpha should be linked from beta and the guide"
9189 );
9190 assert!(index.get("/orphan/").is_empty());
9191 }
9192
9193 #[test]
9198 fn test_directory_listing_from_index_matches_disk_scan() {
9199 let tmp = listing_fixture();
9200 let root = tmp.path().canonicalize().unwrap();
9201 let sort = title_sort();
9202
9203 let indexed = fixture_repo(&root);
9204 indexed.scan_all().unwrap();
9205
9206 for dir in [Path::new(""), Path::new("docs")] {
9207 let scan_dir = if dir.as_os_str().is_empty() {
9209 PathBuf::from(".")
9210 } else {
9211 dir.to_path_buf()
9212 };
9213 let temp_repo = fixture_repo(&root);
9214 temp_repo.scan_folder(&scan_dir).unwrap();
9215 let mut expected_files: Vec<serde_json::Value> = temp_repo
9216 .markdown_files
9217 .pin()
9218 .iter()
9219 .map(|(_, info)| markdown_file_to_json(info))
9220 .collect();
9221 sort_files(&mut expected_files, &sort);
9222 let abs_dir = root.join(dir);
9223 let expected_subdirs: std::collections::BTreeSet<String> = temp_repo
9224 .queued_folders
9225 .pin()
9226 .iter()
9227 .filter_map(|(abs_path, _)| {
9228 (abs_path.parent()? == abs_dir.as_path())
9229 .then(|| abs_path.file_name()?.to_str().map(|s| s.to_string()))?
9230 })
9231 .collect();
9232
9233 let got_files = compute_sibling_files(
9235 indexed.markdown_files.pin().iter().map(|(_, info)| info),
9236 dir,
9237 &sort,
9238 );
9239 let markdown_guard = indexed.markdown_files.pin();
9240 let other_guard = indexed.other_files.pin();
9241 let got_subdir_entries = compute_subdir_entries(
9242 markdown_guard
9243 .iter()
9244 .map(|(_, info)| info.raw_path.as_path())
9245 .chain(
9246 other_guard
9247 .iter()
9248 .filter_map(|(abs, _)| abs.strip_prefix(&root).ok()),
9249 ),
9250 dir,
9251 &Default::default(),
9252 &[],
9253 );
9254 let got_subdirs: std::collections::BTreeSet<String> = got_subdir_entries
9255 .iter()
9256 .filter_map(|entry| entry["name"].as_str().map(|s| s.to_string()))
9257 .collect();
9258
9259 assert_eq!(got_files, expected_files, "files mismatch for {dir:?}");
9260 assert_eq!(
9261 got_subdirs, expected_subdirs,
9262 "subdirs mismatch for {dir:?}"
9263 );
9264 assert!(
9265 !got_files.is_empty(),
9266 "fixture should have files in {dir:?}"
9267 );
9268 assert!(
9269 !got_subdirs.is_empty(),
9270 "fixture should have subdirs in {dir:?}"
9271 );
9272 }
9273 }
9274
9275 #[test]
9278 fn test_subdirs_include_asset_only_directories() {
9279 let tmp = listing_fixture();
9280 let root = tmp.path().canonicalize().unwrap();
9281 let repo = fixture_repo(&root);
9282 repo.scan_all().unwrap();
9283
9284 let markdown_guard = repo.markdown_files.pin();
9285 let other_guard = repo.other_files.pin();
9286 let entries = compute_subdir_entries(
9287 markdown_guard
9288 .iter()
9289 .map(|(_, info)| info.raw_path.as_path())
9290 .chain(
9291 other_guard
9292 .iter()
9293 .filter_map(|(abs, _)| abs.strip_prefix(&root).ok()),
9294 ),
9295 Path::new(""),
9296 &Default::default(),
9297 &[],
9298 );
9299 let names: Vec<&str> = entries
9300 .iter()
9301 .filter_map(|e| e["name"].as_str())
9302 .collect::<Vec<_>>();
9303 assert!(
9304 names.contains(&"assets"),
9305 "asset-only directory must still be listed: {names:?}"
9306 );
9307 }
9308
9309 #[test]
9315 fn test_render_site_json_omits_other_files() {
9316 let tmp = listing_fixture();
9317 let root = tmp.path().canonicalize().unwrap();
9318 let repo = fixture_repo(&root);
9319 repo.scan_all().unwrap();
9320 repo.scan_static_folder().unwrap();
9321 assert!(
9322 !repo.other_files.pin().is_empty(),
9323 "fixture must contain at least one non-markdown file"
9324 );
9325
9326 let params = SiteJsonParams {
9327 sort: title_sort(),
9328 sidebar_style: "panel".to_string(),
9329 sidebar_max_items: 42,
9330 relationship_tracking: false,
9331 };
9332 let bytes = render_site_json(&repo, ¶ms).expect("site.json renders");
9333 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
9334
9335 assert!(
9336 value.get("other_files").is_none(),
9337 "site.json must not include the media catalog"
9338 );
9339 let files = value["markdown_files"]
9340 .as_array()
9341 .expect("markdown_files array");
9342 assert_eq!(files.len(), 4);
9343 assert_eq!(value["index_file"], "index.md");
9344 assert_eq!(value["sidebar_style"], "panel");
9345 assert_eq!(value["sidebar_max_items"], 42);
9346 assert_eq!(value["sort"][0]["field"], "title");
9347
9348 let again = render_site_json(&repo, ¶ms).expect("site.json renders");
9350 assert_eq!(bytes, again);
9351 }
9352
9353 #[cfg(feature = "media-metadata")]
9357 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
9358 async fn test_metadata_single_flight_one_decode() {
9359 use mbr_core::video_metadata_cache::{CachedMetadata, VideoMetadataCache};
9360 use std::sync::atomic::{AtomicUsize, Ordering};
9361 use std::time::Duration;
9362
9363 let inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>> =
9364 Arc::new(papaya::HashMap::new());
9365 let cache = Arc::new(VideoMetadataCache::new(1024 * 1024));
9366 let decodes = Arc::new(AtomicUsize::new(0));
9367 let key = "videos/clip.mp4::cover::mtime=1".to_string();
9368
9369 let producer_notify = match claim_inflight(&inflight, &key) {
9371 InflightClaim::Produce(notify) => notify,
9372 InflightClaim::Wait(_) => panic!("first claim must produce"),
9373 };
9374
9375 let mut handles = Vec::new();
9377 for _ in 0..8 {
9378 let inflight = Arc::clone(&inflight);
9379 let cache = Arc::clone(&cache);
9380 let decodes = Arc::clone(&decodes);
9381 let key = key.clone();
9382 handles.push(tokio::spawn(async move {
9383 match claim_inflight(&inflight, &key) {
9384 InflightClaim::Produce(_) => {
9385 decodes.fetch_add(1, Ordering::SeqCst);
9387 panic!("concurrent request unexpectedly started a decode");
9388 }
9389 InflightClaim::Wait(notify) => {
9390 let notified = notify.notified();
9391 tokio::pin!(notified);
9392 notified.as_mut().enable();
9393 if cache.get(&key).is_none() {
9394 tokio::time::timeout(Duration::from_secs(5), notified)
9395 .await
9396 .expect("waiter timed out");
9397 }
9398 cache.get(&key).is_some()
9399 }
9400 }
9401 }));
9402 }
9403
9404 tokio::time::sleep(Duration::from_millis(50)).await;
9406
9407 decodes.fetch_add(1, Ordering::SeqCst);
9409 cache.insert(key.clone(), CachedMetadata::Cover(vec![1, 2, 3, 4]));
9410 inflight.pin().remove(&key);
9411 producer_notify.notify_waiters();
9412
9413 for handle in handles {
9414 assert!(handle.await.unwrap(), "waiter did not observe the result");
9415 }
9416 assert_eq!(
9417 decodes.load(Ordering::SeqCst),
9418 1,
9419 "only the producer should have decoded"
9420 );
9421 }
9422
9423 #[tokio::test]
9429 async fn test_inflight_slot_released_when_producer_future_dropped() {
9430 let inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>> =
9431 Arc::new(papaya::HashMap::new());
9432 let key = "videos/clip.mp4::cover::mtime=1";
9433
9434 {
9435 let inflight = Arc::clone(&inflight);
9436 let producing = async {
9437 let notify = match claim_inflight(&inflight, key) {
9438 InflightClaim::Produce(notify) => notify,
9439 InflightClaim::Wait(_) => panic!("first claim must produce"),
9440 };
9441 let _slot = InflightSlot::new(Arc::clone(&inflight), key.to_string(), notify);
9442 std::future::pending::<()>().await;
9445 };
9446 assert!(
9449 tokio::time::timeout(std::time::Duration::ZERO, producing)
9450 .await
9451 .is_err()
9452 );
9453 }
9454
9455 assert!(
9456 inflight.pin().get(key).is_none(),
9457 "cancelled producer must not leave its claim behind"
9458 );
9459 assert!(
9460 matches!(claim_inflight(&inflight, key), InflightClaim::Produce(_)),
9461 "next request must be able to produce, not wedge on a stale claim"
9462 );
9463 }
9464
9465 #[tokio::test]
9468 async fn test_inflight_slot_wakes_waiters_when_dropped() {
9469 let inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>> =
9470 Arc::new(papaya::HashMap::new());
9471 let key = "videos/clip.mp4::chapters::mtime=1";
9472
9473 let producer_notify = match claim_inflight(&inflight, key) {
9474 InflightClaim::Produce(notify) => notify,
9475 InflightClaim::Wait(_) => panic!("first claim must produce"),
9476 };
9477 let waiter_notify = match claim_inflight(&inflight, key) {
9478 InflightClaim::Wait(notify) => notify,
9479 InflightClaim::Produce(_) => panic!("second claim must wait"),
9480 };
9481 let notified = waiter_notify.notified();
9482 tokio::pin!(notified);
9483 notified.as_mut().enable();
9484
9485 drop(InflightSlot::new(
9486 Arc::clone(&inflight),
9487 key.to_string(),
9488 producer_notify,
9489 ));
9490
9491 tokio::time::timeout(std::time::Duration::from_secs(5), notified)
9492 .await
9493 .expect("waiter must be woken when the producer releases the slot");
9494 }
9495
9496 #[test]
9500 fn test_claim_inflight_admits_one_producer_under_concurrent_claims() {
9501 use std::sync::Barrier;
9502 use std::sync::atomic::{AtomicUsize, Ordering};
9503
9504 const THREADS: usize = 8;
9505 const KEYS: usize = 400;
9506
9507 let inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>> =
9508 Arc::new(papaya::HashMap::new());
9509 let barrier = Arc::new(Barrier::new(THREADS));
9510 let producers = Arc::new(AtomicUsize::new(0));
9511
9512 std::thread::scope(|scope| {
9513 for _ in 0..THREADS {
9514 let inflight = Arc::clone(&inflight);
9515 let barrier = Arc::clone(&barrier);
9516 let producers = Arc::clone(&producers);
9517 scope.spawn(move || {
9518 for i in 0..KEYS {
9519 let key = format!("key-{i}");
9520 barrier.wait();
9522 if let InflightClaim::Produce(_) = claim_inflight(&inflight, &key) {
9523 producers.fetch_add(1, Ordering::Relaxed);
9524 }
9525 }
9526 });
9527 }
9528 });
9529
9530 assert_eq!(
9531 producers.load(Ordering::Relaxed),
9532 KEYS,
9533 "each key must be claimed by exactly one producer"
9534 );
9535 }
9536
9537 #[cfg(feature = "media-metadata")]
9541 #[tokio::test]
9542 async fn test_hls_generation_slot_released_when_future_dropped() {
9543 use mbr_core::video_transcode::TranscodeTarget;
9544 use mbr_core::video_transcode_cache::{HlsCacheKey, HlsCacheStartResult};
9545
9546 let cache = HlsCache::new(1024 * 1024);
9547 let key = HlsCacheKey::segment(
9548 PathBuf::from("/videos/clip.mp4"),
9549 TranscodeTarget::Resolution720p,
9550 3,
9551 );
9552
9553 {
9554 let key = key.clone();
9555 let producing = async {
9556 let notify = match cache.start_generation(key.clone()) {
9557 HlsCacheStartResult::Started(notify) => notify,
9558 _ => panic!("first start_generation must start"),
9559 };
9560 let _slot = HlsGenerationSlot::new(&cache, key, notify);
9561 std::future::pending::<()>().await;
9562 };
9563 assert!(
9564 tokio::time::timeout(std::time::Duration::ZERO, producing)
9565 .await
9566 .is_err()
9567 );
9568 }
9569
9570 assert!(
9573 !matches!(
9574 cache.start_generation(key),
9575 HlsCacheStartResult::AlreadyInProgress(_)
9576 ),
9577 "cancelled generation must not leave the key wedged in progress"
9578 );
9579 }
9580
9581 #[tokio::test]
9588 async fn test_live_reload_action_keeps_forwarding_after_lag() {
9589 use mbr_core::change_event::{ChangeEventType, FileChangeEvent};
9590
9591 let event = |name: &str| FileChangeEvent {
9592 path: format!("/repo/{name}"),
9593 relative_path: name.to_string(),
9594 event: ChangeEventType::Modified,
9595 };
9596
9597 let (tx, mut rx) = broadcast::channel::<FileChangeEvent>(2);
9598 for i in 0..5 {
9600 tx.send(event(&format!("a{i}.md"))).unwrap();
9601 }
9602
9603 assert!(
9604 matches!(live_reload_action(rx.recv().await), LiveReloadAction::Skip),
9605 "an overflowed receiver must report lag, not terminate"
9606 );
9607
9608 assert!(matches!(
9610 live_reload_action(rx.recv().await),
9611 LiveReloadAction::Forward(_)
9612 ));
9613 tx.send(event("after-lag.md")).unwrap();
9614 while let LiveReloadAction::Forward(forwarded) = live_reload_action(rx.recv().await) {
9615 if forwarded.relative_path == "after-lag.md" {
9616 return;
9617 }
9618 }
9619 panic!("event sent after the lag was never forwarded");
9620 }
9621
9622 #[tokio::test]
9624 async fn test_live_reload_action_closes_when_sender_dropped() {
9625 let (tx, mut rx) = broadcast::channel::<mbr_core::change_event::FileChangeEvent>(2);
9626 drop(tx);
9627 assert!(matches!(
9628 live_reload_action(rx.recv().await),
9629 LiveReloadAction::Close
9630 ));
9631 }
9632
9633 #[test]
9640 fn test_invalidate_derived_caches_clears_link_caches() {
9641 use mbr_core::link_index::{InboundLink, OutboundLink};
9642
9643 let listing_caches = ListingCaches {
9644 sibling_nav_cache: Arc::new(papaya::HashMap::new()),
9645 subdir_cache: Arc::new(papaya::HashMap::new()),
9646 site_json_cache: Arc::new(parking_lot::RwLock::new(SiteJsonCache {
9647 generation: 0,
9648 body: Some(axum::body::Bytes::from_static(b"{\"markdown_files\":[]}")),
9649 })),
9650 };
9651 listing_caches
9652 .sibling_nav_cache
9653 .pin()
9654 .insert(PathBuf::from("docs"), Arc::new(vec![serde_json::json!({})]));
9655 listing_caches
9656 .subdir_cache
9657 .pin()
9658 .insert(PathBuf::new(), Arc::new(vec![serde_json::json!({})]));
9659
9660 let link_cache = LinkCache::new(1024 * 1024);
9661 link_cache.insert(
9662 "/docs/guide/".to_string(),
9663 vec![OutboundLink {
9664 to: "/docs/other/".to_string(),
9665 text: "Other".to_string(),
9666 anchor: None,
9667 internal: true,
9668 }],
9669 );
9670
9671 let inbound_link_cache = InboundLinkCache::new(1024 * 1024, 300);
9672 inbound_link_cache.insert(
9673 "/docs/other/".to_string(),
9674 vec![InboundLink {
9675 from: "/docs/guide/".to_string(),
9676 text: "Other".to_string(),
9677 anchor: None,
9678 }],
9679 );
9680
9681 invalidate_derived_caches(&listing_caches, &link_cache, &inbound_link_cache);
9682
9683 assert!(listing_caches.sibling_nav_cache.pin().is_empty());
9684 assert!(
9685 listing_caches.subdir_cache.pin().is_empty(),
9686 "directory subdirectory lists must be dropped on file change"
9687 );
9688 assert!(
9689 listing_caches.site_json_cache.read().body.is_none(),
9690 "the cached site.json body must be dropped on file change"
9691 );
9692 assert!(
9693 link_cache.get("/docs/guide/").is_none(),
9694 "outbound link cache must be dropped on file change"
9695 );
9696 assert!(
9697 inbound_link_cache.get("/docs/other/").is_none(),
9698 "inbound link cache must be dropped on file change"
9699 );
9700 }
9701
9702 #[test]
9706 fn test_site_json_cache_rejects_store_from_a_raced_rebuild() {
9707 let cache = parking_lot::RwLock::new(SiteJsonCache::default());
9708
9709 let (generation, cached) = cache.read().snapshot();
9711 assert!(cached.is_none());
9712
9713 cache.write().invalidate();
9715
9716 cache
9718 .write()
9719 .store(generation, axum::body::Bytes::from_static(b"stale"));
9720 assert!(
9721 cache.read().body.is_none(),
9722 "a body built before the invalidation must not be published"
9723 );
9724
9725 let (generation, _) = cache.read().snapshot();
9727 cache
9728 .write()
9729 .store(generation, axum::body::Bytes::from_static(b"fresh"));
9730 assert_eq!(cache.read().body.as_deref(), Some(&b"fresh"[..]));
9731 }
9732
9733 #[cfg(feature = "media-metadata")]
9737 #[test]
9738 fn test_media_cache_size_is_independent_of_oembed_cache_size() {
9739 use mbr_core::video_metadata_cache::{CachedMetadata, VideoMetadataCache};
9740
9741 let config = mbr_core::config::Config {
9742 oembed_cache_size: 0,
9743 ..Default::default()
9744 };
9745 let server_config = ServerConfig::from(&config);
9746 assert_eq!(server_config.oembed_cache_size, 0);
9747 assert_eq!(server_config.media_cache_size, 64 * 1024 * 1024);
9748
9749 let cache = VideoMetadataCache::new(server_config.media_cache_size);
9751 let key = "videos/clip.mp4::cover::mtime=1".to_string();
9752 cache.insert(key.clone(), CachedMetadata::Cover(vec![0u8; 256 * 1024]));
9753 assert!(
9754 cache.get(&key).is_some(),
9755 "media caching must stay enabled when the oembed cache is disabled"
9756 );
9757
9758 let cache = VideoMetadataCache::new(server_config.media_cache_size);
9760 for i in 0..100 {
9761 cache.insert(
9762 format!("videos/clip-{i}.mp4::cover::mtime=1"),
9763 CachedMetadata::Cover(vec![0u8; 256 * 1024]),
9764 );
9765 }
9766 assert_eq!(
9767 cache.len(),
9768 100,
9769 "100 covers of 256 KB must fit in the default media cache"
9770 );
9771 }
9772
9773 #[tokio::test]
9780 async fn test_server_init_carries_gui_mode() {
9781 let temp = tempfile::tempdir().unwrap();
9782 std::fs::write(temp.path().join("index.md"), "# Test").unwrap();
9783
9784 for gui_mode in [true, false] {
9785 let config = mbr_core::config::Config {
9786 root_dir: temp.path().to_path_buf(),
9787 ..Default::default()
9788 };
9789 let server = Server::init(ServerConfig::from(&config).with_gui_mode(gui_mode))
9790 .expect("server init should succeed over a temp repo");
9791
9792 assert_eq!(
9793 server.gui_mode, gui_mode,
9794 "Server::init must carry gui_mode through to Server; \
9795 announce_listening reads it to decide stdout vs tracing"
9796 );
9797 }
9798 }
9799
9800 #[cfg(feature = "media-metadata")]
9803 #[test]
9804 fn test_metadata_response_from_cache_variants() {
9805 use mbr_core::video_metadata_cache::CachedMetadata;
9806
9807 let jpg = Server::metadata_response_from_cache(CachedMetadata::Cover(vec![0xFF, 0xD8]));
9808 assert!(jpg.is_some());
9809 assert_eq!(
9810 jpg.unwrap().headers().get(header::CONTENT_TYPE).unwrap(),
9811 "image/jpeg"
9812 );
9813
9814 let vtt =
9815 Server::metadata_response_from_cache(CachedMetadata::Captions("WEBVTT".to_string()));
9816 assert!(vtt.is_some());
9817
9818 assert!(Server::metadata_response_from_cache(CachedMetadata::NotAvailable).is_none());
9819 }
9820
9821 #[test]
9824 fn test_resolve_new_target_rejects_parent_traversal() {
9825 let dir = tempfile::TempDir::new().unwrap();
9826 let base = dir.path().canonicalize().unwrap();
9827 assert!(matches!(
9828 resolve_new_target_path(&mbr_core::vault::LocalVault::new(&base), "../escape.md"),
9829 Err(FileOpError::Traversal)
9830 ));
9831 assert!(matches!(
9832 resolve_new_target_path(
9833 &mbr_core::vault::LocalVault::new(&base),
9834 "docs/../../escape.md"
9835 ),
9836 Err(FileOpError::Traversal)
9837 ));
9838 assert!(!base.join("../escape.md").exists());
9840 }
9841
9842 #[test]
9845 fn test_resolve_new_target_rejects_backslash_traversal() {
9846 let dir = tempfile::TempDir::new().unwrap();
9847 let vault = mbr_core::vault::LocalVault::new(dir.path());
9848 for rel in ["docs\\..\\..\\escape.md", "..\\escape.md", "docs\\..\\x.md"] {
9849 assert!(
9850 matches!(
9851 resolve_new_target_path(&vault, rel),
9852 Err(FileOpError::Traversal)
9853 ),
9854 "{rel:?}"
9855 );
9856 }
9857 }
9858
9859 #[cfg(unix)]
9862 #[test]
9863 fn test_resolve_new_target_rejects_symlinked_ancestor_outside_root() {
9864 let dir = tempfile::TempDir::new().unwrap();
9865 let outside = tempfile::TempDir::new().unwrap();
9866 std::os::unix::fs::symlink(outside.path(), dir.path().join("leak")).unwrap();
9867 let vault = mbr_core::vault::LocalVault::new(dir.path());
9868 assert!(matches!(
9869 resolve_new_target_path(&vault, "leak/new.md"),
9870 Err(FileOpError::Traversal)
9871 ));
9872 assert!(!outside.path().join("new.md").exists());
9873 }
9874
9875 #[test]
9878 fn test_resolve_new_target_through_a_mem_vault() {
9879 let vault = mbr_core::vault::MemVault::new().with_dir("docs");
9880 let resolved = resolve_new_target_path(&vault, "docs/sub/new.md").expect("valid path");
9881 assert_eq!(resolved.as_str(), "docs/sub/new.md");
9882 assert!(matches!(
9883 resolve_new_target_path(&vault, "../x.md"),
9884 Err(FileOpError::Traversal)
9885 ));
9886 }
9887
9888 #[test]
9889 fn test_resolve_new_target_rejects_empty_or_root() {
9890 let dir = tempfile::TempDir::new().unwrap();
9891 let base = dir.path().canonicalize().unwrap();
9892 assert!(matches!(
9893 resolve_new_target_path(&mbr_core::vault::LocalVault::new(&base), ""),
9894 Err(FileOpError::Traversal)
9895 ));
9896 assert!(matches!(
9897 resolve_new_target_path(&mbr_core::vault::LocalVault::new(&base), "/"),
9898 Err(FileOpError::Traversal)
9899 ));
9900 }
9901
9902 #[test]
9903 fn test_resolve_new_target_accepts_valid_new_path() {
9904 let dir = tempfile::TempDir::new().unwrap();
9905 let base = dir.path().canonicalize().unwrap();
9906 let vault = mbr_core::vault::LocalVault::new(&base);
9907 let resolved = resolve_new_target_path(&vault, "docs/new.md").expect("valid path");
9908 assert_eq!(vault.key(&resolved), base.join("docs/new.md"));
9909 }
9910
9911 #[test]
9912 fn test_resolve_new_target_normalizes_leading_slash() {
9913 let dir = tempfile::TempDir::new().unwrap();
9915 let base = dir.path().canonicalize().unwrap();
9916 let vault = mbr_core::vault::LocalVault::new(&base);
9917 let resolved = resolve_new_target_path(&vault, "/docs/new.md").expect("valid path");
9918 assert_eq!(vault.key(&resolved), base.join("docs/new.md"));
9919 }
9920
9921 fn md_exts() -> Vec<String> {
9924 vec!["md".to_string(), "markdown".to_string()]
9925 }
9926
9927 #[test]
9928 fn test_sanitize_upload_name_basename_and_trim() {
9929 assert_eq!(
9930 sanitize_upload_name("image.png", &md_exts()).as_deref(),
9931 Some("image.png")
9932 );
9933 assert_eq!(
9935 sanitize_upload_name(" pic.jpeg ", &md_exts()).as_deref(),
9936 Some("pic.jpeg")
9937 );
9938 assert_eq!(
9940 sanitize_upload_name("clip.final.cut.mp4", &md_exts()).as_deref(),
9941 Some("clip.final.cut.mp4")
9942 );
9943 }
9944
9945 #[test]
9946 fn test_sanitize_upload_name_enforces_media_allowlist() {
9947 for name in [
9950 "index.html",
9951 "mbr-components.min.js",
9952 "theme.css",
9953 "config.toml",
9954 "evil.svg",
9955 "archive.tar.gz",
9956 "payload.sh",
9957 ] {
9958 assert_eq!(
9959 sanitize_upload_name(name, &md_exts()),
9960 None,
9961 "{name} must not be uploadable"
9962 );
9963 }
9964 for name in ["pic.png", "PIC.PNG", "clip.mp4", "song.mp3", "doc.pdf"] {
9966 assert_eq!(
9967 sanitize_upload_name(name, &md_exts()).as_deref(),
9968 Some(name),
9969 "{name} must be uploadable"
9970 );
9971 }
9972 }
9973
9974 #[test]
9975 fn test_sanitize_upload_name_rejects_separators_and_traversal() {
9976 assert_eq!(sanitize_upload_name("../secret.png", &md_exts()), None);
9977 assert_eq!(sanitize_upload_name("notes/pic.png", &md_exts()), None);
9978 assert_eq!(sanitize_upload_name("a\\b.png", &md_exts()), None);
9979 assert_eq!(sanitize_upload_name("..", &md_exts()), None);
9980 assert_eq!(sanitize_upload_name(".", &md_exts()), None);
9981 assert_eq!(sanitize_upload_name("my..pic.png", &md_exts()), None);
9982 }
9983
9984 #[test]
9985 fn test_sanitize_upload_name_requires_stem_and_extension() {
9986 assert_eq!(sanitize_upload_name("noext", &md_exts()), None);
9988 assert_eq!(sanitize_upload_name(".png", &md_exts()), None);
9990 assert_eq!(sanitize_upload_name("pic.", &md_exts()), None);
9992 assert_eq!(sanitize_upload_name("", &md_exts()), None);
9994 assert_eq!(sanitize_upload_name(" ", &md_exts()), None);
9995 }
9996
9997 #[test]
9998 fn test_sanitize_upload_name_rejects_markdown_extensions() {
9999 assert_eq!(sanitize_upload_name("note.md", &md_exts()), None);
10001 assert_eq!(sanitize_upload_name("note.markdown", &md_exts()), None);
10002 assert_eq!(sanitize_upload_name("note.MD", &md_exts()), None);
10004 assert_eq!(
10006 sanitize_upload_name("note.pdf", &md_exts()).as_deref(),
10007 Some("note.pdf")
10008 );
10009 }
10010
10011 fn host_headers(value: &str) -> HeaderMap {
10014 let mut headers = HeaderMap::new();
10015 headers.insert(header::HOST, HeaderValue::from_str(value).unwrap());
10016 headers
10017 }
10018
10019 #[test]
10020 fn test_host_header_hostname_strips_port_and_brackets() {
10021 assert_eq!(host_header_hostname("127.0.0.1:5200"), "127.0.0.1");
10022 assert_eq!(host_header_hostname("localhost:5200"), "localhost");
10023 assert_eq!(host_header_hostname("localhost"), "localhost");
10024 assert_eq!(host_header_hostname("[::1]:5200"), "::1");
10025 assert_eq!(host_header_hostname("[::1]"), "::1");
10026 assert_eq!(host_header_hostname("::1"), "::1");
10027 assert_eq!(
10028 host_header_hostname(" evil.example.com "),
10029 "evil.example.com"
10030 );
10031 }
10032
10033 #[test]
10034 fn test_host_header_is_allowed_accepts_local_names() {
10035 let loopback = [127, 0, 0, 1];
10036 for value in [
10037 "localhost",
10038 "LocalHost:5200",
10039 "127.0.0.1:5200",
10040 "127.0.0.53",
10041 "[::1]:5200",
10042 ] {
10043 assert!(
10044 host_header_is_allowed(&host_headers(value), loopback),
10045 "{value} should be an allowed Host"
10046 );
10047 }
10048 assert!(host_header_is_allowed(
10050 &host_headers("192.168.1.5:5200"),
10051 [192, 168, 1, 5]
10052 ));
10053 }
10054
10055 #[test]
10056 fn test_host_header_is_allowed_rejects_rebinding_names() {
10057 let loopback = [127, 0, 0, 1];
10058 for value in [
10061 "evil.example.com",
10062 "evil.example.com:5200",
10063 "localhost.evil.example.com",
10064 "127.0.0.1.evil.example.com",
10065 "192.168.1.5",
10066 ] {
10067 assert!(
10068 !host_header_is_allowed(&host_headers(value), loopback),
10069 "{value} must be rejected"
10070 );
10071 }
10072 assert!(!host_header_is_allowed(&HeaderMap::new(), loopback));
10074 }
10075
10076 #[test]
10079 fn test_is_servable_mbr_asset_allows_known_asset_types() {
10080 for path in [
10081 "/theme.css",
10082 "/components/mbr-components.min.js",
10083 "/components/mbr-graph.min.js.map",
10084 "/favicon.png",
10085 "/fonts/KaTeX_Main-Bold.woff2",
10086 "/index.html",
10087 ] {
10088 assert!(is_servable_mbr_asset(path), "{path} should be servable");
10089 }
10090 }
10091
10092 #[test]
10093 fn test_is_servable_mbr_asset_rejects_config_and_dotfiles() {
10094 for path in [
10096 "/config.toml",
10097 "/config.TOML",
10098 "/.env",
10099 "/secrets/.env.local",
10100 "/notes.md",
10101 "/id_rsa",
10102 "/",
10103 ] {
10104 assert!(!is_servable_mbr_asset(path), "{path} must not be servable");
10105 }
10106 }
10107
10108 #[test]
10111 fn test_is_template_folder_path_blocks_mbr_and_template_folder() {
10112 let dir = tempfile::tempdir().unwrap();
10113 let base = dir.path().canonicalize().unwrap();
10114 std::fs::create_dir_all(base.join(".mbr/components")).unwrap();
10115 let template_folder = base.join("custom-templates");
10116 std::fs::create_dir_all(&template_folder).unwrap();
10117
10118 for rel in [".mbr/index.html", ".mbr/components/mbr-components.min.js"] {
10120 assert!(
10121 is_template_folder_path(&base.join(rel), &base, Some(&base), None),
10122 "{rel} must be blocked"
10123 );
10124 }
10125 assert!(is_template_folder_path(
10127 &template_folder.join("index.html"),
10128 &base,
10129 Some(&base),
10130 Some(&template_folder)
10131 ));
10132 assert!(!is_template_folder_path(
10134 &base.join("notes/pic.png"),
10135 &base,
10136 Some(&base),
10137 Some(&template_folder)
10138 ));
10139 assert!(!is_template_folder_path(
10141 &base.join(".mbrx/pic.png"),
10142 &base,
10143 Some(&base),
10144 None
10145 ));
10146 }
10147
10148 #[test]
10151 fn test_is_within_served_roots_accepts_repo_and_static_overlay() {
10152 let parent = tempfile::tempdir().unwrap();
10153 let repo = parent.path().join("content");
10154 let static_dir = parent.path().join("static");
10155 std::fs::create_dir_all(&repo).unwrap();
10156 std::fs::create_dir_all(&static_dir).unwrap();
10157 let inside = repo.join("note.md");
10158 std::fs::write(&inside, "# hi").unwrap();
10159 let overlay = static_dir.join("logo.png");
10160 std::fs::write(&overlay, "png").unwrap();
10161 let canonical_repo = repo.canonicalize().unwrap();
10162
10163 assert!(is_within_served_roots(
10164 &inside,
10165 &repo,
10166 Some(&canonical_repo),
10167 "static",
10168 Vec::new
10169 ));
10170 assert!(is_within_served_roots(
10171 &repo,
10172 &repo,
10173 Some(&canonical_repo),
10174 "",
10175 Vec::new
10176 ));
10177 assert!(is_within_served_roots(
10179 &overlay,
10180 &repo,
10181 Some(&canonical_repo),
10182 "../static",
10183 Vec::new
10184 ));
10185 }
10186
10187 #[test]
10188 fn test_is_within_served_roots_rejects_symlink_escape() {
10189 let parent = tempfile::tempdir().unwrap();
10190 let repo = parent.path().join("content");
10191 std::fs::create_dir_all(&repo).unwrap();
10192 let outside = parent.path().join("secret.txt");
10193 std::fs::write(&outside, "top secret").unwrap();
10194 let canonical_repo = repo.canonicalize().unwrap();
10195
10196 let link = repo.join("secret.txt");
10199 #[cfg(unix)]
10200 std::os::unix::fs::symlink(&outside, &link).unwrap();
10201 #[cfg(windows)]
10202 let _ = std::os::windows::fs::symlink_file(&outside, &link);
10203
10204 if link.exists() {
10205 assert!(!is_within_served_roots(
10206 &link,
10207 &repo,
10208 Some(&canonical_repo),
10209 "static",
10210 Vec::new
10211 ));
10212 }
10213 assert!(!is_within_served_roots(
10215 &repo.join("missing.txt"),
10216 &repo,
10217 Some(&canonical_repo),
10218 "static",
10219 Vec::new
10220 ));
10221 }
10222
10223 #[cfg(unix)]
10227 #[test]
10228 fn test_path_checks_follow_mounts_and_refuse_hidden_files() {
10229 let outside = tempfile::Builder::new()
10230 .prefix("mbr-server-test-")
10231 .tempdir()
10232 .unwrap();
10233 let target = outside.path().canonicalize().unwrap();
10234 std::fs::create_dir(target.join(".secret")).unwrap();
10235 std::fs::write(target.join("clip.mp4"), b"clip").unwrap();
10236 std::fs::write(target.join(".secret/key"), b"key").unwrap();
10237 let repo = tempfile::tempdir().unwrap();
10238 let root = repo.path().canonicalize().unwrap();
10239 std::fs::write(root.join(".env"), b"SECRET=1").unwrap();
10240 std::os::unix::fs::symlink(&target, root.join("videos")).unwrap();
10241 let mounts = [target.clone()];
10242
10243 assert!(matches!(
10244 validate_media_path(".env", &root, ""),
10245 Err(MbrError::InvalidMediaPath(_))
10246 ));
10247 assert!(validate_media_path("videos/clip.mp4", &root, "").is_err());
10248 assert_eq!(
10249 validate_media_path_with_mounts("videos/clip.mp4", &root, "", &mounts).unwrap(),
10250 target.join("clip.mp4")
10251 );
10252 assert!(validate_media_path_with_mounts("videos/.secret/key", &root, "", &mounts).is_err());
10253
10254 let link = root.join("videos/clip.mp4");
10255 assert!(!is_within_served_roots(
10256 &link,
10257 &root,
10258 Some(&root),
10259 "",
10260 Vec::new
10261 ));
10262 assert!(is_within_served_roots(
10263 &link,
10264 &root,
10265 Some(&root),
10266 "",
10267 || { mounts.to_vec() }
10268 ));
10269 assert!(!is_within_served_roots(
10270 &root.join("videos/.secret/key"),
10271 &root,
10272 Some(&root),
10273 "",
10274 || mounts.to_vec()
10275 ));
10276 }
10277
10278 #[test]
10281 fn test_dedupe_name_no_collision() {
10282 let dir = Path::new("/repo/notes");
10283 let chosen = dedupe_name(dir, "pic", "png", |_| false);
10284 assert_eq!(chosen, dir.join("pic.png"));
10285 }
10286
10287 #[test]
10288 fn test_dedupe_name_first_collision() {
10289 let dir = Path::new("/repo");
10290 let taken: std::collections::HashSet<PathBuf> = [dir.join("a.txt")].into_iter().collect();
10291 let chosen = dedupe_name(dir, "a", "txt", |p| taken.contains(p));
10292 assert_eq!(chosen, dir.join("a-1.txt"));
10293 }
10294
10295 #[test]
10296 fn test_dedupe_name_suffix_sequence() {
10297 let dir = Path::new("/repo/notes");
10298 let taken: std::collections::HashSet<PathBuf> = [
10299 dir.join("pic.png"),
10300 dir.join("pic-1.png"),
10301 dir.join("pic-2.png"),
10302 ]
10303 .into_iter()
10304 .collect();
10305 let chosen = dedupe_name(dir, "pic", "png", |p| taken.contains(p));
10306 assert_eq!(chosen, dir.join("pic-3.png"));
10307 }
10308
10309 fn headers_with_content_type(value: &str) -> HeaderMap {
10312 let mut headers = HeaderMap::new();
10313 headers.insert(header::CONTENT_TYPE, HeaderValue::from_str(value).unwrap());
10314 headers
10315 }
10316
10317 fn response_with_content_type(value: &str) -> Response<Body> {
10320 Response::builder()
10321 .status(StatusCode::OK)
10322 .header(header::CONTENT_TYPE, value)
10323 .header(header::CONTENT_LENGTH, 1_000_000)
10324 .body(Body::from(vec![0u8; 64]))
10325 .unwrap()
10326 }
10327
10328 #[test]
10329 fn video_and_audio_content_types_are_incompressible() {
10330 for content_type in [
10333 "video/mp4",
10334 "video/quicktime",
10335 "video/mp4; charset=binary",
10336 "audio/mpeg",
10337 "application/pdf",
10338 "application/zip",
10339 "application/gzip",
10340 "application/x-gzip",
10341 "application/octet-stream",
10342 ] {
10343 assert!(
10344 is_incompressible_content_type(&headers_with_content_type(content_type)),
10345 "{content_type} must bypass compression"
10346 );
10347 }
10348 }
10349
10350 #[test]
10351 fn text_content_types_stay_compressible() {
10352 for content_type in [
10353 "text/html; charset=utf-8",
10354 "text/css",
10355 "application/json",
10356 "application/javascript",
10357 "image/svg+xml",
10358 ] {
10359 assert!(
10360 !is_incompressible_content_type(&headers_with_content_type(content_type)),
10361 "{content_type} should still be compressed"
10362 );
10363 }
10364 }
10365
10366 #[test]
10367 fn missing_content_type_stays_compressible() {
10368 assert!(!is_incompressible_content_type(&HeaderMap::new()));
10369 }
10370
10371 #[test]
10372 fn compression_predicate_skips_media_and_keeps_defaults() {
10373 use tower_http::compression::predicate::Predicate;
10374
10375 let predicate = compression_predicate();
10376
10377 assert!(!predicate.should_compress(&response_with_content_type("video/mp4")));
10378 assert!(!predicate.should_compress(&response_with_content_type("audio/mpeg")));
10379 assert!(!predicate.should_compress(&response_with_content_type("image/png")));
10381 assert!(!predicate.should_compress(&response_with_content_type("text/event-stream")));
10382 assert!(predicate.should_compress(&response_with_content_type("text/html")));
10384 }
10385}
10386
10387#[cfg(test)]
10388mod proptests {
10389 use super::*;
10390 use proptest::prelude::*;
10391
10392 proptest! {
10393 #[test]
10397 fn prop_validate_media_path_rejects_dotdot(
10398 prefix in "[a-zA-Z0-9_-]{0,10}",
10399 suffix in "[a-zA-Z0-9_-]{0,10}"
10400 ) {
10401 let temp_dir = tempfile::tempdir().unwrap();
10402 let test_paths = vec![
10404 format!("{}/../{}", prefix, suffix),
10405 format!("../{}/{}", prefix, suffix),
10406 format!("{}/{}/..", prefix, suffix),
10407 format!("{}%2F..%2F{}", prefix, suffix), ];
10409
10410 for path in test_paths {
10411 if path.contains("..") {
10414 let result = validate_media_path(&path, temp_dir.path(), "");
10415 prop_assert!(
10417 result.is_err(),
10418 "Path containing '..' should be rejected: {:?}",
10419 path
10420 );
10421 }
10422 }
10423 }
10424
10425 #[test]
10427 fn prop_validate_media_path_deterministic(
10428 path in "[a-zA-Z0-9_/-]{1,30}"
10429 ) {
10430 let temp_dir = tempfile::tempdir().unwrap();
10431 let result1 = validate_media_path(&path, temp_dir.path(), "");
10432 let result2 = validate_media_path(&path, temp_dir.path(), "");
10433
10434 match (&result1, &result2) {
10436 (Ok(p1), Ok(p2)) => prop_assert_eq!(p1, p2),
10437 (Err(_), Err(_)) => (), _ => prop_assert!(false, "Results should be consistent: {:?} vs {:?}", result1, result2),
10439 }
10440 }
10441
10442 #[test]
10444 fn prop_validate_media_path_decodes_url_encoding(
10445 filename in "[a-zA-Z0-9]{1,15}"
10446 ) {
10447 let temp_dir = tempfile::tempdir().unwrap();
10448
10449 let test_file = temp_dir.path().join(&filename);
10451 std::fs::write(&test_file, "test").unwrap();
10452
10453 let encoded = format!("%20{}", filename); let result = validate_media_path(&encoded, temp_dir.path(), "");
10456
10457 prop_assert!(result.is_err(), "Encoded path with non-existent target should fail");
10459
10460 let result = validate_media_path(&filename, temp_dir.path(), "");
10462 prop_assert!(result.is_ok(), "Valid path should succeed: {:?}", filename);
10463 }
10464
10465 #[test]
10467 fn prop_validate_media_path_valid_paths_succeed(
10468 filename in "[a-zA-Z0-9_-]{1,15}"
10469 ) {
10470 let temp_dir = tempfile::tempdir().unwrap();
10471
10472 let test_file = temp_dir.path().join(&filename);
10474 std::fs::write(&test_file, "test content").unwrap();
10475
10476 let result = validate_media_path(&filename, temp_dir.path(), "");
10478 prop_assert!(result.is_ok(), "Valid file path should succeed: {:?}", filename);
10479
10480 if let Ok(canonical) = result {
10482 let expected_canonical = test_file.canonicalize().unwrap();
10483 prop_assert_eq!(canonical, expected_canonical);
10484 }
10485 }
10486
10487 #[test]
10489 fn prop_validate_media_path_handles_leading_slash(
10490 filename in "[a-zA-Z0-9_-]{1,15}"
10491 ) {
10492 let temp_dir = tempfile::tempdir().unwrap();
10493
10494 let test_file = temp_dir.path().join(&filename);
10496 std::fs::write(&test_file, "test content").unwrap();
10497
10498 let path_with_slash = format!("/{}", filename);
10500 let result = validate_media_path(&path_with_slash, temp_dir.path(), "");
10501 prop_assert!(result.is_ok(), "Path with leading slash should work: {:?}", path_with_slash);
10502
10503 let result_no_slash = validate_media_path(&filename, temp_dir.path(), "");
10505 prop_assert!(result_no_slash.is_ok(), "Path without leading slash should work: {:?}", filename);
10506
10507 if let (Ok(p1), Ok(p2)) = (result, result_no_slash) {
10509 prop_assert_eq!(p1, p2, "Leading slash should not change result");
10510 }
10511 }
10512 }
10513}