Skip to main content

mbr_server/
server.rs

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/// Default HLS cache size: 200 MB.
46#[cfg(feature = "media-metadata")]
47const DEFAULT_HLS_CACHE_SIZE: usize = 200 * 1024 * 1024;
48
49/// Maximum time a request waits for an in-progress video-metadata extraction
50/// before giving up (degrades a lost wakeup into a retryable `None`).
51#[cfg(feature = "media-metadata")]
52const METADATA_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
53
54/// Maximum playback-compatibility probes run concurrently for one page. Each
55/// probe occupies a blocking thread while ffmpeg parses container headers, so
56/// a gallery page with dozens of clips must not saturate the blocking pool.
57#[cfg(feature = "media-metadata")]
58const MEDIA_COMPAT_PROBE_CONCURRENCY: usize = 4;
59
60/// Outcome of trying to claim an in-flight single-flight slot for a cache key.
61enum InflightClaim {
62    /// This caller won the slot and must produce the result, then release it.
63    Produce(Arc<tokio::sync::Notify>),
64    /// Another caller is already producing; await this notify then re-read the
65    /// result cache.
66    Wait(Arc<tokio::sync::Notify>),
67}
68
69/// Claims (or joins) the single-flight slot for `key`.
70///
71/// Returns [`InflightClaim::Produce`] to exactly one caller (which inserts a
72/// fresh `Notify`) and [`InflightClaim::Wait`] to any caller that finds an
73/// existing in-progress entry. Modeled on `HlsCache::start_generation`, so N
74/// concurrent requests for the same media only trigger one decode. Also used
75/// to single-flight inbound-link greps for links.json requests.
76///
77/// The claim is one atomic `try_insert`: `pin()` is a seize epoch guard
78/// (reclamation only, not mutual exclusion), so a `get`-then-`insert` pair is
79/// two independent lock-free operations and two callers can both observe a
80/// vacant slot and both produce.
81///
82/// The winner must hold the returned slot in an [`InflightSlot`] guard so
83/// cancellation or a panic cannot leave the key claimed forever.
84fn 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
95/// Releases a single-flight slot claimed by [`claim_inflight`] when dropped.
96///
97/// The producer holds this for the whole produce step so *every* exit path
98/// frees the slot and wakes waiters: normal return, `?`, panic, and — the case
99/// that motivated it — the handler future being dropped at an `await` because
100/// the client disconnected. Releasing only on the success path leaves a
101/// permanent entry that no TTL or eviction can clear, so every later request
102/// for that key waits the full timeout and then 404s until the process
103/// restarts.
104struct 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        // Waiters re-read the result cache after waking; a miss degrades to a
128        // retryable `None` instead of a hang.
129        self.notify.notify_waiters();
130    }
131}
132
133/// Releases an `HlsCache` generation slot claimed by `start_generation` when
134/// dropped, unless the producer already published a terminal state.
135///
136/// Same hazard as [`InflightSlot`], with a worse failure mode: an `InProgress`
137/// entry has no TTL and `evict_until_freed` refuses to evict non-`Complete`
138/// entries, so a slot abandoned by a cancelled request (the handler future is
139/// dropped at the `spawn_blocking` await when the client disconnects) or by a
140/// panicking transcode task blocks every later request for that playlist or
141/// segment for the full `HLS_WAIT_TIMEOUT` and then 404s, for the life of the
142/// process. Recording a failure instead lets waiters wake immediately and
143/// lets the cache's `FAILED_ENTRY_TTL` re-open the key for a retry.
144#[cfg(feature = "media-metadata")]
145struct HlsGenerationSlot<'a> {
146    cache: &'a HlsCache,
147    /// `None` once the producer settled the entry itself (complete or failed).
148    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    /// Disarms the guard after the producer stored a terminal state, so the
167    /// drop does not overwrite a completed entry with a failure.
168    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
189/// Drops every cache derived from markdown file *contents* after a batch of
190/// file changes.
191///
192/// The sibling navigation lists, the directory-listing subdirectories, the
193/// serialized site.json body, the outbound `LinkCache` and the inbound link
194/// cache are all recomputed from the files themselves, and none of the readers
195/// re-checks mtimes, so a change that is not followed by this call is served
196/// from the pre-edit snapshot indefinitely (`LinkCache` has no TTL at all).
197/// All of them are rebuilt lazily on the next render / listing / site.json /
198/// links.json request.
199fn 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
209/// The path-resolution inputs, owned rather than borrowed.
210///
211/// [`PathResolverConfig`] borrows from [`ServerState`], which is right for a
212/// request handler but impossible for the `'static` markdown-page probe the
213/// link transform carries into the renderer. Built per page render; the clones
214/// are a handful of short strings.
215fn 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/// Everything [`index_page_links`] needs to reproduce the renderer's link
226/// resolution outside a request.
227///
228/// Cloned once at startup rather than read from `ServerState`, because the
229/// index is built and maintained from background tasks that outlive any
230/// individual request.
231#[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
239/// Parse one markdown file and record its contribution to the backlink index.
240///
241/// Link resolution must match what a real render would produce, or the info
242/// panel would show backlinks the page does not actually contain. That is why
243/// this goes through the same `extract_outbound_links_from_vault` →
244/// `resolve_outbound_links` pair the request path uses, with the same
245/// `LinkTransformConfig`, rather than re-deriving URLs.
246fn 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, // server_mode
273        cfg.valid_tag_sources.clone(),
274        Some(repo.wikilink_index.clone()),
275    ) {
276        Ok(links) => {
277            // `OutboundLink.to` is the *raw* markdown destination
278            // (`alpha.md`, `../notes/x.md`) — the renderer records it before
279            // rewriting the href. Resolving that directly against the page URL
280            // yields `/alpha.md/`, which matches no page, so every
281            // extension-style link would silently produce no backlink.
282            // Running it through the same `transform_link` the renderer uses
283            // for the href first gives `../alpha/`, which resolves to the
284            // page's real URL.
285            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            // `true`, not `is_index_file`. These hrefs have *already* been
305            // through `transform_link`, which added the `../` that compensates
306            // for the trailing-slash URL convention. Passing `is_index_file`
307            // here makes `resolve_relative_url` strip the page URL's last
308            // segment a second time, so `docs/beta.md` linking `alpha.md`
309            // recorded a backlink on `/alpha/` instead of `/docs/alpha/`. The
310            // bug hid at the repository root, where the (former) silent clamp
311            // on an above-root `..` cancelled the extra hop. This is the same
312            // reasoning `page_errors::validate_media_references` documents for
313            // post-transform srcs.
314            let resolved = resolve_outbound_links(url_path, resolved, true);
315            index.set_page_links(url_path, &resolved);
316        }
317        Err(e) => {
318            // A file that cannot be parsed contributes no backlinks. Withdraw
319            // whatever it contributed before so a file that became unreadable
320            // does not leave stale backlinks behind.
321            // A not-downloaded note (a cloud provider evicted it) is an
322            // expected state on mobile, not something to warn about.
323            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
333/// Build the whole backlink index from the current repository contents.
334///
335/// Blocking and rayon-parallel; call from `spawn_blocking`.
336fn 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/// The three caches that hold pre-rendered *listings* of the repository: the
363/// per-directory file lists, the per-directory subdirectory lists, and the
364/// serialized `/.mbr/site.json` body.
365///
366/// Grouped so every invalidation site clears all three. They are derived from
367/// the same data, and there are several invalidation sites (the watcher task,
368/// the create handler, the move handler); clearing only some of them serves a
369/// page whose navigation disagrees with its content.
370#[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/// The memoized `/.mbr/site.json` body plus a generation counter.
386///
387/// The counter closes the fill/invalidate race: a rebuild that started before a
388/// file change would otherwise publish its pre-change snapshot *after* the
389/// invalidation cleared the slot, and that stale body would then be served
390/// until the next change. A builder captures the generation up front and its
391/// store is rejected if the generation moved meanwhile.
392#[derive(Default)]
393pub struct SiteJsonCache {
394    generation: u64,
395    body: Option<axum::body::Bytes>,
396}
397
398impl SiteJsonCache {
399    /// The current generation and body, read together under one lock.
400    fn snapshot(&self) -> (u64, Option<axum::body::Bytes>) {
401        (self.generation, self.body.clone())
402    }
403
404    /// Publishes `body` only if no invalidation happened since `generation` was
405    /// observed.
406    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/// What the live-reload WebSocket loop should do with a broadcast receive
429/// result.
430#[derive(Debug)]
431enum LiveReloadAction {
432    /// Serialize and forward this event to the client.
433    Forward(mbr_core::change_event::FileChangeEvent),
434    /// This client fell behind and the channel dropped events; keep listening.
435    Skip,
436    /// The sender is gone (server shutting down); close the socket.
437    Close,
438}
439
440/// Maps a file-change broadcast result to the live-reload loop's next action.
441///
442/// Extracted so the lag path is unit-testable: the loop must keep forwarding
443/// after a `Lagged` error rather than treating it as terminal. Dropped events
444/// only cost this client a missed reload, and the client re-fetches the page
445/// on the next event it does receive.
446fn 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
462/// Default outbound link cache size: 2 MB.
463const DEFAULT_LINK_CACHE_SIZE: usize = 2 * 1024 * 1024;
464
465/// Default inbound link cache size: 4 MB. Sized for the sidebar mini graph,
466/// which fans out links.json requests for many pages at once.
467const DEFAULT_INBOUND_LINK_CACHE_SIZE: usize = 4 * 1024 * 1024;
468
469/// TTL for inbound link cache entries in seconds. The editing endpoints
470/// (`/.mbr/create`, `/.mbr/move`, `/.mbr/mkdir`) invalidate this cache when
471/// they mutate the repo, and the file watcher drops it wholesale (together with
472/// the outbound `LinkCache`) after each debounced batch of changes, but a
473/// bounded TTL still guards against any missed invalidation and keeps
474/// mini-graph bursts from re-grepping the repo.
475const INBOUND_LINK_CACHE_TTL_SECS: u64 = 300;
476
477/// Maximum inbound-link greps allowed to run concurrently. Each grep walks
478/// the whole repository, so a burst of links.json requests (the sidebar mini
479/// graph fetches 10-80 pages) must not stampede the filesystem.
480const INBOUND_GREP_MAX_CONCURRENCY: usize = 2;
481
482/// Maximum time a request waits for an in-progress inbound-link grep before
483/// giving up (degrades a lost wakeup into a retryable `None`).
484const INBOUND_GREP_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
485
486/// `content-type` prefixes whose payloads must never be gzipped.
487///
488/// Two independent reasons, either of which is disqualifying:
489///
490/// 1. **Correctness.** tower-http drops `content-length` and `accept-ranges`
491///    and switches to `transfer-encoding: chunked` on any response it
492///    compresses. For `video/*` and `audio/*` that destroys seeking and
493///    duration detection in every client that negotiates gzip — verified
494///    against WebKit, which is the engine behind mbr's own GUI window. (Range
495///    requests are unaffected because tower-http skips compression when
496///    `content-range` is present, so the bug only bites the initial plain
497///    `GET`.)
498/// 2. **Waste.** These formats are already entropy-coded, so gzip burns CPU on
499///    every byte of a potentially multi-gigabyte file to save nothing.
500///
501/// Matched as a prefix against the response's `content-type`, mirroring
502/// tower-http's own [`NotForContentType`] semantics.
503///
504/// [`NotForContentType`]: tower_http::compression::predicate::NotForContentType
505const 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
515/// Returns `true` when the response's `content-type` names an already-compressed
516/// or range-critical payload that must bypass the compression layer.
517///
518/// Absent or non-ASCII `content-type` headers compare against the empty string
519/// and therefore match nothing, preserving tower-http's compress-by-default
520/// behaviour for everything not explicitly listed.
521fn 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
532/// tower-http compression predicate: compress unless the payload is one of the
533/// [`INCOMPRESSIBLE_CONTENT_TYPE_PREFIXES`].
534///
535/// Written as a free function rather than a closure so it coerces cleanly into
536/// tower-http's blanket `Predicate` impl for
537/// `Fn(StatusCode, Version, &HeaderMap, &Extensions) -> bool`.
538fn 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
547/// Builds the compression predicate for the router.
548///
549/// Keeps every [`DefaultPredicate`] exclusion (gRPC, images, SSE, tiny bodies)
550/// and adds [`INCOMPRESSIBLE_CONTENT_TYPE_PREFIXES`] on top.
551///
552/// [`DefaultPredicate`]: tower_http::compression::predicate::DefaultPredicate
553fn 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/// Query parameters for media viewer routes.
560#[derive(Debug, serde::Deserialize)]
561pub struct MediaViewerQuery {
562    /// Path to the media file (URL-encoded)
563    pub path: Option<String>,
564}
565
566/// Validates a media path from a query parameter.
567///
568/// - URL-decodes the path
569/// - Rejects paths containing ".." (directory traversal)
570/// - Validates the path resolves within the repository root OR the static folder
571///
572/// # Arguments
573///
574/// * `path` - The URL-encoded path from the query parameter
575/// * `repo_root` - The repository root directory
576/// * `static_folder` - The static folder path (may be relative to repo_root, e.g., "../static")
577///
578/// # Returns
579///
580/// * `Ok(PathBuf)` - The validated, canonical path to the media file
581/// * `Err(MbrError)` - If the path is invalid or attempts directory traversal
582///
583/// # Security
584///
585/// When `static_folder` points outside `repo_root` (e.g., `../static`), paths are validated
586/// against BOTH directories. This allows serving assets from external static folders while
587/// maintaining path traversal protection. Content root takes precedence if the file exists
588/// in both locations.
589pub 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
597/// [`validate_media_path`], also accepting a file inside one of the accepted
598/// symlink mounts' targets (`mount_targets`, canonical — see
599/// [`mbr_core::vault::Mount`]). A path with a hidden segment is refused
600/// wherever it would resolve.
601pub 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    // URL-decode the path
608    let decoded = percent_decode_str(path)
609        .decode_utf8()
610        .map_err(|_| MbrError::InvalidMediaPath("Invalid UTF-8 in path".to_string()))?;
611
612    // Reject paths containing ".." to prevent directory traversal
613    if decoded.contains("..") {
614        return Err(MbrError::DirectoryTraversal);
615    }
616
617    // Remove leading slash if present for path joining
618    let clean_path = decoded.trim_start_matches('/');
619
620    // Hidden files are never served (`.env`, `.git/config`).
621    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    // Try repo_root first
632    let full_path = repo_root.join(clean_path);
633
634    // Canonicalize repo root for validation
635    let canonical_root = repo_root
636        .canonicalize()
637        .map_err(|_| MbrError::InvalidMediaPath("Repository root not found".to_string()))?;
638
639    // Try to resolve within repo_root (or a mount reached through it)
640    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 non-empty, try resolving against it as a fallback
648    if !static_folder.is_empty() {
649        let static_root = repo_root.join(static_folder);
650
651        // The static folder must exist
652        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    // Neither repo_root nor static_folder contained a valid path
665    Err(MbrError::InvalidMediaPath(format!(
666        "Path does not exist: {}",
667        decoded
668    )))
669}
670
671/// Safely join a base directory with a relative path for serving MBR assets.
672///
673/// Returns `Some(PathBuf)` if the path is safe (within base_dir and exists as a file),
674/// `None` otherwise. This prevents path traversal attacks.
675///
676/// # Security
677///
678/// This function guards against path traversal attacks by:
679/// 1. Rejecting paths containing ".." before any filesystem operations
680/// 2. Canonicalizing both the base directory and the joined path
681/// 3. Verifying the resolved path starts with the base directory
682/// 4. Ensuring the path is a file (not a directory)
683fn safe_join_asset(base_dir: &Path, relative_path: &str) -> Option<PathBuf> {
684    // Early rejection of obvious path traversal attempts
685    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    // Canonicalize base_dir first to handle any symlinks in the base
697    let canonical_base = base_dir.canonicalize().ok()?;
698
699    // Canonicalize the candidate path - this resolves symlinks and ".."
700    let canonical = candidate.canonicalize().ok()?;
701
702    // Verify containment and that it's a file
703    if canonical.starts_with(&canonical_base) && canonical.is_file() {
704        Some(canonical)
705    } else {
706        None
707    }
708}
709
710/// Verify that a file path is safely contained within a base directory.
711///
712/// Returns `Some(PathBuf)` with the canonical path if valid, `None` if the path
713/// escapes the base directory (path traversal) or doesn't exist as a file.
714///
715/// This is used for defense-in-depth validation of paths that have already
716/// been constructed from URL paths.
717///
718/// # Security
719///
720/// Guards against path traversal by canonicalizing both paths and verifying containment.
721/// Note: We intentionally do NOT reject paths containing ".." before canonicalization
722/// because the base_dir itself may be constructed with ".." (e.g., when static_folder
723/// is "../static"). The canonicalization resolves all ".." components, and the
724/// starts_with check ensures the resolved path is within bounds.
725#[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/// Resolve a media source file from a URL path with path traversal protection.
738///
739/// Tries `base_dir/url_path` first, then falls back to
740/// `base_dir/static_folder/url_path`. Each candidate is validated with
741/// [`validate_path_containment`], so paths that escape their containing
742/// directory (e.g. via `..`) return `None`.
743///
744/// Returns the canonical path of the resolved file, or `None` if the file
745/// doesn't exist in either location or the path escapes containment.
746///
747/// A file inside an accepted symlink mount (`mount_targets`, canonical) is
748/// found too, through the link; a path with a hidden segment never is.
749#[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 stays within base_dir (defense in depth)
771    validate_path_containment(&direct, base_dir)
772        // Validate path stays within static folder
773        .or_else(|| validate_path_containment(&in_static, &static_dir))
774        .or_else(|| in_mount(direct))
775        .or_else(|| in_mount(in_static))
776}
777
778/// Whether `canonical` is inside one of `mount_targets` (canonical symlink
779/// mount targets) with no hidden segment below the target — the served-files
780/// rule for a mount, for the path-based checks that do not go through the
781/// vault.
782fn 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
795/// Name of the per-repository template/configuration folder.
796const MBR_TEMPLATE_DIR: &str = ".mbr";
797
798/// File extensions the `/.mbr/*` route is allowed to serve.
799///
800/// `.mbr/` is a *configuration* directory as much as an asset directory: it
801/// holds `config.toml` with the Argon2 `edit_token_hash`. Serving it required
802/// no credential, so the route is restricted to the asset types the compiled-in
803/// defaults and shipped templates actually reference (stylesheets, scripts,
804/// source maps, templates, images, fonts) and everything else 404s.
805const MBR_ASSET_EXTENSIONS: &[&str] = &[
806    // Documents, styles, scripts and their source maps
807    "css",
808    "js",
809    "mjs",
810    "map",
811    "json",
812    "html",
813    "txt", // Images and fonts
814    "png",
815    "jpg",
816    "jpeg",
817    "gif",
818    "webp",
819    "svg",
820    "ico",
821    "woff",
822    "woff2",
823    "ttf",
824    "otf",
825    "eot",
826    // Pagefind index shipped inside `.mbr/pagefind/` by a static build
827    "wasm",
828    "pf_meta",
829    "pf_fragment",
830    "pf_index",
831];
832
833/// Whether `asset_path` (a `/`-prefixed path relative to the template folder)
834/// may be served by [`Server::serve_mbr_assets`].
835///
836/// Requires an allowlisted extension ([`MBR_ASSET_EXTENSIONS`]) and rejects any
837/// dot-prefixed component, so neither `config.toml` nor a dotfile such as
838/// `.env` can be read out of the template folder.
839fn 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
857/// File extensions the `/.mbr/upload` endpoint accepts.
858///
859/// Mirrors the image/audio/video/PDF classification in
860/// `repo::StaticFileMetadata::empty` (plus the `vtt`/`srt` caption sidecars the
861/// video pipeline understands). The uploader must never be able to create a
862/// file the browser or the template engine will *execute* — `.html`, `.js`,
863/// `.css` and `.toml` are deliberately absent, and so is `.svg`, which executes
864/// script when navigated to directly.
865const UPLOAD_ALLOWED_EXTENSIONS: &[&str] = &[
866    // Images
867    "jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff", // Audio
868    "aiff", "aif", "mp3", "aac", "m4a", "ogg", "oga", "opus", "wma", "flac", "wav",
869    // Video
870    "mp4", "m4v", "mov", "webm", "flv", "mpg", "mpeg", "avi", "3gp", "wmv",
871    // Documents and caption sidecars
872    "pdf", "vtt", "srt",
873];
874
875/// Canonicalizes the deepest existing ancestor of `path` (the path itself when
876/// it already exists), so containment checks also work for files that have not
877/// been created yet.
878fn canonical_existing_ancestor(path: &Path) -> Option<PathBuf> {
879    path.ancestors()
880        .find_map(|ancestor| ancestor.canonicalize().ok())
881}
882
883/// Whether `target` lands inside mbr's template folder — the repository's
884/// `.mbr/` directory or an explicitly configured `--template-folder`.
885///
886/// Checked both lexically (the caller builds `target` by joining onto the
887/// canonical root, so `dir=.mbr` is caught before the file exists) and against
888/// the canonicalized deepest existing ancestor (so a symlinked `.mbr` or a
889/// template folder given by a non-canonical path is caught too).
890fn 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
909/// Extracts the hostname from a `Host` header value, dropping the port and any
910/// IPv6 brackets: `127.0.0.1:5200` → `127.0.0.1`, `[::1]:5200` → `::1`,
911/// `localhost` → `localhost`.
912fn host_header_hostname(host: &str) -> &str {
913    let host = host.trim();
914    if let Some(rest) = host.strip_prefix('[') {
915        // Bracketed IPv6 literal, with or without a port.
916        rest.split(']').next().unwrap_or(rest)
917    } else if host.matches(':').count() > 1 {
918        // Bare IPv6 literal (not RFC-conformant in `Host`, but be lenient).
919        host
920    } else {
921        host.split(':').next().unwrap_or(host)
922    }
923}
924
925/// Whether a `Host` header names an address this server could have been reached
926/// at directly.
927///
928/// Accepts `localhost`, any loopback IP literal (`127.0.0.1`, `127.0.0.53`,
929/// `::1`, `[::1]`) and the configured bind address. Everything else — notably
930/// an attacker-controlled name that DNS-rebinds to 127.0.0.1 — is rejected, as
931/// is a missing `Host` (HTTP/1.1 requires one and every browser sends it).
932///
933/// The port is deliberately ignored: `start_with_port_retry` may bind a
934/// different port than the configured one, and a rebinding attacker controls
935/// the *name*, never the port. A wildcard bind (`0.0.0.0`) is only reachable
936/// under a name this rejects, but `Config::validate` already refuses to enable
937/// editing on a non-loopback bind without a token, and a configured token skips
938/// this check entirely.
939fn 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
953/// tower-http's `on_failure`, minus the noise.
954///
955/// Its default logs every 5xx at ERROR. mbr answers `503` only to say "retry
956/// shortly", always with `Retry-After`: a note a cloud provider has not
957/// downloaded yet, a video still being remuxed. Clients do retry, every second
958/// or two, so each one would log an ERROR for an expected, transient state.
959/// Those are DEBUG; every other failure keeps tower-http's ERROR line.
960fn 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
978/// The cloud provider a path lives in, when its location gives it away: the
979/// folder each provider syncs into on Apple platforms (iCloud Drive's
980/// `Mobile Documents`, on iOS and macOS alike; macOS File Provider folders
981/// under `~/Library/CloudStorage`). Third-party providers on iOS live in an
982/// anonymous app-group container, so they get no name ("Downloading…").
983fn 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
997/// Seconds a client is asked to wait before retrying a not-downloaded file.
998const NOT_DOWNLOADED_RETRY_SECS: &str = "2";
999
1000/// How long a `/.mbr/*` request waits for a file it asked a cloud provider
1001/// for before answering `503`. Short: the page is waiting on it, and a small
1002/// CSS or SVG file normally arrives within a second or two.
1003const MBR_ASSET_DOWNLOAD_WAIT: std::time::Duration = std::time::Duration::from_secs(3);
1004
1005/// How often [`MBR_ASSET_DOWNLOAD_WAIT`] checks whether the file arrived.
1006const MBR_ASSET_DOWNLOAD_POLL: std::time::Duration = std::time::Duration::from_millis(150);
1007
1008/// `Retry-After` plus the de-facto `Refresh`, so a browser that honours it
1009/// reloads the page by itself once the file had time to arrive.
1010fn 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
1025/// `503` for a non-page file (image, PDF, video) that is not downloaded.
1026fn 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/// Why [`Server::read_editable_bytes`] could not hand a note to an edit
1037/// endpoint.
1038#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1039enum EditReadError {
1040    /// The read failed for a reason other than "not downloaded" (`404`, as
1041    /// before).
1042    NotFound,
1043    /// The provider did not deliver the note in time (`503` + `Retry-After`).
1044    NotDownloaded,
1045    /// The blocking read task itself failed (`500`).
1046    Failed,
1047    /// A line-level write needs text; the file is not UTF-8 (`400`).
1048    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
1064/// Whether a render failed because the note's bytes are not on this device
1065/// ([`VaultError::NotDownloaded`], which crosses `io::Error` as
1066/// `WouldBlock`). No desktop read produces it.
1067fn is_not_downloaded(error: &MbrError) -> bool {
1068    match error {
1069        MbrError::Markdown(markdown) => is_not_downloaded_markdown(markdown),
1070        _ => false,
1071    }
1072}
1073
1074/// [`is_not_downloaded`] for the markdown layer's own error type.
1075fn 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
1088/// Logs a failed render under `context`.
1089///
1090/// A note that is not downloaded yet is an expected, transient state: its page
1091/// answers 503 "Downloading" with `Refresh: 2`, so the browser asks again every
1092/// couple of seconds until the provider delivers it, and each of those asks
1093/// would otherwise log an ERROR. That is DEBUG. Every other failure keeps ERROR.
1094fn 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
1102/// Defense-in-depth containment check for a path the router is about to serve.
1103///
1104/// Returns `true` only when `path` **canonicalizes** inside the repository root
1105/// or inside the `static_folder` overlay (which may legitimately resolve
1106/// outside the root, e.g. `static_folder = "../static"`).
1107///
1108/// `Path::starts_with` on an un-canonicalized path is not enough: a symlink
1109/// whose target lives outside the root is lexically inside it, and both
1110/// `is_file()` and `ServeFile` follow symlinks. Every resolver in this crate is
1111/// supposed to reject that, so this is a second, independent gate rather than
1112/// the primary one.
1113///
1114/// A file inside an accepted symlink mount's target (`mount_targets`) is
1115/// served too: that is what a mount is. The targets come from the vault's own
1116/// table, so this stays independent of the resolver that produced `path`.
1117fn 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    // Only pay for this canonicalize when the cheap check already failed.
1139    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    /// True when a native GUI window fronts this server. Only affects how the
1151    /// startup banner is announced; see [`Server::announce_listening`].
1152    pub gui_mode: bool,
1153    /// File watcher handle - kept alive for the lifetime of the server.
1154    /// When Server is dropped, this is dropped, stopping the watcher.
1155    #[cfg(feature = "watcher")]
1156    _watcher_handle: WatcherHandle,
1157    /// The repository index the router serves from.
1158    repo: Arc<Repo>,
1159    /// How [`Server::refresh`] applies what it finds, shared with the
1160    /// file-change task.
1161    changes: ChangeApplier,
1162    /// Files restored from [`EmbeddedStorage::snapshot`], when it was used.
1163    restored_files: Option<usize>,
1164    /// The templates the router renders with (shared: a reload is seen by
1165    /// every request), for [`Server::reload_templates`].
1166    templates: mbr_core::templates::Templates,
1167}
1168
1169/// Where the background thread parks the file watcher once it is running.
1170#[cfg(feature = "watcher")]
1171type WatcherHandle = Arc<std::sync::Mutex<Option<crate::watcher::FileWatcher>>>;
1172
1173/// Starts the file watcher on a background thread so a large repository
1174/// cannot delay server startup, feeding `tx`. A watcher that fails to start
1175/// only disables events from disk: the channel and everything subscribed to
1176/// it keep working.
1177///
1178/// Without the `watcher` feature none of this exists and `tx` carries only
1179/// the server's own writes (task toggles, edits, uploads, renames).
1180///
1181/// The watcher also follows the repository's symlink mounts
1182/// ([`crate::watcher::FileWatcher::watch_mounts`]). Mounts are discovered by
1183/// the scan, which races this thread, so both sides sync under the handle's
1184/// lock: this thread when it parks the watcher, the scan
1185/// ([`sync_watched_mounts`]) when it finishes. Whichever runs second sees
1186/// what the other produced.
1187#[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                // Store the watcher in the shared handle so it stays alive
1213                // and can be properly dropped when Server is dropped
1214                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/// Has the running file watcher (if it has started) follow every symlink
1232/// mount the repository has discovered so far. See [`spawn_file_watcher`].
1233#[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
1242/// Most file changes in one batch that are applied file by file; a larger
1243/// batch is a full rescan.
1244const SURGICAL_THRESHOLD: usize = 50;
1245
1246/// How long the change task gathers events before applying them.
1247const DEBOUNCE_DURATION: std::time::Duration = std::time::Duration::from_secs(2);
1248
1249/// Everything a batch of file changes has to update: the repository index and
1250/// every index and cache derived from it. See [`ChangeApplier::apply`].
1251#[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/// Where a batch of change events came from, which decides how much of it is
1268/// trusted.
1269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1270enum ChangeSource {
1271    /// The watcher and the server's own writes: noisy (editors touch files,
1272    /// a checkout moves thousands), so modified non-markdown files are
1273    /// dropped and a large batch is a full rescan.
1274    Events,
1275    /// [`Server::refresh`]'s stat-diff: exact, one event per real change, so
1276    /// every event is applied, file by file, however many there are. A
1277    /// rescan would also re-read every note and drop the frontmatter the
1278    /// index kept for files that are listed but no longer downloaded.
1279    Diff,
1280}
1281
1282impl ChangeApplier {
1283    /// Applies one batch of change events.
1284    ///
1285    /// For [`ChangeSource::Events`], small batches (<= [`SURGICAL_THRESHOLD`]
1286    /// files) are applied file by file and larger ones rescan, and modified
1287    /// non-markdown files are dropped (only a markdown file's contents feed an
1288    /// index). Every derived cache is dropped afterwards, since none of them
1289    /// re-checks mtimes.
1290    async fn apply(
1291        &self,
1292        pending_events: Vec<mbr_core::change_event::FileChangeEvent>,
1293        source: ChangeSource,
1294    ) {
1295        // Filter to only relevant events
1296        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            // Surgical invalidation: update individual files
1326            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            // Serialized against the initial index build; see the
1339            // `is_ready()` comment below.
1340            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                        // Take the URL from the repository's own map
1352                        // rather than recomputing it. `base_dir` is the
1353                        // path as configured, while the scanner keys
1354                        // everything by the *canonical* root, and on
1355                        // macOS a temp dir differs (`/var` vs
1356                        // `/private/var`). A recomputed URL would not
1357                        // match the key the initial build inserted
1358                        // under, so the edit would write a second entry
1359                        // and never withdraw the stale one. Captured
1360                        // before `invalidate_file` because a deletion
1361                        // removes the entry it is read from.
1362                        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                    // After `repo.invalidate_file`, never before: a
1373                    // created or modified file's url/title are read
1374                    // back out of the repository's own map, which the
1375                    // call above is what populates. A no-op until the
1376                    // task index has actually been built.
1377                    task_index.invalidate_file(&abs_path, &event.event, &repo);
1378                }
1379                // Rebuild tag index if any files were deleted or modified
1380                // (created files add tags inline in invalidate_file)
1381                if has_tag_changes {
1382                    repo.rebuild_tag_index();
1383                }
1384                // Relationships may have changed on any create/modify/delete;
1385                // rebuild the index so endpoint resolution stays consistent.
1386                repo.build_relationship_index();
1387                // The global wikilink index must track the same changes.
1388                repo.build_wikilink_index();
1389
1390                // Re-index only the pages that changed. This runs after
1391                // build_wikilink_index() on purpose: `[[Name]]` links
1392                // resolve through that index, so re-extracting first
1393                // would record links against the pre-edit name table.
1394                //
1395                // `is_ready()` gates it because the initial build is
1396                // still filling the index otherwise. That is safe only
1397                // because the caller holds `index_lock` across this
1398                // whole task: without it, an edit arriving mid-build
1399                // would be skipped here and then overwritten by the
1400                // build's pre-edit contents, leaving links.json stale
1401                // until the next full rescan.
1402                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                            // Only reachable for a file the scan never
1415                            // saw (created inside this batch, or
1416                            // deleted before it was ever indexed).
1417                            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            // Too many changes — full rescan
1441            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                // A rescan can discover links created since startup.
1448                #[cfg(feature = "watcher")]
1449                sync_watched_mounts(&watcher_handle, &repo);
1450                // Every page may have changed, so rebuild rather than
1451                // patch. `populate_inbound_index` re-marks it ready; the
1452                // stale index stays queryable in the meantime, which is
1453                // better than falling back to a full-repo grep per page.
1454                if inbound_index.is_ready() {
1455                    populate_inbound_index(&repo, &inbound_index, &link_index_cfg);
1456                }
1457                // Same reasoning for tasks: too many files moved for a
1458                // per-file patch to be cheaper than one read pass. Still
1459                // a no-op unless somebody has actually used tasks.
1460                task_index.rebuild_if_built(&repo);
1461                let _ = base_dir; // keep alive for potential future use
1462            })
1463            .await
1464            .ok();
1465        }
1466
1467        // The repository changed, so every cache derived from file
1468        // contents may be stale. Drop them all; they are rebuilt lazily
1469        // on the next render/links.json from the freshly invalidated
1470        // repo.
1471        invalidate_derived_caches(
1472            &self.listing_caches,
1473            &self.link_cache,
1474            &self.inbound_link_cache,
1475        );
1476    }
1477}
1478
1479/// Configuration for initializing a Server instance.
1480///
1481/// This struct consolidates all parameters needed by `Server::init`,
1482/// making it easier to construct and pass around configuration.
1483///
1484/// # Example
1485///
1486/// ```ignore
1487/// use mbr_server::server::ServerConfig;
1488/// use mbr_core::config::Config;
1489///
1490/// let config = Config::default();
1491/// let server_config = ServerConfig::from(&config)
1492///     .with_gui_mode(false)
1493///     .with_log_filter(Some("mbr=debug"));
1494/// let server = Server::init(server_config)?;
1495/// ```
1496#[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    /// Repo-relative hidden directories named on the command line, exempt from
1507    /// the scanner's and watcher's leading-dot rule. See
1508    /// [`mbr_core::config::explicit_hidden_dirs`].
1509    pub explicit_hidden_dirs: Vec<std::path::PathBuf>,
1510    /// Read-only symlink mounts on (the default) or off
1511    /// (`Config::no_symlink_mounts`, the operator's kill switch).
1512    pub symlink_mounts: bool,
1513    pub index_file: String,
1514    pub oembed_timeout_ms: u64,
1515    pub oembed_cache_size: usize,
1516    /// Budget in bytes for the video/PDF metadata cache (cover images,
1517    /// chapters, captions). Separate from `oembed_cache_size`, which sizes a
1518    /// text-metadata cache.
1519    #[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    /// Enable typed relationship tracking.
1528    pub relationship_tracking: bool,
1529    /// Configured relation types (for the relationship index + site.json).
1530    pub relationship_types: Vec<RelationType>,
1531    pub tag_sources: Vec<TagSource>,
1532    pub sidebar_style: String,
1533    pub sidebar_max_items: usize,
1534    /// Depth (hops) of the sidebar mini graph neighborhood (1-5).
1535    pub graph_depth: usize,
1536    /// Concentric-mode growth threshold for the flashcard deck (0 < t ≤ 1).
1537    pub flashcards_concentric_threshold: f64,
1538    /// Draw review-progress indicators on flashcard notes' reading view.
1539    pub flashcards_progress_indicators: bool,
1540    pub title_prefix: String,
1541    pub title_suffix: String,
1542    /// Highlight blocks beginning with an incomplete marker (TK/TODO/FIXME/XXX).
1543    pub mark_incomplete: bool,
1544    /// Marker strings used by the incomplete-block highlighter.
1545    pub incomplete_markers: Vec<String>,
1546    /// Enable the task browser (`POST /.mbr/tasks`).
1547    pub tasks_enabled: bool,
1548    /// Emit `data-mbr-line` source lines on block elements.
1549    pub review_enabled: bool,
1550    /// Maintain the `@done(...)` annotation when `POST /.mbr/task` toggles a
1551    /// task's status.
1552    pub tasks_stamp_done: bool,
1553    /// Where the task panel's Show filter starts (`tasks`/`markers`/`all`).
1554    pub tasks_default_include: IncludeFilter,
1555    /// Globs matched against repo-relative paths whose files are kept out of the
1556    /// task index (e.g. `templates/**`). Empty by default.
1557    pub tasks_ignore_globs: Vec<String>,
1558    /// Enable the in-browser markdown editing endpoints.
1559    pub edit_enabled: bool,
1560    /// Require the editing token even for loopback callers.
1561    pub edit_require_token_on_loopback: bool,
1562    /// Argon2 PHC hash of the shared editing token (server-side only).
1563    pub edit_token_hash: Option<String>,
1564    /// Maximum size in bytes of a single asset uploaded via `/.mbr/upload`.
1565    pub upload_max_bytes: usize,
1566    #[cfg(feature = "media-metadata")]
1567    pub transcode_enabled: bool,
1568    /// Storage (and optionally a saved index) supplied by an app that embeds
1569    /// the server, instead of the directory scan of `base_dir` the desktop
1570    /// does. `None` everywhere but the iOS app (`mbr_ffi::mobile`); see
1571    /// [`EmbeddedStorage`].
1572    pub embedded: Option<EmbeddedStorage>,
1573    /// Why the repository's configuration could not be loaded, when the
1574    /// embedder fell back to the defaults instead of failing (the iOS app).
1575    /// Reported on every page's `errors.json` as `config_parse_error`.
1576    /// Always `None` on the desktop, which refuses to start instead.
1577    pub config_problem: Option<String>,
1578}
1579
1580/// The repository storage an embedding app hands the server.
1581///
1582/// With one, the server:
1583///
1584/// - reads and writes the repository only through `vault` (it still renders
1585///   templates and serves `.mbr/` assets from `base_dir` when that is a real
1586///   directory, and from the compiled-in defaults otherwise);
1587/// - starts no filesystem watcher — an embedding platform without change
1588///   events (iOS) calls [`Server::refresh`] instead, which needs the
1589///   per-file stamps the repository then records
1590///   ([`Repo::with_change_tracking`]);
1591/// - restores `snapshot`, when given and still valid, instead of scanning:
1592///   navigation, `site.json` and metadata search are answered from it
1593///   immediately, and a [`Server::refresh`] brings it up to date.
1594///
1595/// Symlink mounts and an external static overlay are whatever `vault` itself
1596/// provides; `ServerConfig::symlink_mounts` does not apply.
1597#[derive(Clone)]
1598pub struct EmbeddedStorage {
1599    pub vault: Arc<dyn Vault>,
1600    pub snapshot: Option<mbr_core::repo::RepoSnapshot>,
1601}
1602
1603impl ServerConfig {
1604    /// Set whether the server is running in GUI mode (native window).
1605    #[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    /// Set the log filter for tracing (e.g., "mbr=debug,tower_http=warn").
1612    #[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, // Default to server mode
1640            theme: config.theme.clone(),
1641            log_filter: None, // Set via with_log_filter()
1642            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            // Server/GUI default: on unless config overrides.
1654            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    /// The canonical targets of every symlink mount accepted so far, for the
1675    /// path-based serving checks ([`is_within_served_roots`],
1676    /// [`validate_media_path_with_mounts`]).
1677    fn mount_targets(&self) -> Vec<PathBuf> {
1678        self.repo
1679            .mounts()
1680            .into_iter()
1681            .map(|(_, mount)| mount.target)
1682            .collect()
1683    }
1684
1685    /// Refuses a write into a read-only symlink mount (`403`), before any lock
1686    /// is taken or byte read. The vault refuses it again at the write itself.
1687    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    /// The defense-in-depth containment check for a resolved key, before it
1701    /// is served: [`is_within_served_roots`] wherever the key is a real file.
1702    ///
1703    /// A key with no [`Vault::local_path`] names nothing on disk, so there is
1704    /// no symlink to escape through: it was produced from a [`VaultPath`],
1705    /// which cannot leave its vault, and the vault itself resolved it. That
1706    /// key is served. The desktop's vault always has local paths, so it
1707    /// always takes the filesystem check.
1708    ///
1709    /// A file the vault reports not downloaded may have nothing on disk under
1710    /// its own name (an iCloud `.name.icloud` placeholder), so the filesystem
1711    /// check cannot see it. It is still answered — with "downloading" — since
1712    /// the resolver only produces such a key under a parent the vault itself
1713    /// canonicalized inside the repository. Asked only when the check failed,
1714    /// so the desktop (whose vault answers `Local` with no I/O) pays nothing.
1715    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    /// The repository's storage. Every read and write a handler makes on the
1733    /// user's files goes through it ([`mbr_core::vault`]).
1734    fn vault(&self) -> &dyn Vault {
1735        self.repo.vault().as_ref()
1736    }
1737
1738    /// The vault path of an index key — a path the resolver or
1739    /// `resolve_new_target` produced. A key outside the vault is a traversal.
1740    fn vault_path(&self, key: &Path) -> Result<VaultPath, FileOpError> {
1741        Ok(self.vault().vault_path(key)?)
1742    }
1743
1744    /// Whether anything (file or directory, links followed) is at `key`, as
1745    /// `Path::exists` answered.
1746    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    /// Whether `key` is an existing directory, as `Path::is_dir` answered.
1752    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    /// The path-resolver inputs for this server's repository: its vault and
1758    /// static overlay ([`Repo::vault`], [`Repo::static_vault`]) plus the
1759    /// configured resolution settings. One definition, so every handler
1760    /// resolves a URL against the same storage the index was scanned from.
1761    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    /// IPv4 address the server was configured to bind. Used to validate the
1777    /// `Host` header on editing requests (anti DNS-rebinding).
1778    pub bind_ip: [u8; 4],
1779    pub base_dir: std::path::PathBuf,
1780    /// Pre-computed canonical base directory for path resolution (avoids per-request canonicalize)
1781    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    /// Optional template folder that overrides default .mbr/ and compiled defaults
1792    pub template_folder: Option<std::path::PathBuf>,
1793    /// Sort configuration for file listings
1794    pub sort: Vec<SortField>,
1795    /// Whether the server is running in GUI mode (native window) vs browser mode
1796    pub gui_mode: bool,
1797    /// Theme for Pico CSS selection (e.g., "default", "amber", "fluid", "fluid.jade")
1798    pub theme: String,
1799    /// Cache for OEmbed page metadata to avoid redundant network requests
1800    pub oembed_cache: Arc<OembedCache>,
1801    /// Cache for dynamically generated video metadata (covers, chapters, captions)
1802    #[cfg(feature = "media-metadata")]
1803    pub video_metadata_cache: Arc<VideoMetadataCache>,
1804    /// Whether video transcoding is enabled
1805    #[cfg(feature = "media-metadata")]
1806    pub transcode_enabled: bool,
1807    /// Cache for HLS playlists and transcoded segments
1808    #[cfg(feature = "media-metadata")]
1809    pub hls_cache: Arc<HlsCache>,
1810    /// Single-flight guard for video metadata extraction: maps a metadata cache
1811    /// key to a `Notify` while an extraction is in progress, so a gallery with N
1812    /// references to the same clip only spawns one ffmpeg decode (others await).
1813    #[cfg(feature = "media-metadata")]
1814    pub metadata_inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>>,
1815    /// Cache of probed video resolutions keyed by path+mtime, so repeated HLS
1816    /// requests for an unchanged file never re-run a blocking ffmpeg demux.
1817    #[cfg(feature = "media-metadata")]
1818    pub video_resolution_cache:
1819        Arc<papaya::HashMap<String, mbr_core::video_transcode::VideoResolution>>,
1820    /// Cache of playback-compatibility probes keyed by path+mtime. Both
1821    /// outcomes are cached, so a page reload never re-opens an unchanged file,
1822    /// and an edited file is transparently re-probed.
1823    #[cfg(feature = "media-metadata")]
1824    pub media_compat_cache:
1825        Arc<papaya::HashMap<String, mbr_core::video_metadata::PlaybackCompatibility>>,
1826    /// Per-directory memoized sibling navigation lists (prev/next). Avoids an
1827    /// O(repo) scan on every markdown render; invalidated when files change.
1828    ///
1829    /// Doubles as the *file* half of a directory listing: the listing for a
1830    /// directory is exactly the sorted sibling list of the files it contains.
1831    pub sibling_nav_cache: Arc<papaya::HashMap<PathBuf, Arc<Vec<serde_json::Value>>>>,
1832    /// Per-directory memoized immediate-subdirectory lists, the other half of a
1833    /// directory listing. Invalidated alongside `sibling_nav_cache`.
1834    pub subdir_cache: Arc<papaya::HashMap<PathBuf, Arc<Vec<serde_json::Value>>>>,
1835    /// Fully rendered `/.mbr/site.json` body.
1836    ///
1837    /// The payload is a pure function of the repository index, but mbr is a
1838    /// multi-page app: `shared.ts` fetches it on *every* navigation, and
1839    /// rebuilding it re-serializes every markdown file's frontmatter each time.
1840    /// Cleared whenever the repo changes (alongside `sibling_nav_cache`) and
1841    /// rebuilt lazily on the next request.
1842    pub site_json_cache: Arc<parking_lot::RwLock<SiteJsonCache>>,
1843    /// Whether bidirectional link tracking is enabled
1844    pub link_tracking: bool,
1845    /// Whether typed relationship tracking is enabled
1846    pub relationship_tracking: bool,
1847    /// Configured relation types (for directory-scan temp repos + site.json)
1848    pub relationship_types: Vec<RelationType>,
1849    /// Cache for outbound links extracted during page renders
1850    pub link_cache: Arc<LinkCache>,
1851    /// Cache for inbound links discovered via grep
1852    pub inbound_link_cache: Arc<InboundLinkCache>,
1853    /// Repository-wide backlink index, built once in the background after the
1854    /// initial scan and maintained incrementally by the watcher. Until it
1855    /// reports ready, `links.json` falls back to the per-page grep below.
1856    pub inbound_index: Arc<InboundIndex>,
1857    /// Bounds concurrent inbound-link greps: each cache miss walks the whole
1858    /// repository, and the sidebar mini graph fans out many links.json
1859    /// requests at once, so at most two full-repo walks run at a time.
1860    pub inbound_grep_semaphore: Arc<tokio::sync::Semaphore>,
1861    /// Single-flight guard for inbound-link greps: maps a page URL path to a
1862    /// `Notify` while a grep is in progress, so N concurrent links.json
1863    /// requests for the same page trigger one repo walk (others await).
1864    pub inbound_grep_inflight: Arc<papaya::HashMap<String, Arc<tokio::sync::Notify>>>,
1865    /// Tag sources for frontmatter extraction
1866    pub tag_sources: Vec<TagSource>,
1867    /// Sidebar navigation style ("panel" for mbr-browse, "single" for mbr-browse-single)
1868    pub sidebar_style: String,
1869    /// Maximum items per section in sidebar navigation
1870    pub sidebar_max_items: usize,
1871    /// Depth (hops) of the sidebar mini graph neighborhood (1-5)
1872    pub graph_depth: usize,
1873    /// Concentric-mode growth threshold, for `__MBR_CONFIG__`.
1874    pub flashcards_concentric_threshold: f64,
1875    /// Whether flashcard notes draw reading-view progress indicators.
1876    pub flashcards_progress_indicators: bool,
1877    /// Text to prepend to all page titles
1878    pub title_prefix: String,
1879    /// Text to append to all page titles
1880    pub title_suffix: String,
1881    /// Highlight blocks beginning with TK/TODO/FIXME/XXX (default on in server/GUI).
1882    pub mark_incomplete: bool,
1883    /// Marker strings used by the incomplete-block highlighter.
1884    pub incomplete_markers: Vec<String>,
1885    /// Whether the task browser (`POST /.mbr/tasks`) is enabled.
1886    pub tasks_enabled: bool,
1887    /// Whether block elements carry `data-mbr-line` source lines.
1888    pub review_enabled: bool,
1889    /// Whether `POST /.mbr/task` maintains the `@done(...)` annotation when it
1890    /// toggles a task's status.
1891    pub tasks_stamp_done: bool,
1892    /// Where the task panel's Show filter starts (`tasks`/`markers`/`all`).
1893    /// Reaches the frontend as `window.__MBR_CONFIG__.tasksDefaultInclude`;
1894    /// the query endpoint itself has no opinion, since the panel always sends
1895    /// an explicit `include`.
1896    pub tasks_default_include: IncludeFilter,
1897    /// Lazy index of the repository's markdown tasks.
1898    ///
1899    /// Deliberately *not* built at startup: it is filled on the first task
1900    /// query and then kept fresh by the watcher, so a server whose user never
1901    /// opens the task panel never pays for a full-repo read pass.
1902    pub task_index: Arc<mbr_core::task_index::TaskIndex>,
1903    /// Whether the in-browser markdown editing endpoints are enabled.
1904    pub edit_enabled: bool,
1905    /// Require the editing token even for loopback callers.
1906    pub edit_require_token_on_loopback: bool,
1907    /// Argon2 PHC hash of the shared editing token (never sent to the frontend).
1908    pub edit_token_hash: Option<String>,
1909    /// Maximum size in bytes of a single asset uploaded via `/.mbr/upload`.
1910    pub upload_max_bytes: usize,
1911    /// See [`ServerConfig::config_problem`].
1912    pub config_problem: Option<String>,
1913    /// Serializes read-patch-write cycles on one file across the in-place
1914    /// write endpoints (`/.mbr/edit`, `/.mbr/task`, `/.mbr/flashcard-review`).
1915    pub file_write_locks: Arc<FileWriteLocks>,
1916}
1917
1918/// JSON body for `POST /.mbr/edit/{*path}`.
1919#[derive(serde::Deserialize)]
1920pub struct EditRequest {
1921    /// Full new file contents (frontmatter + body, recombined by the client).
1922    pub content: String,
1923    /// SHA-256 hex of the content the client loaded, for optimistic concurrency.
1924    pub base_hash: String,
1925}
1926
1927/// JSON body for `POST /.mbr/task`.
1928///
1929/// `expected` is the per-line analogue of [`EditRequest::base_hash`]: it is what
1930/// makes it safe to patch one line of a file without holding the whole file.
1931#[derive(serde::Deserialize)]
1932pub struct TaskToggleRequest {
1933    /// Repo-relative **filesystem** path of the file to patch (with extension),
1934    /// the same convention as `/.mbr/raw` and `/.mbr/edit`.
1935    pub path: String,
1936    /// 1-based source line of the task.
1937    pub line: u32,
1938    /// The exact current text of that line, verbatim. The line terminator is
1939    /// ignored, everything else must match byte for byte.
1940    pub expected: String,
1941    /// Target status: `open`, `done` or `canceled`.
1942    pub to: mbr_core::tasks::TaskStatus,
1943    /// The version of the file the client's line numbers come from
1944    /// ([`mbr_core::edit_auth::content_hash`], as `/.mbr/raw` and the page's
1945    /// `markdown_source_hash` report it). When present, a file that is any
1946    /// other version is a `409` even if `expected` happens to match: a line
1947    /// added above the task moves a *different* task onto `line`. Optional so
1948    /// that older clients keep the line-only check.
1949    #[serde(default)]
1950    pub base_hash: Option<String>,
1951}
1952
1953/// Response for a successful `POST /.mbr/task`.
1954#[derive(serde::Serialize)]
1955pub struct TaskToggleResponse {
1956    /// The line that was patched, echoed back.
1957    pub line: u32,
1958    /// Its new text, without the terminator — including any `@done(...)` the
1959    /// server added or removed, which the client cannot predict.
1960    pub text: String,
1961    /// The fingerprint of the file as written, the client's next `base_hash`.
1962    pub hash: String,
1963}
1964
1965/// JSON body for `POST /.mbr/flashcard-review`.
1966///
1967/// Addressed exactly like [`TaskToggleRequest`]: `line`/`expected` name the
1968/// card's **term** line, which the page carries as the `<dt>`'s
1969/// `data-mbr-line`.
1970#[derive(serde::Deserialize)]
1971pub struct FlashcardReviewRequest {
1972    /// Repo-relative **filesystem** path of the note (with extension).
1973    pub path: String,
1974    /// 1-based source line of the definition-list term.
1975    pub line: u32,
1976    /// The exact current text of that line; the terminator is ignored.
1977    pub expected: String,
1978    /// `again`, `hard`, `good` or `easy`.
1979    pub rating: mbr_core::flashcards::Rating,
1980    /// The reviewer's local wall-clock time, `YYYY-MM-DD HH:MM` — written as
1981    /// the entry's timestamp after [`mbr_core::flashcards::parse_review_time`]
1982    /// checks it. Required.
1983    pub at: String,
1984    /// The version of the file the client read; see
1985    /// [`TaskToggleRequest::base_hash`].
1986    #[serde(default)]
1987    pub base_hash: Option<String>,
1988}
1989
1990/// Response for a successful `POST /.mbr/flashcard-review`.
1991///
1992/// Everything a client holding the file's lines needs to stay in step without
1993/// re-reading it: the entry (for its in-memory history) and where the lines
1994/// went (to splice them in and shift every later line number by
1995/// `inserted.len()`).
1996#[derive(serde::Serialize)]
1997pub struct FlashcardReviewResponse {
1998    /// The entry as written, e.g. `2026-10-06 13:45 - Good`.
1999    pub entry: String,
2000    /// 1-based line of the new bullet.
2001    pub line: u32,
2002    /// 1-based line of the first inserted line.
2003    pub inserted_at: u32,
2004    /// The inserted lines, without terminators (one, or two when the term's
2005    /// `___Review History___` definition was created).
2006    pub inserted: Vec<String>,
2007    /// The fingerprint of the file as written, the client's next `base_hash`.
2008    pub hash: String,
2009}
2010
2011/// JSON body for `POST /.mbr/create/{*path}`.
2012#[derive(serde::Deserialize)]
2013pub struct CreateRequest {
2014    /// Full file contents (frontmatter + body) for the new markdown file.
2015    pub content: String,
2016    /// Create any missing parent directories.
2017    #[serde(default)]
2018    pub create_dirs: bool,
2019}
2020
2021/// JSON body for `POST /.mbr/move/{*path}`.
2022#[derive(serde::Deserialize)]
2023pub struct MoveRequest {
2024    /// Destination repo-relative filesystem path (with markdown extension).
2025    pub to: String,
2026    /// Create any missing parent directories at the destination.
2027    #[serde(default)]
2028    pub create_dirs: bool,
2029}
2030
2031/// Response for a successful `POST /.mbr/create`.
2032#[derive(serde::Serialize)]
2033pub struct CreateResponse {
2034    /// The canonical site URL of the new page.
2035    pub url_path: String,
2036    /// The repo-relative filesystem path of the new file.
2037    pub path: String,
2038}
2039
2040/// Response for a successful `POST /.mbr/mkdir`.
2041#[derive(serde::Serialize)]
2042pub struct MkdirResponse {
2043    /// The repo-relative filesystem path of the folder.
2044    pub path: String,
2045}
2046
2047/// Response for a successful `POST /.mbr/move`.
2048#[derive(serde::Serialize)]
2049pub struct MoveResponse {
2050    /// The canonical site URL the page moved *from* (now gone).
2051    pub from_url: String,
2052    /// The canonical site URL the page moved *to*.
2053    pub url_path: String,
2054    /// The repo-relative filesystem path of the destination file.
2055    pub path: String,
2056    /// Site URLs of pages whose inbound links were rewritten (A4-A).
2057    pub rewritten: Vec<String>,
2058    /// Site URLs of pages whose bare `[[Name]]` links were rewritten (A4-C).
2059    pub wikilinks_rewritten: Vec<String>,
2060    /// Whether any missing destination parent directories were created.
2061    pub created_dirs: bool,
2062}
2063
2064/// Query parameters for `POST /.mbr/upload`.
2065#[derive(serde::Deserialize)]
2066pub struct UploadParams {
2067    /// Repo-relative destination folder (the note's own folder). May be empty
2068    /// (`""`) for a root-level note.
2069    #[serde(default)]
2070    pub dir: String,
2071    /// Desired filename (basename with extension).
2072    pub name: String,
2073}
2074
2075/// Response for a successful `POST /.mbr/upload`.
2076#[derive(serde::Serialize)]
2077pub struct UploadResponse {
2078    /// Root-absolute, percent-encoded URL of the saved file (matches how mbr
2079    /// serves it), e.g. `/notes/image.png`.
2080    pub url: String,
2081    /// Repo-relative filesystem path of the saved file, e.g. `notes/image.png`.
2082    pub path: String,
2083    /// Final filename after collision de-duplication, e.g. `image-1.png`.
2084    pub name: String,
2085}
2086
2087/// Error type for the file-management editing endpoints
2088/// (`/.mbr/create`, `/.mbr/move`, `/.mbr/mkdir`, `/.mbr/upload`). Mirrors the
2089/// inline `(StatusCode, &str)` convention used by the other edit handlers rather
2090/// than the crate-wide `MbrError`.
2091#[derive(Debug)]
2092enum FileOpError {
2093    /// The target path already exists (create/move collision) → `409`.
2094    AlreadyExists,
2095    /// The destination parent directory does not exist (and `create_dirs`
2096    /// was not set) → `400`.
2097    ParentMissing,
2098    /// The destination lacks a configured markdown extension → `400`.
2099    NotMarkdown,
2100    /// An uploaded filename was empty, contained path separators / `..`, lacked
2101    /// an extension, carried a markdown extension (markdown goes through
2102    /// `/.mbr/create`), or was not an allowed media type → `400`.
2103    InvalidUploadName,
2104    /// The upload destination is inside the template folder (`.mbr/` or
2105    /// `--template-folder`), where a file would be executed as a template or
2106    /// served as site JavaScript → `400`.
2107    ForbiddenUploadDir,
2108    /// The move source is not an existing markdown file → `404`.
2109    SourceNotFound,
2110    /// The path escaped the repository root (traversal/symlink) → `400`.
2111    Traversal,
2112    /// The path is inside a read-only symlink mount → `403`, the status every
2113    /// other refusal to write answers with (`check_edit_access`), which the
2114    /// clients already report as "refused".
2115    ReadOnly,
2116    /// A filesystem error occurred → `500`.
2117    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
2167/// Resolves a repo-relative path (that may not yet exist) against an
2168/// already-canonical repository root, guarding against traversal/symlink
2169/// escape. Shared core of [`Server::resolve_new_target`], factored out for
2170/// unit testing.
2171///
2172/// Rejects any `..` or absolute component, joins onto `canonical_base`, then
2173/// canonicalizes the deepest **existing** ancestor and asserts it stays within
2174/// the root (so a symlink in the existing portion cannot escape).
2175fn 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    // Reject `..`/absolute components before any filesystem access. Stricter
2181    // than `VaultPath`'s own normalization on purpose: a write target is never
2182    // spelled with a `..`, so one that has it is refused rather than resolved.
2183    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    // `\` is a separator to `VaultPath` on every platform, so a Unix
2190    // `a\..\b.md` would otherwise be normalized rather than refused.
2191    if clean.split(['/', '\\']).any(|segment| segment == "..") {
2192        return Err(FileOpError::Traversal);
2193    }
2194    let candidate = VaultPath::new(clean).map_err(|_| FileOpError::Traversal)?;
2195
2196    // Canonicalize the deepest existing ancestor to defeat symlink escape
2197    // through the already-existing portion of the path. The vault root always
2198    // exists, so the search ends there at the latest.
2199    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
2208/// Percent-encode set for upload-response URLs. Encodes everything except the
2209/// RFC 3986 unreserved characters (`A-Z a-z 0-9 - . _ ~`) and the `/` path
2210/// separator, so the returned URL round-trips through mbr's request-path
2211/// percent-decoding and the browser can load the saved file (e.g. a space in a
2212/// filename becomes `%20`).
2213const UPLOAD_URL_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
2214    .remove(b'/')
2215    .remove(b'-')
2216    .remove(b'.')
2217    .remove(b'_')
2218    .remove(b'~');
2219
2220/// Sanitizes a desired upload filename down to a safe basename.
2221///
2222/// Returns `None` (which callers map to `400`) when `name`:
2223/// - is empty or whitespace-only,
2224/// - contains a path separator (`/` or `\`) or a `..` sequence,
2225/// - is not a pure basename (has directory components),
2226/// - lacks a non-empty stem or a non-empty extension,
2227/// - carries a configured markdown extension (those go through `/.mbr/create`),
2228///   or
2229/// - carries an extension outside [`UPLOAD_ALLOWED_EXTENSIONS`].
2230///
2231/// The allowlist is the important half: rejecting only separators and markdown
2232/// left the media uploader able to write `index.html` or
2233/// `components/mbr-components.min.js`, which the watcher hot-reloads into every
2234/// rendered page.
2235///
2236/// On success returns the trimmed, validated basename unchanged.
2237fn 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    // Reject path separators and parent-traversal outright: only a basename is
2243    // accepted. `\\` is rejected too so a Windows-style path can't sneak through.
2244    if name.contains('/') || name.contains('\\') || name.contains("..") {
2245        return None;
2246    }
2247    // The input must be exactly its own file name (no directory components,
2248    // and not `.`/`..` which have no file name).
2249    let path = Path::new(name);
2250    if path.file_name().and_then(|n| n.to_str()) != Some(name) {
2251        return None;
2252    }
2253    // Require a non-empty stem AND a non-empty extension.
2254    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    // Markdown files must be created via `/.mbr/create`, not uploaded.
2264    if mbr_core::repo::is_markdown_extension(&ext_lower, markdown_extensions) {
2265        return None;
2266    }
2267    // Only media types the rest of mbr already understands may be uploaded.
2268    if !UPLOAD_ALLOWED_EXTENSIONS.contains(&ext_lower.as_str()) {
2269        return None;
2270    }
2271    Some(name.to_string())
2272}
2273
2274/// Finds a non-colliding destination path for `stem`.`ext` inside `dir`.
2275///
2276/// Returns `dir/stem.ext` when free, otherwise the first free
2277/// `dir/stem-N.ext` (N = 1, 2, 3, …). `exists` reports whether a candidate is
2278/// taken, so the collision loop is pure and unit-testable without touching the
2279/// filesystem.
2280fn 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/// Serializable view of the repository for `/.mbr/site.json`.
2288///
2289/// Deliberately *not* `serde_json::to_value(&*repo)`: that materializes the
2290/// whole `other_files` subtree — one `Value` per asset, tens of thousands on a
2291/// media-heavy repo — only for the handler to delete the key again one
2292/// statement later. Naming the fields that actually ship means the media
2293/// catalog is never built here; it is served by `/.mbr/media.json`.
2294#[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
2303/// The non-repo values that appear in `/.mbr/site.json`.
2304///
2305/// Owned (not borrowed from `ServerState`) so the body can be rendered on the
2306/// blocking pool.
2307struct SiteJsonParams {
2308    sort: Vec<SortField>,
2309    sidebar_style: String,
2310    sidebar_max_items: usize,
2311    relationship_tracking: bool,
2312}
2313
2314/// Renders the `/.mbr/site.json` body for the current repository snapshot.
2315///
2316/// Pure with respect to the caches: the caller decides whether to memoize.
2317/// Relationship injection still walks a `serde_json::Value`, because it
2318/// decorates each markdown entry, but that DOM now contains only what ships.
2319fn 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: &params.sort,
2327        sidebar_style: &params.sidebar_style,
2328        sidebar_max_items: params.sidebar_max_items,
2329    })?;
2330
2331    // Add relationship_types + per-note resolved relationships (if enabled).
2332    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    /// Initialize a new server instance with the given configuration.
2341    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        // Media metadata (cover JPEGs, chapters, captions) gets its own budget:
2394        // it used to borrow the oembed *text* cache size, so a couple of dozen
2395        // covers filled it and `--oembed-cache-size 0` silently disabled media
2396        // caching too.
2397        #[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        // Single-flight guard + probed-resolution cache for media metadata/HLS.
2404        #[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        // Listing caches (per-directory files, per-directory subdirectories and
2417        // the serialized site.json body). Created before the file-change
2418        // invalidation task so that task can clear them when files change.
2419        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        // Use try_init to allow multiple server instances in tests
2426        // RUST_LOG env var takes precedence, then CLI flag, then default (warn)
2427        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        // Synchronous: restoring is in-memory work (no storage reads), and
2474        // doing it before the first request is what makes the snapshot's
2475        // navigation available the moment `init` returns.
2476        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        // Spawn background repo scan so site.json is ready before first request.
2488        // Phase 1: basic scan (file listing + frontmatter). Phase 2: media metadata (ffmpeg/lopdf).
2489        // Created before the scan so the scan can hand the watcher the symlink
2490        // mounts it discovers; the watcher itself starts further down.
2491        #[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            // A restored index already holds every file; what changed since
2499            // it was saved is the embedding app's `Server::refresh`.
2500            if !restored && let Err(e) = repo_for_scan.scan_all() {
2501                tracing::error!("Background scan failed: {e}");
2502            }
2503            // Build the relationship index once all note titles are known.
2504            repo_for_scan.build_relationship_index();
2505            // Build the global wikilink name index (always on) so body
2506            // `[[Name]]` links resolve globally on first render.
2507            repo_for_scan.build_wikilink_index();
2508            repo_for_scan.mark_scan_complete();
2509
2510            // Phase 1.5: scan static folder (deferred from scan_all for faster search)
2511            if !restored && let Err(e) = repo_for_scan.scan_static_folder() {
2512                tracing::error!("Background static scan failed: {e}");
2513            }
2514            // Every mount is known now (the scan discovers them as it descends).
2515            #[cfg(feature = "watcher")]
2516            sync_watched_mounts(&watcher_handle_for_scan, &repo_for_scan);
2517
2518            // Phase 2: populate basic file metadata (stat calls for size/timestamps)
2519            repo_for_scan.populate_basic_metadata();
2520
2521            // Phase 3: populate media metadata in background (non-blocking for site.json)
2522            repo_for_scan.populate_media_metadata();
2523            repo_for_scan.notify_media_populated();
2524
2525            // Phase 4: extract text from PDFs/text files for search
2526            repo_for_scan.ensure_text_extracted();
2527        });
2528
2529        // Create a broadcast channel for file changes - watcher will be initialized in background
2530        let (file_change_tx, _rx) = tokio::sync::broadcast::channel::<
2531            mbr_core::change_event::FileChangeEvent,
2532        >(mbr_core::change_event::BROADCAST_CAPACITY);
2533
2534        // Embedded storage has no watcher: its vault need not be a directory
2535        // at all, and the platform that embeds it (iOS) has no change events
2536        // worth the descriptors. `Server::refresh` replaces it.
2537        #[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        // Only the watcher reads this setting.
2555        #[cfg(not(feature = "watcher"))]
2556        let _ = watcher_ignore_dirs;
2557
2558        // Spawn background task to reload templates when .html files change
2559        let templates_for_reload = templates.clone();
2560        let templates_for_server = templates.clone();
2561        // Canonicalize once here, not per event: the watcher reports canonical
2562        // paths while the configured folder is stored as written, and this
2563        // cannot change for the life of the process. Fall back to the path as
2564        // given when canonicalization fails (e.g. the folder does not exist).
2565        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                // `while let Ok(..)` here treated a `Lagged` error as the end of
2572                // the stream, so one burst of file changes silently disabled
2573                // template hot reload for the life of the process — the same
2574                // defect as the live-reload WebSocket loop.
2575                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                // Only reload for .html files
2582                if !event.path.ends_with(".html") {
2583                    continue;
2584                }
2585
2586                // If we have a template folder, only reload for changes in that folder
2587                // Otherwise, only reload for changes in .mbr folder
2588                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        // Link caches. Created before the file-change invalidation task so that
2596        // task can drop them when files change: both are derived from file
2597        // contents and neither the outbound `LinkCache` nor a served links.json
2598        // response re-checks mtimes, so a missed invalidation serves pre-edit
2599        // links until the process restarts.
2600        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        // Repository-wide backlink index. Built once, in the background, after
2607        // the initial scan (which is what makes the wikilink index — and so
2608        // link resolution — trustworthy). Until it is ready, links.json falls
2609        // back to the per-page grep, so startup is not blocked on it.
2610        let inbound_index = Arc::new(InboundIndex::new());
2611        // Serializes the initial full build against the watcher's incremental
2612        // updates. Both mutate the same index, and an update that interleaves
2613        // with the build can be silently undone by it.
2614        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        // Lazy task index. Created here so the watcher can keep it fresh, but
2639        // deliberately left *empty*: it is filled by the first `/.mbr/tasks`
2640        // request, and `invalidate_file` is a no-op until then, so a server
2641        // whose user never opens the task panel never reads a file for it.
2642        // The ignore patterns and the marker rule are compiled once, by the
2643        // constructor.
2644        //
2645        // The panel follows the highlighter deliberately: with `mark_incomplete`
2646        // off, the rendered pages carry no `#mbr-marker-N` anchors, so a marker
2647        // listed here would be a result that cannot be opened. An empty marker
2648        // list is the constructor's off switch.
2649        //
2650        // Read once, at startup, exactly like `tasks_ignore_globs` — flipping
2651        // `mark_incomplete` takes a restart.
2652        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        // Applies file-change batches to the repository and every index and
2662        // cache derived from it. One definition, used by the debounced task
2663        // below (watcher events and the server's own writes) and by
2664        // `Server::refresh` (an embedding app's stat-diff, which has no
2665        // watcher).
2666        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        // Spawn background task to invalidate repo cache when files change.
2682        // Uses debouncing: accumulate events for 2 seconds, then apply changes.
2683        // For small batches (<=50 files): surgical per-file invalidation.
2684        // For large batches: full clear + rescan.
2685        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                // Wait for the first event
2690                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                // Accumulate events during the debounce window
2697                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                                    // Too many events queued — force full rescan
2708                                    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                                    // Push over threshold to trigger full rescan
2715                                    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            // Task browser query endpoint (gated by tasks_enabled; 404 when off)
2813            .route("/.mbr/tasks", post(Self::tasks_handler))
2814            // Editing endpoints: raw source fetch and save (gated by edit_enabled + auth)
2815            .route("/.mbr/raw/{*path}", get(Self::raw_markdown_handler))
2816            .route(
2817                "/.mbr/edit/{*path}",
2818                post(Self::save_markdown_handler)
2819                    // Cap edit payloads at 5 MB (axum default is 2 MB).
2820                    .layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
2821            )
2822            // Single-line task toggle (gated by edit_enabled + auth, like the
2823            // rest of the write endpoints). Singular `/task`, next to the plural
2824            // `/tasks` query above.
2825            .route("/.mbr/task", post(Self::task_toggle_handler))
2826            // Flashcard review: appends one history entry under a term (gated
2827            // by edit_enabled + auth, like `/task`).
2828            .route(
2829                "/.mbr/flashcard-review",
2830                post(Self::flashcard_review_handler),
2831            )
2832            // File-management endpoints (gated by edit_enabled + auth): create a
2833            // new file, move/rename with repo-wide link rewrite, create a folder.
2834            .route(
2835                "/.mbr/create/{*path}",
2836                post(Self::create_markdown_handler)
2837                    // Cap create payloads at 5 MB (axum default is 2 MB).
2838                    .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            // Binary asset upload (the editor's image uploader). Body limit comes
2843            // from `upload_max_bytes`; oversize bodies get 413 automatically.
2844            .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            // Media viewer routes - must be before the catch-all /.mbr/{*path}
2850            .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    /// Re-reads the templates from `.mbr/` (or the template folder), as the
2875    /// desktop watcher does when an `.html` file there changes. For an
2876    /// embedder without a watcher (the iOS app's refresh). A template that
2877    /// does not parse keeps the previous set and is logged; `false` then.
2878    pub fn reload_templates(&self) -> bool {
2879        reload_templates(&self.templates)
2880    }
2881
2882    /// The repository index this server serves.
2883    pub fn repo(&self) -> &Arc<Repo> {
2884        &self.repo
2885    }
2886
2887    /// How many files [`Server::init`] restored from
2888    /// [`EmbeddedStorage::snapshot`]; `None` when it scanned instead (no
2889    /// snapshot, or one taken by another build or configuration).
2890    pub fn restored_files(&self) -> Option<usize> {
2891        self.restored_files
2892    }
2893
2894    /// Brings the index up to date with the repository's storage when there
2895    /// is no watcher to do it: re-lists the vault, compares every file with
2896    /// what the index recorded ([`Repo::diff_vault`]), and applies the
2897    /// differences exactly as a batch of watcher events would be applied —
2898    /// the same invalidation, index updates and cache drops. Returns what it
2899    /// found; an empty diff touches nothing.
2900    ///
2901    /// Needs [`EmbeddedStorage`] (only then does the repository record the
2902    /// stamps the diff compares against). Call after the initial scan, which
2903    /// [`Repo::wait_for_media`] signals.
2904    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    /// Announces the bound address once the listener is up.
2920    ///
2921    /// In server mode (`-s`) the URL is the whole point of the command and the
2922    /// user is watching that terminal, so it goes to stdout unchanged. In GUI
2923    /// mode the window is the affordance and the banner is noise: on Windows a
2924    /// console-subsystem binary launched from Explorer gets a console window,
2925    /// and the line lands there, behind the webview the user is actually
2926    /// looking at. It becomes a `tracing::info!` instead, so `-v` still
2927    /// surfaces it while the default `warn` level keeps it hidden.
2928    ///
2929    /// The decision reads `self.gui_mode` rather than relying on which start
2930    /// method was called. `start_with_port_retry` happens to be GUI-only today,
2931    /// but that is incidental and would rot silently the first time a
2932    /// non-GUI caller wanted port retry.
2933    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    /// Starts the server and optionally signals when ready to accept connections.
2947    /// If a sender is provided, it will receive `()` once the server is bound and listening.
2948    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        // Signal that server is ready before starting to serve
2966        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    /// Starts the server with automatic port retry on address-in-use errors.
2984    ///
2985    /// If the configured port is already in use, this method will try incrementing
2986    /// the port (up to `max_retries` times) until it finds an available port.
2987    /// A warning is printed to stderr when the port is incremented.
2988    ///
2989    /// If a sender is provided, it will receive the actual bound port once the
2990    /// server is listening.
2991    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                    // Signal that server is ready with the actual port
3008                    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                    // Fail fast if we've hit the maximum port number
3027                    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    /// WebSocket handler for live reload file change notifications.
3056    ///
3057    /// # Security
3058    ///
3059    /// WebSocket handshakes are exempt from the same-origin policy, so without
3060    /// this check any page the user happens to visit could open
3061    /// `ws://127.0.0.1:<port>/.mbr/ws/changes` and watch the private
3062    /// file-change feed in real time. Upgrades must therefore be same-origin,
3063    /// and a handshake with **no** `Origin` is rejected as well (browsers
3064    /// always send one on a WebSocket upgrade).
3065    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        // If file watcher is not initialized, close the connection
3086        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        // Subscribe to file change events
3100        let mut rx = file_change_tx.subscribe();
3101
3102        tracing::info!("WebSocket client connected for live reload");
3103
3104        // Send initial connection confirmation
3105        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        // Handle bidirectional communication
3116        loop {
3117            tokio::select! {
3118                // Forward file change events to the client. The whole result is
3119                // bound (never matched with a refutable `Ok(..)` pattern): a
3120                // pattern mismatch permanently disables the branch for that
3121                // `select!`, so a single `Lagged` would silently kill live
3122                // reload for this tab with the socket still open.
3123                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                // Handle incoming messages from client (mostly for connection health)
3149                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    /// Serves `/.mbr/site.json` from a cached body.
3181    ///
3182    /// The body only changes when the repository does, so it is built once and
3183    /// reused until `invalidate_derived_caches` drops it. Building it is
3184    /// blocking CPU work on a large repo, so the (rare) rebuild runs on the
3185    /// blocking pool rather than stalling an async worker.
3186    pub async fn get_site_info(
3187        State(config): State<ServerState>,
3188    ) -> Result<impl IntoResponse, StatusCode> {
3189        // Wait for background scan to complete (watcher handles updates)
3190        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, &params))
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                // Publish only if the repository did not change while we built
3216                // (a concurrent request storing an equivalent body is harmless:
3217                // both were built from the same generation). This response
3218                // still carries the snapshot the request was answered from.
3219                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    /// Returns media metadata (other_files) as JSON.
3241    ///
3242    /// Waits for both the initial scan and media metadata population to complete
3243    /// before returning, ensuring rich metadata (dimensions, duration, etc.) is available.
3244    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        // Wait for background scan + media metadata population (watcher handles updates)
3250        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        // Build response with only other_files
3260        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    /// Search endpoint for finding files by metadata and content.
3284    ///
3285    /// POST /.mbr/search
3286    ///
3287    /// Request body (JSON):
3288    /// ```json
3289    /// {
3290    ///   "q": "search query",
3291    ///   "limit": 50,           // optional, default 50
3292    ///   "scope": "all",        // "metadata", "content", or "all"
3293    ///   "filetype": "markdown",// optional filter
3294    ///   "folder": "/docs"      // optional folder scope
3295    /// }
3296    /// ```
3297    ///
3298    /// Response (JSON):
3299    /// ```json
3300    /// {
3301    ///   "query": "search query",
3302    ///   "total_matches": 42,
3303    ///   "results": [...],
3304    ///   "duration_ms": 15
3305    /// }
3306    /// ```
3307    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        // Don't wait for scan — search with whatever files are available now.
3314        let scan_in_progress = !config.repo.is_scan_complete();
3315
3316        // Only extract text for non-markdown searches (gated on scan completion)
3317        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        // Clone query string for error handling (query is moved into closure)
3327        let query_str = query.q.clone();
3328
3329        // Run search on blocking thread pool (grep does synchronous I/O)
3330        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 searching all filetypes or non-markdown, also search other files
3335            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                // Merge and re-sort
3347                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    /// Task query endpoint for the task browser.
3414    ///
3415    /// `POST /.mbr/tasks`
3416    ///
3417    /// Every request field is optional, so `{}` means "all incomplete tasks in
3418    /// the repository, grouped by file":
3419    ///
3420    /// ```json
3421    /// {
3422    ///   "q": "report #work",
3423    ///   "folder": "/docs/",
3424    ///   "statuses": ["open"],
3425    ///   "priorities": [],
3426    ///   "due": "any",
3427    ///   "mode": "category",
3428    ///   "limit": 500
3429    /// }
3430    /// ```
3431    ///
3432    /// Returns `404` when `tasks_enabled` is off. Server/GUI only — static
3433    /// builds have no task endpoint at all.
3434    ///
3435    /// The first request builds the index (one sequential read pass over the
3436    /// repository's markdown); later ones reuse it, and the watcher keeps it
3437    /// fresh. Like search, this does not wait for the repository scan: partial
3438    /// results with `scan_in_progress: true` beat a hung panel.
3439    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        // Grouping is pure CPU work over an in-memory snapshot, but a
3458        // repository with a hundred thousand tasks makes it long enough to be
3459        // worth keeping off the async runtime.
3460        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    /// A JSON error body shaped like an empty task response, so the frontend
3504    /// can render a failure without a second parse path.
3505    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    /// Enforces the access policy shared by the raw-fetch and save editing
3523    /// endpoints. Returns `Ok(())` when the request may proceed, or `Err(resp)`
3524    /// with the appropriate error response.
3525    ///
3526    /// Policy:
3527    /// 1. Editing must be enabled (`403` otherwise).
3528    /// 2. CSRF: the request must carry `X-MBR-Edit: 1` and be same-origin
3529    ///    (`403` otherwise).
3530    /// 3. Host: when **no** token is configured, the `Host` header must name a
3531    ///    loopback address or the bound address (`403` otherwise). Same-origin
3532    ///    alone does not stop DNS rebinding — a rebound attacker page *is*
3533    ///    genuinely same-origin — so the `Host` name is the only thing that
3534    ///    distinguishes it. This check is skipped once a token is configured,
3535    ///    because then the token is the authority and the server may
3536    ///    legitimately sit behind a reverse proxy presenting any `Host`.
3537    /// 4. Token: required whenever a token is configured, for any non-loopback
3538    ///    caller, or when `edit_require_token_on_loopback` is set. Verified
3539    ///    against `edit_token_hash` as a bearer token (`401` otherwise).
3540    ///
3541    /// Deriving (4) from the peer IP alone was a bypass: behind the
3542    /// TLS-terminating reverse proxy that `docs/modes/editing.md` recommends,
3543    /// every request arrives from 127.0.0.1, so a configured `edit_token_hash`
3544    /// was never checked. A configured token is now *always* enforced.
3545    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        // CSRF: custom header that browsers won't send cross-origin without a
3555        // CORS preflight (which we never grant).
3556        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        // Without a token, the only thing separating a DNS-rebound attacker
3573        // page from the real local UI is the name it used to reach us.
3574        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    /// Whether a request that reached us under this `Host` could ever pass
3604    /// [`Self::check_edit_access`]'s Host gate.
3605    ///
3606    /// With a token configured the Host is not checked (the token is the
3607    /// defence, and a 401 is how a remote reader is asked for it). Without one,
3608    /// only a loopback name or the bind address may edit. Shared with
3609    /// [`Self::page_edit_enabled`] so a page never advertises editing the
3610    /// endpoints will refuse with a 403.
3611    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    /// The `edit_enabled` a page rendered for this request should advertise.
3616    ///
3617    /// `config.edit_enabled` alone is not enough: a page reached through
3618    /// `tailscale serve` or a reverse proxy, with no token configured, would
3619    /// say editing is on and then have every write refused with a 403 — the
3620    /// task checkboxes, the editor and flashcard reviews all failing per click.
3621    fn page_edit_enabled(config: &ServerState, headers: &HeaderMap) -> bool {
3622        config.edit_enabled && Self::host_may_edit(config, headers)
3623    }
3624
3625    /// Best-effort same-origin check for CSRF protection.
3626    ///
3627    /// Prefers the `Sec-Fetch-Site` metadata header (sent by all modern
3628    /// browsers); falls back to comparing the `Origin` host to the `Host`
3629    /// header. A request with no `Origin` at all (e.g. a non-browser client) is
3630    /// allowed — CSRF requires an attacker-controlled document, which always
3631    /// yields an `Origin`/`Sec-Fetch-Site`.
3632    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            // No Origin header: not a cross-origin browser request.
3645            (None, _) => true,
3646            _ => false,
3647        }
3648    }
3649
3650    /// Resolves a URL path to an existing, editable markdown file on disk.
3651    ///
3652    /// Only `ResolvedPath::MarkdownFile` targets are accepted (no file
3653    /// creation), and the canonical path must stay within the repository root
3654    /// (defense-in-depth against traversal/symlink escape).
3655    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                // Defense in depth: canonicalize again, inside the vault, so a
3665                // link out of the repository can never be written through, and
3666                // require an existing file.
3667                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    /// [`Self::resolve_editable_markdown`], asked again after a short pause
3681    /// while it fails.
3682    ///
3683    /// Resolution runs before the per-file write lock (the lock is keyed by
3684    /// what it returns), so it can `stat` a note while another request's
3685    /// atomic write is renaming over it. On Windows that open can fail for a
3686    /// moment (the replaced file is delete-pending, which surfaces as
3687    /// `PermissionDenied`), and the resolver reads any stat error as "absent":
3688    /// a spurious 404 (or, from the canonicalize check, 400) for a file that is
3689    /// plainly there. Seen on CI as `test_concurrent_task_toggles_on_one_file_both_land`
3690    /// failing on Windows aarch64. A real miss costs three 20 ms pauses, on an
3691    /// edit endpoint only.
3692    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    /// GET /.mbr/raw/{*path} — returns the raw markdown source of an existing
3710    /// file plus an `X-MBR-Content-Hash` header for optimistic concurrency.
3711    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    /// POST /.mbr/edit/{*path} — overwrites an existing markdown file with the
3743    /// provided content, guarded by an optimistic-concurrency hash check and an
3744    /// atomic temp-file-then-rename write.
3745    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        // Held across the hash check and the write, so no other write can
3763        // land in between and be overwritten unseen.
3764        let _write_guard = config.file_write_locks.lock(&md_path).await;
3765
3766        // Optimistic concurrency: reject if the file changed since it was loaded.
3767        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(&current, 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        // Trigger live-reload for connected clients.
3788        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    /// POST /.mbr/task — flips the status of a single task line.
3806    ///
3807    /// ```json
3808    /// { "path": "docs/guide.md", "line": 42,
3809    ///   "expected": "- [ ] write the report !!", "to": "done" }
3810    /// ```
3811    ///
3812    /// ```json
3813    /// { "line": 42, "text": "- [x] write the report !! @done(2026-08-04 14:32)" }
3814    /// ```
3815    ///
3816    /// Gated on `edit_enabled` and the same [`Self::check_edit_access`] policy
3817    /// as every other write endpoint — this writes to the user's files, so it
3818    /// answers to the editing switch, not to `tasks_enabled` (in-document
3819    /// checkboxes exist whether or not the task browser does).
3820    ///
3821    /// # `expected` and `base_hash`
3822    ///
3823    /// `expected` alone is not enough. It proves that line `line` still reads
3824    /// the way the client thinks, not that it is the *same task*: add two lines
3825    /// above a list of identical `- [ ] call back` items and a page rendered
3826    /// before the edit would toggle a different one, silently. So the client
3827    /// also sends `base_hash`, the version of the file its line numbers come
3828    /// from — the page's own render (`markdown_source_hash`) or its
3829    /// `/.mbr/raw` read — and any other version on disk is a `409`. The
3830    /// response carries the new version, so a page's consecutive writes chain.
3831    /// `base_hash` is optional: without it the line check is all there is, as
3832    /// before.
3833    ///
3834    /// | Status | Cause |
3835    /// |--------|-------|
3836    /// | `403` / `401` | [`Self::check_edit_access`] (disabled, CSRF, cross-origin, `Host`, token) |
3837    /// | `404` | The path is not an editable markdown file, or it could not be read |
3838    /// | `503` | The note is not downloaded (a cloud provider's placeholder) and did not arrive within a few seconds; `Retry-After` |
3839    /// | `400` | Path outside the root, a file that is not UTF-8, no such line, or the line is not a task |
3840    /// | `409` | The line no longer matches `expected`, or `base_hash` names another version of the file |
3841    /// | `422` | The body is not a well-formed request (axum's `Json` rejection, before this handler runs) |
3842    /// | `500` | The write failed |
3843    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        // Local wall clock, matching the naive/local dates `tasks.rs` parses.
3872        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    /// The `409` for a write whose `base_hash` names another version of the
3909    /// file than `current`, the bytes read under the file's write lock; `None`
3910    /// when they agree or the client sent no hash.
3911    ///
3912    /// The one comparison behind `/.mbr/edit`'s required hash and the optional
3913    /// one on `/.mbr/task` and `/.mbr/flashcard-review`, all against
3914    /// [`mbr_core::edit_auth::content_hash`] — the fingerprint `/.mbr/raw`
3915    /// reports and a rendered page carries as `markdown_source_hash`.
3916    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    /// Reads a markdown file a line-level write is about to patch.
3928    ///
3929    /// Shared by `POST /.mbr/task` and `POST /.mbr/flashcard-review`; `action`
3930    /// only names the caller in the log line. Callers hold the file's
3931    /// [`FileWriteLocks`] guard from before this read until after the write.
3932    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    /// After a line-level write: live-reload for connected clients, then the
3942    /// task index, which the watcher would also refresh — but only after its
3943    /// debounce, and the client that sent the write expects its own next query
3944    /// to see the change. (A flashcard review inserts lines, which moves every
3945    /// task below it, so it needs the invalidation as much as a toggle does.)
3946    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    /// POST /.mbr/flashcard-review — appends one review to a flashcard's
3960    /// history.
3961    ///
3962    /// ```json
3963    /// { "path": "notes/french.md", "line": 12,
3964    ///   "expected": "What is the capital of France?", "rating": "good",
3965    ///   "at": "2026-10-06 13:45" }
3966    /// ```
3967    ///
3968    /// ```json
3969    /// { "entry": "2026-10-06 13:45 - Good", "line": 15,
3970    ///   "inserted_at": 14, "inserted": [": ___Review History___", "  * 2026-10-06 13:45 - Good"] }
3971    /// ```
3972    ///
3973    /// Same gate, same `expected` guard and same atomic write as
3974    /// [`Self::task_toggle_handler`]; the source surgery is
3975    /// [`mbr_core::flashcards::append_review`].
3976    ///
3977    /// The entry is stamped with the **reviewer's** wall clock (`at`), not the
3978    /// server's: the format carries no offset and the deck replays entries as
3979    /// browser-local time, so a server in another time zone would put every
3980    /// review hours away from where FSRS's 1m/10m steps expect it. `at` is
3981    /// held to the exact entry format and to within
3982    /// [`mbr_core::flashcards::REVIEW_TIME_WINDOW_HOURS`] of the server's UTC
3983    /// clock — any real time zone passes, garbage and far past/future do not.
3984    ///
3985    /// | Status | Cause |
3986    /// |--------|-------|
3987    /// | `403` / `401` | [`Self::check_edit_access`] |
3988    /// | `404` | The path is not an editable markdown file, or it could not be read |
3989    /// | `503` | The note is not downloaded (a cloud provider's placeholder) and did not arrive within a few seconds; `Retry-After` |
3990    /// | `400` | Path outside the root, or a file that is not UTF-8 |
3991    /// | `409` | `base_hash` names another version of the file; the line is gone, changed, or no longer a top-level term — the client's copy is stale — or no insertion keeps every other card intact |
3992    /// | `422` | Malformed body, including an unknown rating or a missing, malformed or implausible `at` |
3993    /// | `500` | The write failed |
3994    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    /// Resolves a repo-relative path that may not yet exist to an absolute
4060    /// path, guarding against traversal and symlink escape.
4061    ///
4062    /// Rejects any `..` (or absolute) component, joins onto the canonical repo
4063    /// root, then canonicalizes the deepest **existing** ancestor and asserts it
4064    /// stays within the root. Does not require the target to exist or to be
4065    /// markdown — callers enforce extensions where relevant (create/move do;
4066    /// mkdir does not).
4067    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    /// Whether `path` ends in a configured markdown extension (case-insensitive).
4073    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    /// Whether `path`'s file name is the configured index file.
4081    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    /// The repo-relative path of `path`, always `/`-separated.
4088    ///
4089    /// This feeds the `path` field of the file-operation responses, which is
4090    /// documented as e.g. `notes/image.png`, so it must not become
4091    /// `notes\image.png` on Windows.
4092    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    /// [`Vault::write_atomic`] for an index key, with the error the
4098    /// file-operation endpoints answer with. Blocking; async callers use
4099    /// [`Self::atomic_write_file_async`]. For the desktop this is
4100    /// [`crate::file_write::atomic_write`] (temp file beside the target,
4101    /// permissions kept, rename).
4102    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    /// [`Self::atomic_write_file`] off the async executor.
4112    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    /// The bytes of the file behind an index key, read through the vault on a
4126    /// blocking thread (where `tokio::fs` read them).
4127    /// Reads a note an edit endpoint (`/.mbr/raw`, `/.mbr/edit`, `/.mbr/task`,
4128    /// `/.mbr/flashcard-review`) is about to hand out or patch; `action` only
4129    /// names the caller in the log line.
4130    ///
4131    /// A note the vault reports not downloaded — or whose read fails that way
4132    /// — is requested from the provider and polled for up to
4133    /// [`MBR_ASSET_DOWNLOAD_WAIT`], like a `/.mbr/*` override
4134    /// ([`Self::serve_mbr_file`]); still missing, it is a `503` with
4135    /// `Retry-After` (logged at DEBUG), never the `404` it used to be. On iOS
4136    /// this is the common case after an edit on another device: iCloud marks
4137    /// the phone's copy dataless until the new version is fetched, and the
4138    /// app's fail-fast I/O policy turns the read into `EDEADLK`. Once the
4139    /// bytes arrive the caller sees the *new* version, so a line-level write
4140    /// then answers `409` and the page offers to reload.
4141    ///
4142    /// Free on the desktop: a `LocalVault` answers `availability` without a
4143    /// system call and never fails a read `NotDownloaded`.
4144    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; // a provider with the bytes at hand has them now
4176            }
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    /// Broadcasts a `FileChangeEvent` for live-reload + watcher reconciliation.
4189    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    /// POST /.mbr/create/{*path} — creates a new markdown file with the given
4206    /// contents. `{*path}` is a repo-relative filesystem path with extension.
4207    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    /// Blocking body of [`Self::create_markdown_handler`].
4230    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        // Surgical state update (Created): the new file adds its own tags inline
4258        // in invalidate_file, so no tag-index rebuild is needed.
4259        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    /// POST /.mbr/mkdir/{*path} — creates a directory (idempotent). `{*path}` is
4280    /// a repo-relative filesystem path. No request body.
4281    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    /// Blocking body of [`Self::mkdir_handler`].
4302    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            // Idempotent: pre-creating an existing folder is retry-safe.
4308            return Ok(MkdirResponse { path: rel_path });
4309        }
4310        if config.key_exists(&target) {
4311            // A file occupies the path.
4312            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    /// POST /.mbr/upload?dir=<>&name=<> — writes a raw binary asset (the
4326    /// editor's image uploader) into the repo next to the note being edited and
4327    /// returns a URL the editor and rendered site both resolve. The body is the
4328    /// raw file bytes; the body-size cap comes from `upload_max_bytes`
4329    /// (oversized bodies are rejected with `413` by `DefaultBodyLimit`).
4330    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, &params.dir, &params.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    /// Blocking body of [`Self::upload_handler`].
4355    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        // Compose `<dir>/<name>` and resolve it traversal-safely. `dir` may be
4365        // empty (root-level note); leading/trailing slashes are tolerated.
4366        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        // `.mbr` is an ordinary path component to the resolver, so without this
4376        // the uploader could drop a file into the template folder — where the
4377        // watcher hot-reloads `*.html` as Tera templates and `components/*.js`
4378        // shadows the compiled-in bundle in every page.
4379        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        // The destination is normally the note's own (existing) folder; create
4389        // it defensively if missing. `safe_name` is a pure basename, so the
4390        // parent is the resolved, containment-checked directory.
4391        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        // Collision policy: keep the name, suffix `-1`, `-2`, … on collision.
4402        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        // Root-absolute URL that matches how mbr serves the file. Reuse
4413        // `build_static_url_path` (same util used for every static asset URL),
4414        // then percent-encode so special characters load in the browser.
4415        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        // A static asset needs no markdown/index rebuild; the watcher will pick
4429        // it up into media.json. Broadcast so live-reload clients learn of it.
4430        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    /// POST /.mbr/move/{*path} — moves/renames a markdown file, rewriting
4444    /// inbound links repo-wide and (on stem change) bare `[[Name]]` links.
4445    /// `{*path}` is the source repo-relative filesystem path with extension.
4446    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        // One inbound-grep permit guards the whole repo-wide rewrite pass, held
4458        // across the blocking work (mirrors links.json grep throttling).
4459        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    /// Blocking body of [`Self::move_markdown_handler`].
4478    fn do_move(
4479        config: &ServerState,
4480        from: &str,
4481        req: MoveRequest,
4482    ) -> Result<MoveResponse, FileOpError> {
4483        // Resolve source (existing markdown) and destination (new path).
4484        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        // A note in a mount can be neither moved out (its source would have to
4492        // be deleted) nor moved in.
4493        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        // Collision: destination exists and is not the same file. A case-only
4501        // rename on a case-insensitive filesystem canonicalizes back to source.
4502        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        // Create the destination parent if requested.
4513        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        // Compute index-stripped page URLs before touching disk.
4525        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        // From the first read of the source until it is gone, no other write
4533        // may touch either file: a toggle landing on the source in between
4534        // would be lost with it, or would recreate it after the delete. `src`
4535        // is canonical and `dst` is resolved under the canonical root, so these
4536        // are the keys the other writers use. Both are taken in path order
4537        // (`lock_all_blocking`) and released before the repo-wide rewrites
4538        // below, which lock each file they change one at a time — never two
4539        // locks held while waiting for a third.
4540        let move_guards = config
4541            .file_write_locks
4542            .lock_all_blocking(&[src.as_path(), dst.as_path()]);
4543
4544        // A4-C delta names (bare `[[Name]]` rewrite on stem change) need the
4545        // source frontmatter (title/aliases) so still-resolvable names are kept.
4546        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        // Read source, re-express its own relative links against the new folder
4564        // (A4-B), then write the rewritten content to the destination.
4565        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        // Content changed (A4-B), so this is a temp-write + delete-source, not a
4576        // plain rename. For a case-only rename the old-cased name must be removed
4577        // before the rename lands so the new case is preserved.
4578        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            // Without real paths there are no POSIX permissions to carry over:
4588            // the vault's own atomic write, then the source goes.
4589            _ => {
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        // Skip set for the repo-wide walkers: the destination file (already
4602        // written); the source no longer exists so it won't be walked.
4603        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        // A4-A: rewrite inbound links across the whole repo.
4610        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        // A4-C: rewrite bare `[[Name]]` links, guarded by the pre-move index.
4623        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        // A5: surgical repo/cache updates + broadcasts.
4638        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    /// The move's write on a local filesystem: the rewritten content goes to a
4696    /// temp file beside the destination carrying the **source's** permissions
4697    /// (as a rename would keep them), then lands with a rename, and the source
4698    /// is removed — old-cased name first for a case-only rename, so the new
4699    /// case is preserved. Not `Vault::write_atomic`, which would take the
4700    /// permissions of a destination that does not exist yet.
4701    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        // The moved note keeps the source's permissions, as a rename would.
4715        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    /// Computes the old resolvable names that no longer resolve to the file
4736    /// after a move (for A4-C bare-`[[Name]]` rewriting), each paired with the
4737    /// new filename stem. Title and aliases are unchanged by a move, so in
4738    /// practice only a changed filename stem contributes.
4739    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        // Names that still resolve to the file after the move must NOT be
4759        // rewritten: the new stem, the (unchanged) title, and any aliases.
4760        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    /// Media viewer endpoint for video, PDF, audio, and image content.
4783    ///
4784    /// GET /.mbr/videos/?path=<encoded_path>
4785    /// GET /.mbr/pdfs/?path=<encoded_path>
4786    /// GET /.mbr/audio/?path=<encoded_path>
4787    /// GET /.mbr/images/?path=<encoded_path>
4788    ///
4789    /// Renders the media_viewer.html template with the appropriate media type
4790    /// and validated media path. The path query parameter must be URL-encoded
4791    /// and point to a valid file within the repository.
4792    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        // Determine media type from route path (the URI path without query string)
4800        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        // Check for missing path parameter
4822        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        // Validate the media path
4843        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        // Extract title from filename
4901        let title = validated_path
4902            .file_stem()
4903            .and_then(|s| s.to_str())
4904            .unwrap_or("Media Viewer")
4905            .to_string();
4906
4907        // Generate breadcrumbs from the URL path (media_path), not the filesystem path
4908        // The media_path is already the URL path (e.g., "/videos/Jay Sankey/video.mp4")
4909        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        // Get parent path for back navigation (from URL path)
4915        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                // Ensure trailing slash and clean up leading slash for format
4920                let clean = p.trim_start_matches('/');
4921                format!("/{}/", clean)
4922            }
4923        });
4924
4925        // Build template context
4926        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        // Render the media viewer template
4953        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    /// Serves assets from /.mbr/* path.
4985    ///
4986    /// Priority:
4987    /// 1. If template_folder is set, serve from there (js/ for components, rest from root)
4988    /// 2. Otherwise, check .mbr/ directory in base_dir
4989    /// 3. Fall back to compiled-in DEFAULT_FILES
4990    ///
4991    /// # Security
4992    ///
4993    /// Path traversal attacks are blocked by `safe_join_asset` which validates
4994    /// that resolved paths remain within the intended directory, and
4995    /// [`is_servable_mbr_asset`] restricts what may be read out of that
4996    /// directory at all — the template folder also holds `config.toml`, which
4997    /// carries the Argon2 `edit_token_hash` and must never be served.
4998    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        // Normalize path: add leading slash if missing
5005        let asset_path = if path.starts_with('/') {
5006            path.clone()
5007        } else {
5008            format!("/{}", path)
5009        };
5010
5011        // Asset allowlist: `.mbr/` is a config directory as much as an asset
5012        // directory, so only recognized asset types are ever served from it.
5013        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        // Try template_folder first if set (with path traversal protection)
5019        if let Some(ref template_folder) = config.template_folder {
5020            // Map components/* -> components-js/* in template folder
5021            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        // Try .mbr/ directory in base_dir (with path traversal protection)
5038        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        // Handle /pico.min.css dynamically based on theme config
5046        if asset_path == "/pico.min.css" {
5047            return Self::serve_themed_pico(&config.theme);
5048        }
5049
5050        // Fall back to compiled-in defaults
5051        Self::serve_default_file(&asset_path)
5052    }
5053
5054    /// Serves one `/.mbr/*` override at `path` (canonical, already vetted by
5055    /// `safe_join_asset`), waiting briefly for it when a cloud provider has
5056    /// not downloaded it yet.
5057    ///
5058    /// `.mbr/` is a hidden folder, so the scan never reads anything in it and
5059    /// nothing else asks the provider for its files: on iOS a dataless
5060    /// `user.css` would otherwise fail every request (`EDEADLK` under the
5061    /// app's fail-fast I/O policy). When the repository's vault reports the
5062    /// file not downloaded — or the read fails that way — the download is
5063    /// requested and the file polled for up to [`MBR_ASSET_DOWNLOAD_WAIT`];
5064    /// still missing, it is a `503` with `Retry-After`, logged at DEBUG like
5065    /// every other not-downloaded answer.
5066    ///
5067    /// Free on the desktop: a `LocalVault` answers `availability` without a
5068    /// system call, and a file outside the vault (an external
5069    /// `template_folder`) is only read, as before.
5070    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; // a provider with the bytes at hand has them now
5099                }
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    /// `bytes` read from `path`, with its MIME type and cache headers.
5114    async fn file_response(path: &Path, bytes: Vec<u8>) -> Result<Response<Body>, StatusCode> {
5115        let mime = Self::guess_mime_type(path);
5116
5117        // Generate ETag from content
5118        let etag = generate_etag(&bytes);
5119
5120        // Get Last-Modified from file metadata
5121        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    /// Serve themed Pico CSS based on the configured theme.
5144    ///
5145    /// Returns the appropriate Pico CSS variant based on theme config:
5146    /// - "" or "default" -> pico.min.css
5147    /// - "{color}" (e.g., "amber") -> pico.{color}.min.css
5148    /// - "fluid" -> pico.fluid.classless.min.css
5149    /// - "fluid.{color}" (e.g., "fluid.amber") -> pico.fluid.classless.{color}.min.css
5150    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    /// Guess MIME type from file extension
5174    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    /// Render an error page using the error.html template.
5194    /// Falls back to a plain text response if template rendering fails.
5195    #[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    /// [`Self::render_error_page`] with extra template context: `extra`'s
5226    /// keys are added after the standard ones (the Downloading page's
5227    /// `downloading`, `note_name`, `download_provider`).
5228    #[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        // Server mode uses absolute paths; error pages omit title affixes
5257        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                // An error page has no markdown body, so there is nothing on it a
5269                // review anchor could point at. Inline rather than a parameter:
5270                // `render_error_page` already takes `tasks_enabled: bool`, and a
5271                // second adjacent bool would be silently swappable at eight call
5272                // sites.
5273                review_enabled: false,
5274                tasks_default_include,
5275                title_affixes: None,
5276                // `render_error_page` has no `Config`/`ServerState` in scope
5277                // (discrete parameters only) and an error page has no
5278                // markdown body to scope review storage against anyway.
5279                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    /// Serve from compiled-in DEFAULT_FILES or KATEX_FILES with cache headers.
5313    fn serve_default_file(path: &str) -> Result<Response<Body>, StatusCode> {
5314        // First check DEFAULT_FILES
5315        let file = mbr_core::assets::default_file(path)
5316            // Then check KATEX_FILES (embedded KaTeX CSS, JS, and fonts)
5317            .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            // Generate ETag from content
5328            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        // Defense in depth: never serve a resolved filesystem path that escapes
5355        // the repository root (or the static-folder overlay). A symlink inside
5356        // the repo pointing outside it is lexically contained, so the check has
5357        // to be made on the canonicalized path, right before serving.
5358        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                // Check if this is a PDF cover sidecar file that might be stale
5376                #[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                // A file a cloud provider has not downloaded would stall (or,
5387                // with the mobile app's fail-fast I/O policy, break) half-way
5388                // through the body. Free on the desktop, whose vault answers
5389                // `Local` without a system call.
5390                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                // `ServeFile` (Range, ETag, Last-Modified, content type) for a
5395                // real file — every file on the desktop. Anything else streams
5396                // through the vault, with the same Range handling.
5397                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                // A markdown page has exactly one URL — the directory-style one
5404                // (`docs/guide.md` -> `/docs/guide/`). Serving it at any other
5405                // spelling with a 200 is not harmless: the browser's base for
5406                // that page's own relative links becomes `/docs/` instead of
5407                // `/docs/guide/`, so every `../` href the renderer emitted for
5408                // the trailing-slash convention lands one directory too high
5409                // and 404s. The damage therefore shows up one click *after* the
5410                // wrong URL, which is what makes it so hard to trace back.
5411                //
5412                // Redirecting here is the general fix: it repairs hand-typed
5413                // URLs, inbound external links and stale bookmarks, not just
5414                // hrefs mbr generated itself.
5415                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                    // Listed by the provider, but its bytes are in the cloud:
5433                    // ask for them and say so, rather than a 500.
5434                    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                // Try to serve HLS content (playlist or segment) for transcoded variants
5486                #[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                // Try to serve the stream-copy (remux) HLS variant. Deliberately
5494                // NOT gated on `transcode_enabled`: a remux costs a fraction of
5495                // a re-encode and is how a video the browser refused to play
5496                // gets recovered.
5497                #[cfg(feature = "media-metadata")]
5498                if let Some(response) = Self::try_serve_remux_content(&path, &config).await {
5499                    return Ok(response);
5500                }
5501
5502                // Try to serve dynamically generated video metadata (server mode only)
5503                #[cfg(feature = "media-metadata")]
5504                if let Some(response) = Self::try_serve_video_metadata(&path, &config).await {
5505                    return Ok(response);
5506                }
5507
5508                // Try to serve dynamically generated PDF cover image (server mode only)
5509                #[cfg(feature = "media-metadata")]
5510                if let Some(response) = Self::try_serve_pdf_cover(&path, &config).await {
5511                    return Ok(response);
5512                }
5513
5514                // Try to serve errors.json for per-page problem reporting
5515                // (server/GUI only — static builds never register this)
5516                if let Some(response) = Self::try_serve_errors_json(&path, &config).await {
5517                    return Ok(response);
5518                }
5519
5520                // Try to serve links.json for bidirectional link tracking
5521                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    /// Serves a static file that has no local path, through its vault, with
5545    /// `Range` support ([`crate::vault_body`]). The counterpart of
5546    /// [`Self::serve_static_file`] for vaults whose bytes are not files.
5547    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    /// The page for a note whose bytes are not on this device yet (a cloud
5588    /// provider's dataless file). The download has been requested; the page
5589    /// asks to be fetched again shortly (`Retry-After`, and `Refresh` for the
5590    /// browsers that honour it).
5591    ///
5592    /// Still a `503` underneath, but not shown as an error: downloading on
5593    /// first access is the normal life of a cloud note. `error.html`'s
5594    /// `downloading` branch draws an indeterminate progress bar, "Downloading
5595    /// from iCloud…" (when the provider can be told from the path) and the
5596    /// note's name.
5597    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                // The page title is the note the reader tapped: mbr's own header
5622                // and the iOS bar (app-bridge.ts reads it) show its name, not
5623                // "Downloading".
5624                ("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    /// Serves a static file using tower's ServeFile service with cache headers.
5639    /// ServeFile already provides Last-Modified and ETag headers.
5640    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        // Add Cache-Control header for browser revalidation
5655        response.headers_mut().insert(
5656            header::CACHE_CONTROL,
5657            HeaderValue::from_static(CACHE_CONTROL_NO_CACHE),
5658        );
5659
5660        Ok(response)
5661    }
5662
5663    /// Builds a response from a cached video-metadata entry, or `None` for a
5664    /// negative (`NotAvailable`) marker so the caller falls through to a 404.
5665    #[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    /// Runs the (blocking) ffmpeg extraction for a single metadata type off the
5680    /// async worker, stores the result (positive or negative) in the cache, and
5681    /// returns the response. This is only ever called by the single producer for
5682    /// a given cache key (see `try_serve_video_metadata`).
5683    #[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        // ffmpeg decoding is blocking CPU/IO work; keep it off the tokio worker
5696        // threads (finding #16). Owned `video_file`/`metadata_type` are `Send`.
5697        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    /// Try to serve dynamically generated video metadata (cover, chapters, captions).
5724    ///
5725    /// Returns Some(Response) if the request was for video metadata and we successfully
5726    /// generated it, None otherwise (fall through to 404).
5727    #[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        // Check if this is a video metadata request
5733        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        // Resolve the video file path (with path-traversal protection) *before*
5742        // computing the cache key so the key can be scoped to the file's mtime
5743        // (finding #13). If the file no longer exists, fall through to 404.
5744        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        // mtime-scoped key: editing the source file yields a new key so stale
5758        // positive/negative entries are naturally missed and re-extracted.
5759        let key = cache_key_with_mtime(&video_file, cache_type_str);
5760
5761        // Fast path: serve a cached result (may be a negative marker).
5762        if let Some(cached) = config.video_metadata_cache.get(&key) {
5763            return Self::metadata_response_from_cache(cached);
5764        }
5765
5766        // Single-flight (finding #20): ensure only one ffmpeg decode runs per
5767        // (path, type). Either we claim the slot and produce, or another request
5768        // is already producing and we await its result. Mirrors the race-free
5769        // HlsCache pattern (register interest before re-checking).
5770        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                // Hold the slot in a drop guard: a client disconnect drops this
5778                // future at the await below, and a release that only runs on the
5779                // success path would leave `key` claimed forever, wedging every
5780                // later request for it into a full `METADATA_WAIT_TIMEOUT` wait
5781                // followed by a 404. The guard releases the slot and wakes any
5782                // waiters (who then read the freshly-populated cache entry) on
5783                // every exit path.
5784                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                // Register interest before re-checking so a completion that lands
5796                // between claim and here is not missed.
5797                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    /// Try to serve a PDF cover sidecar file, handling staleness detection.
5821    ///
5822    /// This is called from the StaticFile branch when a `.pdf.cover.jpg` sidecar exists.
5823    /// It checks if the sidecar is stale (PDF modified after sidecar) and regenerates if needed.
5824    ///
5825    /// Returns:
5826    /// - `Some(Response)` if the sidecar is stale and we regenerated the cover
5827    /// - `None` if the sidecar is fresh (caller should serve as normal static file)
5828    ///   or if this is not a PDF cover sidecar request
5829    #[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        // Check if this is a PDF cover request
5839        let _pdf_url_path = parse_pdf_cover_request(url_path)?;
5840
5841        // Find the PDF file path (corresponding to this sidecar)
5842        // The sidecar is at {pdf_path}.cover.jpg, so remove .cover.jpg to get pdf_path
5843        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        // Build an mtime-scoped cache key from the resolved source file so an
5850        // edited PDF invalidates a stale cached cover at runtime (finding #13).
5851        let key = cache_key_with_mtime(&pdf_file, "pdf_cover");
5852
5853        // Check memory cache first
5854        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, // Cached negative result
5858                _ => None,                            // Other types not relevant for PDF covers
5859            };
5860        }
5861
5862        // If PDF doesn't exist, let static file serving handle it
5863        if !pdf_file.is_file() {
5864            // Cache and serve the sidecar contents
5865            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        // Compare modification times
5879        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            // Sidecar is stale - regenerate
5886            tracing::debug!(
5887                "Sidecar is stale (PDF modified after sidecar), regenerating: {}",
5888                sidecar_file_path.display()
5889            );
5890
5891            // Generate new cover (async with concurrency control)
5892            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                    // Fall through to serve stale sidecar instead of failing
5902                }
5903            }
5904        }
5905
5906        // Sidecar is fresh (or regeneration failed) - read, cache, and serve
5907        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        // Let static file serving handle it
5919        None
5920    }
5921
5922    /// Try to serve dynamically generated PDF cover image.
5923    ///
5924    /// Returns Some(Response) if the request was for a PDF cover image and we successfully
5925    /// generated it, None otherwise (fall through to 404).
5926    ///
5927    /// Request pattern: `/path/to/document.pdf.cover.jpg` -> extract cover from `/path/to/document.pdf`
5928    ///
5929    /// This function implements accelerated serving with pre-generated covers:
5930    /// 1. If a sidecar file (e.g., `document.pdf.cover.jpg`) exists and is newer than the PDF,
5931    ///    it is served directly from disk (with memory caching for subsequent requests).
5932    /// 2. If the sidecar is stale (PDF modified after sidecar), the cover is regenerated.
5933    /// 3. If no sidecar exists, the cover is dynamically generated from the PDF.
5934    #[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        // Check if this is a PDF cover request
5940        let pdf_url_path = parse_pdf_cover_request(path)?;
5941
5942        // Resolve the PDF file path (with path-traversal protection) *before*
5943        // computing the cache key so the key can be scoped to the file's mtime
5944        // (finding #13). First try the direct path, then the static_folder prefix.
5945        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        // Build an mtime-scoped cache key so an edited PDF invalidates a stale
5956        // cached cover at runtime (finding #13).
5957        let key = cache_key_with_mtime(&pdf_file, "pdf_cover");
5958
5959        // Check memory cache first
5960        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, // Cached negative result
5964                _ => None,                            // Other types not relevant for PDF covers
5965            };
5966        }
5967
5968        // Build sidecar path: {pdf_path}.cover.jpg
5969        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        // Check if we can serve from sidecar file
5977        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            // Cache the sidecar contents for subsequent requests
5980            config
5981                .video_metadata_cache
5982                .insert(key, CachedMetadata::Cover(bytes.clone()));
5983            return Some(Self::build_jpg_response(bytes));
5984        }
5985
5986        // Sidecar doesn't exist or is stale - generate dynamically
5987        tracing::debug!("Generating PDF cover for: {}", pdf_file.display());
5988
5989        // Generate the cover image (async with concurrency control)
5990        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    /// Try to serve a PDF cover from a pre-generated sidecar file.
6015    ///
6016    /// Returns `Some(bytes)` if:
6017    /// 1. The sidecar file exists
6018    /// 2. The sidecar is newer than the PDF (not stale)
6019    /// 3. The file can be read successfully
6020    ///
6021    /// Returns `None` if the sidecar doesn't exist, is stale, or can't be read.
6022    #[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        // Ensure the sidecar path stays within the same directory as the validated PDF path.
6028        // This provides an additional defense-in-depth check against path traversal before
6029        // performing any filesystem operations on the sidecar file.
6030        if let Some(pdf_dir) = pdf_path.parent() {
6031            // First, ensure that the sidecar's parent directory is exactly the same as the PDF's
6032            // parent directory. Since `sidecar_path` was constructed from `pdf_path` by only
6033            // changing the file name, any deviation here indicates an unexpected or unsafe path.
6034            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                // A sidecar without a parent directory is unexpected; treat as invalid.
6046                tracing::warn!(
6047                    "Sidecar path has no parent directory; skipping sidecar: {}",
6048                    sidecar_path.display()
6049                );
6050                return None;
6051            }
6052
6053            // Additionally, validate that the sidecar path, once canonicalized, still resides
6054            // under the PDF's directory. This guards against any remaining path traversal risks.
6055            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            // If the PDF has no parent directory, treat this as invalid and do not use the sidecar.
6064            tracing::warn!(
6065                "PDF path has no parent directory; skipping sidecar: {}",
6066                pdf_path.display()
6067            );
6068            return None;
6069        }
6070
6071        // Check if sidecar exists
6072        let sidecar_meta = tokio::fs::metadata(sidecar_path).await.ok()?;
6073
6074        // Get PDF modification time for staleness check
6075        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 is newer than sidecar, sidecar is stale
6080        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        // Read and return sidecar contents
6089        tokio::fs::read(sidecar_path).await.ok()
6090    }
6091
6092    /// Try to serve links.json for bidirectional link tracking.
6093    ///
6094    /// Returns Some(Response) if the request was for links.json and we successfully
6095    /// generated it, None otherwise (fall through to 404).
6096    async fn try_serve_links_json(path: &str, config: &ServerState) -> Option<Response<Body>> {
6097        use mbr_core::link_index::PageLinks;
6098
6099        // Check if this is a links.json request
6100        if !path.ends_with("links.json") {
6101            return None;
6102        }
6103
6104        // If link tracking is disabled, return None (404)
6105        if !config.link_tracking {
6106            tracing::debug!("links.json requested but link tracking is disabled");
6107            return None;
6108        }
6109
6110        // Extract the page URL path from the request
6111        // e.g., "docs/guide/links.json" -> "/docs/guide/". The axum catch-all
6112        // delivers `path` without a leading slash, so add one explicitly (as
6113        // `try_serve_errors_json` does): this is the shared `link_cache` /
6114        // `inbound_link_cache` key, and the site-absolute form is what
6115        // `markdown_to_html` and `try_serve_errors_json` write. Keying it
6116        // `docs/guide/` here instead made every render's back-fill invisible to
6117        // this handler, so an edited page kept serving its first-seen links.
6118        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        // Check if the page exists and get outbound links
6129        // If not cached, we need to verify the page exists and render it to extract links
6130        let outbound = if let Some(cached) = config.link_cache.get(&page_url_path) {
6131            cached
6132        } else {
6133            // Resolve the path to find the markdown file
6134            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            // Convert page_url_path to a request path for the resolver
6138            // "/docs/guide/" -> "docs/guide"
6139            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                    // Render the page to extract outbound links
6149                    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,  // server_mode
6173                        false, // transcode_enabled (not needed for link extraction)
6174                        valid_tag_sources,
6175                        // A links-only render: nothing consumes the HTML, so `data-mbr-line` would
6176                        // be pure cost.
6177                        markdown::ReviewLines::Omit,
6178                        false, // mark_incomplete: not needed for link extraction
6179                        &config.incomplete_markers,
6180                        Some(config.repo.wikilink_index.clone()),
6181                    )
6182                    .await
6183                    {
6184                        Ok(render_result) => {
6185                            // Resolve relative URLs to absolute before caching
6186                            let resolved_links = resolve_outbound_links(
6187                                &page_url_path,
6188                                render_result.outbound_links,
6189                                is_index_file,
6190                            );
6191                            // Cache the outbound links
6192                            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                    // Page doesn't exist
6222                    tracing::debug!("links.json: page not found: {}", page_url_path);
6223                    return None;
6224                }
6225            }
6226        };
6227
6228        // Get inbound links from cache, or grep with single-flight + bounded
6229        // concurrency: each miss walks the whole repository, and the sidebar
6230        // mini graph fans out many links.json requests at once, so a burst
6231        // must not stampede the filesystem. Mirrors the race-free video
6232        // metadata pattern (register interest before re-checking).
6233        let inbound = if config.inbound_index.is_ready() {
6234            // The repository-wide index is authoritative once built: it was
6235            // produced by inverting every page's resolved links, the same way
6236            // static builds do, so no grep and no per-page cache is involved.
6237            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                    // Release the slot and wake waiters on every exit path —
6244                    // failure, `?`, panic, and a client disconnect that drops
6245                    // this future at the grep await — so waiters degrade to a
6246                    // retryable miss instead of hanging and the key is never
6247                    // left permanently claimed.
6248                    let _slot = InflightSlot::new(
6249                        Arc::clone(&config.inbound_grep_inflight),
6250                        page_url_path.clone(),
6251                        notify,
6252                    );
6253                    // Re-check the cache after winning the slot: a previous
6254                    // producer may have populated it between our miss and the
6255                    // claim.
6256                    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                    // Register interest before re-checking so a completion
6264                    // that lands between claim and here is not missed.
6265                    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        // Typed relationships (declared + derived) for this page, if enabled.
6287        // The index normalises keys (leading slash) on both insert and lookup,
6288        // so `page_url_path` can be passed through without compensation.
6289        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    /// Runs the inbound-link grep for `page_url_path` on a blocking thread,
6319    /// bounded by the inbound-grep semaphore, and caches the result. Returns
6320    /// `None` if the task fails (callers degrade to a retryable miss).
6321    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        // Bound concurrent full-repo walks. The semaphore is never closed, so
6328        // acquire only fails if the runtime is shutting down.
6329        let _permit = config.inbound_grep_semaphore.acquire().await.ok()?;
6330
6331        // Grep for inbound links. This walks the whole repository (blocking
6332        // filesystem + CPU work), so run it on a blocking thread to avoid
6333        // stalling the async worker. All captured data is owned/`Send`.
6334        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        // Cache the result
6354        config
6355            .inbound_link_cache
6356            .insert(page_url_path.to_string(), links.clone());
6357        Some(links)
6358    }
6359
6360    /// Try to serve `errors.json` for per-page problem reporting.
6361    ///
6362    /// This endpoint is server/GUI only — static builds never register it and
6363    /// the template guards the corresponding `<mbr-page-errors>` element with
6364    /// `{% if server_mode %}`. Triple-gated to guarantee zero leakage into
6365    /// static output.
6366    ///
6367    /// Returns:
6368    /// - `Some(200 JSON)` with an `errors: []` array (possibly empty) when the
6369    ///   request targets a valid page.
6370    /// - `None` (→ 404) when the path is not `errors.json`, when link tracking
6371    ///   is disabled, or when the underlying page does not exist.
6372    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        // Only handle exact `errors.json` tails. This keeps the endpoint
6381        // tightly scoped and avoids colliding with a user-authored file that
6382        // happens to have "errors" in its stem.
6383        if !path.ends_with("errors.json") {
6384            return None;
6385        }
6386
6387        // Respect the `--no-link-tracking` flag: the whole feature is gated on
6388        // link tracking being on, so reuse that switch rather than introducing
6389        // a new toggle. When disabled, the component stays silent (see
6390        // `mbr-page-errors.ts`'s 404 handling).
6391        if !config.link_tracking {
6392            tracing::debug!("errors.json requested but link tracking is disabled");
6393            return None;
6394        }
6395
6396        // Reconstruct the canonical page URL for the resolver.
6397        // e.g. "docs/guide/errors.json" -> "/docs/guide/". The axum catch-all
6398        // delivers `path` without a leading slash, so we add one explicitly
6399        // to yield a canonical site-absolute URL in the JSON payload.
6400        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        // Resolve the page and render if it is a markdown file. We need the
6416        // rendered HTML for media / wikilink scans (the `LinkCache` only holds
6417        // outbound links), so unlike `try_serve_links_json` we cannot short-
6418        // circuit through the cache when the HTML is missing.
6419        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                    // Must match `markdown_to_html`: the hrefs this render
6445                    // produces are the ones `validate_rendered_links` judges,
6446                    // so a checker rendering without the probe would report
6447                    // every extension-less markdown link as non-canonical while
6448                    // the page a reader sees is perfectly fine.
6449                    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,  // server_mode
6466                    false, // transcode_enabled (not needed for error scan)
6467                    valid_tag_sources,
6468                    // The error scan reads hrefs, never `data-mbr-line`.
6469                    markdown::ReviewLines::Omit,
6470                    false, // mark_incomplete: not needed for error scan
6471                    &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                        // Back-fill the link cache so a subsequent links.json
6483                        // call is a hit (mirrors `try_serve_links_json`).
6484                        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                    // The page itself answers 503 "Downloading" and is
6499                    // retried every couple of seconds; its errors.json is
6500                    // fetched on each of those loads. Answer cleanly, with
6501                    // the problems that do not depend on the note's body.
6502                    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                // Tag pages have no authored HTML and no media; there is
6514                // nothing for the validators to flag. Return an empty
6515                // payload so the UI can stay silent.
6516                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            // Tag pages and tag indexes have no authored body; their outbound
6544            // links are synthesized absolute URLs, so that list is the only
6545            // thing to check.
6546            errors.extend(validate_internal_links(&outbound_links, &resolver_config));
6547        } else {
6548            // For a rendered page, read the hrefs that actually went into the
6549            // HTML. `outbound_links` holds the *authored* destinations resolved
6550            // with markdown semantics, so re-resolving those re-applies the
6551            // same rules the transform used and can never see a transform bug —
6552            // which is exactly how a missing trailing slash stayed invisible.
6553            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        // Relationship data problems. Detected once per index rebuild, so this
6578        // is a pair of map lookups; gated on the feature that produced them so
6579        // `--no-relationship-tracking` reports nothing (the index is empty then
6580        // anyway, but the gate keeps the contract explicit).
6581        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    /// `errors.json` for a note whose bytes are not on this device yet: `200`
6613    /// with only the problems that do not depend on the note's body (an
6614    /// ignored configuration) and `not_downloaded: true`, never cached, so the
6615    /// load after the download completes gets the real list.
6616    /// `mbr-page-errors.ts` treats it like any other answer: no indicator
6617    /// unless there is a problem to show, and nothing in the console.
6618    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    /// Probes a media file for tracks that break browser playback, memoized by
6649    /// path+mtime.
6650    ///
6651    /// `probe_playback_compatibility` opens the container (blocking work), so a
6652    /// cache miss runs on a blocking thread. Both outcomes are cached, so a
6653    /// page with fine videos costs one probe per file for the lifetime of the
6654    /// server rather than one per `errors.json` request.
6655    #[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    /// Detects videos on the page that resolve and serve correctly but that
6681    /// the browser cannot decode.
6682    ///
6683    /// Deliberately lives here rather than in `vid.rs` / `markdown.rs`: this
6684    /// opens every referenced video with ffmpeg, which must never sit on the
6685    /// markdown render path. `errors.json` is fetched lazily in the background
6686    /// by `<mbr-page-errors>`, so the cost is off the critical path, bounded by
6687    /// [`MEDIA_COMPAT_PROBE_CONCURRENCY`], and paid at most once per
6688    /// file+mtime.
6689    #[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        // `collect_media_references` already dedupes by src; filtering to video
6702        // extensions keeps images and PDFs out of the ffmpeg path entirely.
6703        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        // One probe per distinct file. A page can reference the same clip under
6713        // several spellings (the reported repro embeds it both percent-encoded
6714        // and angle-bracketed), and each spelling needs its own error so the
6715        // frontend can match every element — but the bytes are read once.
6716        //
6717        // `buffered` (not `buffer_unordered`) bounds concurrency without making
6718        // the result order depend on probe timing.
6719        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                    // The diagnosis is about the file, not the element, so the
6744                    // media type is reported rather than `source`.
6745                    kind: MediaKind::Video,
6746                    reason,
6747                    remedy: compat.remedy(),
6748                    // Heuristic hint, never a verdict — see the variant docs.
6749                    advisory: true,
6750                })
6751            })
6752            .collect()
6753    }
6754
6755    /// Build a JPEG image response.
6756    #[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    /// Build a WebVTT response.
6768    #[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    /// Build an HLS playlist response.
6780    #[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    /// Build an HLS segment response.
6793    #[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    /// Build a response for one part of the stream-copy (remux) variant.
6806    #[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    /// Serve one part of the remux variant from cache, or generate it exactly
6832    /// once and serve that.
6833    ///
6834    /// Wraps the single-flight state machine in `HlsCache` so N concurrent
6835    /// requests for the same key run one generation: the winner produces and the
6836    /// rest await its result. That, plus the caching, is what bounds the
6837    /// concurrent ffmpeg work a player can provoke.
6838    ///
6839    /// The generation is detached (see `HlsCache::spawn_generation`), so a client
6840    /// that disconnects mid-request — which Safari's HLS loader does routinely —
6841    /// cannot cancel the work or skip the cache bookkeeping. Awaiting the returned
6842    /// `JoinHandle` is how this request reads the result; dropping it merely stops
6843    /// listening.
6844    ///
6845    /// Every failure carries a status: an empty body or a bare 404 would leave a
6846    /// player retrying or stalled with nothing to report.
6847    #[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                    // The detached task itself failed to finish; its guard has
6865                    // released the claim, so a retry can succeed.
6866                    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                    // Not complete: either the producer recorded a failure, or
6881                    // the wait timed out / the claim was released. Distinguish
6882                    // them so the client is told something true.
6883                    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            // With caching off there is no entry to settle, but the work is still
6892            // detached so a disconnect cannot orphan a half-finished mux.
6893            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    /// Explain why a waiter never saw completed content.
6911    ///
6912    /// A `Failed` entry means the producer got a real error worth reporting; the
6913    /// alternative (timed out, or the claim was released by an interrupted
6914    /// producer) is transient and retryable, so it must not be reported as a
6915    /// permanent failure.
6916    #[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    /// A permanent "this variant cannot be produced" answer.
6932    #[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    /// A transient "try again" answer.
6943    ///
6944    /// `503` with `Retry-After: 1` rather than a 404, because the content may
6945    /// well exist on the next attempt and a player should be told to come back
6946    /// rather than treat the segment as missing.
6947    #[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    /// Map a remux failure to an honest status code.
6959    ///
6960    /// An empty playlist or a silent 404 would make a player hang or retry
6961    /// forever, so every failure gets a status and a reason.
6962    #[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            // Nothing to copy, or nothing a player could decode.
6970            TranscodeError::NoVideoStream { .. }
6971            | TranscodeError::NoKeyframeIndex
6972            | TranscodeError::UnsupportedFormat => StatusCode::UNPROCESSABLE_ENTITY,
6973            // The client asked for a segment that does not exist.
6974            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    /// Try to serve the stream-copy (remux) HLS variant.
6988    ///
6989    /// Reachable **without** `--transcode`: a remux is a stream copy costing a
6990    /// tiny fraction of the 720p/480p re-encode, and it exists to recover a
6991    /// video the browser has already failed to play, so it does not warrant the
6992    /// same opt-in. Still server/GUI only — static builds never register this
6993    /// route.
6994    ///
6995    /// Returns `Some(Response)` when the path was a remux URL, `None` to fall
6996    /// through to the remaining handlers.
6997    #[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        // Path-traversal protection, and the source of the mtime the cache key
7009        // is scoped to.
7010        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        // Segment URIs in the playlist are relative to the playlist URL, so the
7024        // base name keeps the video's own extension.
7025        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    /// Probes a video's resolution, memoized by path+mtime.
7051    ///
7052    /// The underlying `probe_video_resolution` demuxes the file, which is
7053    /// blocking work, so on a cache miss it runs on a blocking thread. The
7054    /// result is cached keyed by the file's mtime, so an unchanged file is never
7055    /// re-demuxed and an edited file is transparently re-probed (finding #17).
7056    #[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    /// Try to serve HLS content (playlist or segment) for transcoded video variants.
7083    ///
7084    /// Returns Some(Response) if the request was for HLS content and we
7085    /// successfully served it, None otherwise (fall through to other handlers).
7086    #[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        // Helper to build error response for transcode errors
7097        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                // For other errors, fall through to 404 (return None)
7124                _ => None,
7125            }
7126        }
7127
7128        // Check if this is an HLS request
7129        let hls_request = parse_hls_request(path)?;
7130
7131        // Extract the video path and target from the request
7132        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        // Resolve the original video file path with path traversal protection
7142        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        // Fast path (finding #17): if the requested playlist/segment is already
7153        // cached, serve it without probing the source. The blocking ffmpeg
7154        // demux in `probe_video_resolution` must not run on cache hits.
7155        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        // Cache miss: probe the source to decide whether transcoding applies.
7170        // The probe demuxes the file (blocking), so it runs off the async worker
7171        // and its result is cached by path+mtime so repeated misses for an
7172        // unchanged file never re-demux (finding #17 + #16).
7173        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 422 instead of None (404) with helpful message
7182            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                // Generate or serve cached playlist
7197                let cache_key = HlsCacheKey::playlist(video_file.clone(), target);
7198
7199                // Extract base name for playlist URLs
7200                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                        // Guard the claimed slot: if this future is dropped at
7210                        // the await below (client disconnect) or the worker
7211                        // panics, the guard records a failure so the key is not
7212                        // stuck `InProgress` forever.
7213                        let mut slot = HlsGenerationSlot::new(
7214                            &config.hls_cache,
7215                            cache_key.clone(),
7216                            Arc::clone(&notify),
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                                // Return meaningful error response for known error types
7246                                if let Some(response) = build_transcode_error_response(&e) {
7247                                    return Some(response);
7248                                }
7249                                return None;
7250                            }
7251                            Err(e) => {
7252                                // `slot` releases the wedged entry on the way out.
7253                                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 422 with cached error message instead of None (404)
7276                        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                        // Generate without caching
7285                        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                                // Return meaningful error response for known error types
7301                                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                // Generate or serve cached segment
7316                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                        // See the playlist arm: the guard frees the claimed
7328                        // slot if this future is cancelled mid-transcode or the
7329                        // worker panics.
7330                        let mut slot = HlsGenerationSlot::new(
7331                            &config.hls_cache,
7332                            cache_key.clone(),
7333                            Arc::clone(&notify),
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                                // Return meaningful error response for known error types
7362                                if let Some(response) = build_transcode_error_response(&e) {
7363                                    return Some(response);
7364                                }
7365                                return None;
7366                            }
7367                            Err(e) => {
7368                                // `slot` releases the wedged entry on the way out.
7369                                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 422 with cached error message instead of None (404)
7392                        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                        // Transcode without caching (not recommended for segments)
7401                        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                                // Return meaningful error response for known error types
7414                                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    /// `edit_enabled` is the per-request value from [`Self::page_edit_enabled`],
7433    /// not `config.edit_enabled`.
7434    /// The modification time (Unix seconds) of the file behind an index key,
7435    /// stat'ed through the vault on a blocking thread.
7436    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        // Determine if this is an index file (which doesn't need ../ prefix for links)
7457        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            // The page a reader is actually looking at: extension-less
7473            // markdown targets must come out with the trailing slash their
7474            // canonical URL has, or every relative link on the page they lead
7475            // to resolves one directory too high.
7476            markdown_page_probe: Some(mbr_core::link_transform::filesystem_markdown_page_probe(
7477                owned_resolver_config(config),
7478            )),
7479        };
7480
7481        // Transcoding is only available with media-metadata feature
7482        #[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        // Read here rather than inside the render so the page can carry the
7489        // fingerprint of exactly the bytes it was rendered from: every line
7490        // number on it (`data-mbr-line`, `mbr-task-N`) is relative to them.
7491        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                // The one place the feature is ever on: the real page render.
7511                // Editing alone still numbers `<dt>`s, for flashcard reviews.
7512                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        // Use relative path for markdown_source so live reload can match it.
7539        // `path_to_url` keeps it `/`-separated: the editor splits this value on
7540        // `/` to build the raw/save URLs and to derive the note's folder for
7541        // image uploads, so a Windows `docs\guide.md` would collapse to a single
7542        // segment and drop uploads into the repo root.
7543        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        // The version of `markdown_source` this page shows. In-place writes
7550        // from the page send it back as `base_hash`, so a note changed
7551        // elsewhere since the render answers 409 instead of having a line the
7552        // page never saw patched (`task-toggle.ts`).
7553        frontmatter.insert("markdown_source_hash".into(), source.hash.into());
7554        // Indicate server mode for frontend search functionality
7555        frontmatter.insert("server_mode".into(), "true".into());
7556        // Indicate GUI mode for native window detection
7557        frontmatter.insert(
7558            "gui_mode".into(),
7559            if config.gui_mode { "true" } else { "" }.into(),
7560        );
7561
7562        // Compute breadcrumbs based on the URL path, not the file path
7563        // For a file like docs/guide.md, the URL is /docs/guide/ so breadcrumbs should include docs
7564        let url_path_buf = if is_index_file {
7565            // index.md -> use parent directory path
7566            // e.g., docs/index.md -> /docs/ -> breadcrumbs path is "docs"
7567            relative_md_path
7568                .parent()
7569                .unwrap_or(Path::new(""))
7570                .to_path_buf()
7571        } else {
7572            // regular.md -> use parent + file stem
7573            // e.g., docs/guide.md -> /docs/guide/ -> breadcrumbs path is "docs/guide"
7574            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        // This page's canonical URL. Built with `path_to_url` rather than
7583        // `display()`: on Windows the latter yields `/docs\guide/`, which never
7584        // matches the `/docs/guide/` key the links.json request handler looks
7585        // up, making the link cache a permanent miss. (The `replace` collapses
7586        // the `//` produced when the path is empty, i.e. the root index.)
7587        let current_url =
7588            format!("/{}/", mbr_core::url_path::path_to_url(&url_path_buf)).replace("//", "/");
7589
7590        // Cache outbound links for links.json endpoint if link tracking is
7591        // enabled. An empty list is cached too: skipping the insert would leave
7592        // a page that just lost its last link serving its stale pre-edit entry
7593        // (`LinkCache` has no TTL and nothing else overwrites it).
7594        if config.link_tracking {
7595            // Resolve relative URLs to absolute before caching
7596            let resolved_links =
7597                resolve_outbound_links(&current_url, outbound_links, is_index_file);
7598            config
7599                .link_cache
7600                .insert(current_url.clone(), resolved_links);
7601        }
7602
7603        // Get modified date from file metadata, through the vault on a
7604        // blocking thread (blocking fs work stays off the executor). Also the
7605        // page's Last-Modified below.
7606        let modified_secs = Self::modified_secs(config, md_path).await;
7607
7608        // Compute prev/next sibling pages for navigation (reuses `current_url`
7609        // computed above).
7610        let parent_dir = relative_md_path.parent().unwrap_or(Path::new(""));
7611
7612        // Get sibling markdown files in the same directory. The sorted list is
7613        // memoized per parent directory so we avoid an O(repo) scan on every
7614        // render; the cache is cleared whenever files change.
7615        let siblings: Arc<Vec<serde_json::Value>> = cached_dir_files(config, parent_dir);
7616
7617        // Build the extra context (navigation, TOC, readability, chrome) via
7618        // the shared builder; server mode uses absolute URLs.
7619        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: &current_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        // Whether this page may edit (drives the edit button, task checkboxes and
7648        // flashcard reviews): off when the request's Host would be refused.
7649        // A template variable, not a frontmatter entry: frontmatter is
7650        // serialized into `window.frontmatter`, where the info panel lists every
7651        // key as note metadata ("Edit enabled: true"). The page reads the flag
7652        // from `window.__MBR_CONFIG__.editEnabled` (`_head.html`).
7653        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(&current_url)
7660            } else {
7661                Vec::new()
7662            };
7663            // A hash hit once the inbound index is built; until then (and with
7664            // link tracking off) the card falls back to the lazy chip.
7665            let backlinks = (config.link_tracking && config.inbound_index.is_ready())
7666                .then(|| config.inbound_index.source_count(&current_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        // Generate ETag from rendered content
7683        let etag = generate_etag(full_html_output.as_bytes());
7684
7685        // Last-Modified from the same stat as `modified_secs`.
7686        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    /// Scans `dir_path` from disk to produce a directory listing's files and
7704    /// subdirectories.
7705    ///
7706    /// Only used before the background scan finishes — see
7707    /// [`Self::directory_to_html`]. Scanning is blocking filesystem work (and
7708    /// re-parses the YAML frontmatter of every file in the directory), so it
7709    /// runs on a blocking thread. All captured data is owned/`Send`.
7710    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            // Create a temporary repo instance, over the server's own vault, to
7731            // scan this directory
7732            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            // Scan this directory only (non-recursive). `dir_path` is the
7745            // resolver's key, so its vault path needs no relativizing against a
7746            // possibly non-canonical configured root.
7747            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            // Extract markdown files and transform to JSON using helper
7756            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 using configurable sort order
7764            sort_files(&mut files, &sort);
7765
7766            // Extract subdirectories. This scan is non-recursive, so a
7767            // subfolder's index note is not in `temp_repo`; its frontmatter
7768            // (for the sort title) is read straight from disk — one 8 KB read
7769            // per subfolder, on the pre-scan path only.
7770            let mut subdirs: Vec<serde_json::Value> = temp_repo
7771                .queued_folders
7772                .pin()
7773                .iter()
7774                .filter_map(|(abs_path, queued)| {
7775                    // Only include immediate children
7776                    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    /// `edit_enabled` is the per-request value from [`Self::page_edit_enabled`]:
7822    /// a listing carries `<mbr-editor>` too (its "New note" entry point), so it
7823    /// must advertise editing exactly when a markdown page here would.
7824    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        // Calculate relative path from root
7834        let relative_path = pathdiff::diff_paths(dir_path, root_path)
7835            .unwrap_or_else(|| std::path::PathBuf::from("."));
7836
7837        // Serve the listing from the in-memory repository index, memoized per
7838        // directory. The previous implementation built a throwaway `Repo` and
7839        // re-parsed the YAML frontmatter of every file in the directory on
7840        // *every* request — data already resident in `repo.markdown_files`.
7841        // While the initial scan is still running that index is incomplete, so
7842        // a per-request disk scan remains the fallback.
7843        // The memoized lists are shared between requests, so this page takes its
7844        // own copy to hand to the template engine.
7845        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        // Use helper functions for navigation elements
7858        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        // Build context
7865        let mut context = std::collections::HashMap::new();
7866        // `Value::Array`, not `json!`: the lists are already `Value`s, and the
7867        // macro would deep-clone every entry a second time.
7868        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        // Add full config to template context
7881        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        // Pass tag_sources configuration for frontend (consistent with markdown pages)
7892        context.insert(
7893            "tag_sources".to_string(),
7894            json!(page_context::tag_sources_json(&config.tag_sources)),
7895        );
7896
7897        // Mode flags, sidebar navigation configuration, and title affixes
7898        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        // Detect if we're at the root directory
7917        let is_root =
7918            relative_path.as_os_str().is_empty() || relative_path == std::path::Path::new(".");
7919
7920        // Add is_home to context for template conditional rendering
7921        context.insert("is_home".to_string(), json!(is_root));
7922        // Drives `window.__MBR_CONFIG__.editEnabled` and the nav's
7923        // `<mbr-editor>`, which on a listing offers "New note" here. Off when the
7924        // request's Host would be refused, as on a markdown page.
7925        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        // Generate ETag from rendered content
7940        let etag = generate_etag(full_html_output.as_bytes());
7941
7942        // Directory listings are dynamic - use no-store to always fetch fresh
7943        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    /// Renders a tag page showing all pages with a specific tag value.
7953    async fn tag_page_to_html(
7954        source: &str,
7955        value: &str,
7956        config: &ServerState,
7957    ) -> Result<Response<Body>, MbrError> {
7958        // Find the TagSource config to get labels (fallback: capitalized source name)
7959        let (label, label_plural) =
7960            page_context::tag_labels(&config.tag_sources, source, &capitalize_first(source));
7961
7962        // Get pages with this tag from the index
7963        let pages = config.repo.tag_index.get_pages(source, value);
7964
7965        // Get display value for the tag
7966        let display_value = config
7967            .repo
7968            .tag_index
7969            .get_tag_display(source, value)
7970            .unwrap_or_else(|| value.to_string());
7971
7972        // Build template context
7973        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    /// Renders a tag source index showing all tags from a source.
8015    async fn tag_source_index_to_html(
8016        source: &str,
8017        config: &ServerState,
8018    ) -> Result<Response<Body>, MbrError> {
8019        // Find the TagSource config to get labels (fallback: capitalized source name)
8020        let (label, label_plural) =
8021            page_context::tag_labels(&config.tag_sources, source, &capitalize_first(source));
8022
8023        // Get all tags for this source
8024        let tags = config.repo.tag_index.get_all_tags(source);
8025
8026        // Build template context
8027        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    /// Handler for the root path "/" - renders the home page using the same
8061    /// logic as other directories but with the home.html template.
8062    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        // Resolve empty path (root)
8072        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
8124// ============================================================================
8125// Pure helper functions for directory listing (extracted for testability)
8126// ============================================================================
8127
8128/// Decides whether a watcher file event should trigger a template reload.
8129///
8130/// `template_folder` must already be canonicalized by the caller. `notify`
8131/// reports canonical absolute paths, but the configured template folder is
8132/// stored as written: only the CLI canonicalizes it, while `.mbr/config.toml`
8133/// and `MBR_TEMPLATE_FOLDER` deserialize verbatim into a plain `PathBuf`. A
8134/// relative value (`mytemplates`) or one reached through a symlink therefore
8135/// never matches a canonical event path, which silently disables template hot
8136/// reload. Canonicalization belongs in the caller so this stays pure and off
8137/// the per-event hot path — the folder cannot change during the process.
8138///
8139/// Comparison is component-wise via [`Path::starts_with`], not a string prefix
8140/// test: `"/x/tmpl-backup/index.html"` textually starts with `"/x/tmpl"` but is
8141/// a sibling directory, not a template.
8142/// Reloads `templates`, logging a failure: the watcher's template task and
8143/// [`Server::reload_templates`] share it. `false` when the reload failed.
8144fn 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        // Match `.mbr` as a path component rather than substring
8158        // matching "/.mbr/". The watcher reports native separators,
8159        // so on Windows this string is `...\.mbr\theme.css` and the
8160        // slash form would never match, silently disabling template
8161        // hot reload.
8162        None => Path::new(event_path)
8163            .components()
8164            .any(|c| c.as_os_str() == ".mbr"),
8165    }
8166}
8167
8168/// Builds the sorted list of sibling markdown files that share `parent_dir`.
8169///
8170/// This is the pure core of prev/next navigation: it filters the provided
8171/// markdown-file infos to those whose parent directory equals `parent_dir`,
8172/// converts each to its template JSON form, and sorts using `sort`. Extracted
8173/// so it can be memoized per directory (see `sibling_nav_cache`) and unit
8174/// tested independently of the live repository.
8175fn 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
8190/// Normalizes a repo-relative directory path into the key used by the listing
8191/// caches and by `MarkdownInfo::raw_path.parent()`.
8192///
8193/// `pathdiff` yields an empty path for the repo root, but the callers that go
8194/// through `home_page`'s fallback branch can produce `.`; both must map to the
8195/// same key, and it must be the empty path because that is what
8196/// `Path::new("root.md").parent()` returns.
8197fn 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
8205/// Returns the immediate subdirectory name of `dir` on the way to `file_path`,
8206/// or `None` if `file_path` is not below a subdirectory of `dir`.
8207///
8208/// Both paths are repo-relative. A direct child *file* of `dir` yields `None`
8209/// (its first remaining component is the file itself, not a directory).
8210fn 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    // There must be at least one more component (the file name), otherwise
8219    // `first` is the file, not a directory.
8220    components.next()?;
8221    match first {
8222        std::path::Component::Normal(name) => Some(name),
8223        _ => None,
8224    }
8225}
8226
8227/// Builds the deduplicated list of immediate subdirectories of `dir` from the
8228/// repo-relative paths of every indexed file, in sidebar order (see
8229/// [`mbr_core::sorting::sort_folders`]; `folder_frontmatter` supplies each
8230/// folder's index-note title, keyed by folder URL).
8231///
8232/// Derived from the file index rather than from a disk walk so it can be
8233/// memoized and refreshed by the same invalidation that refreshes the file
8234/// lists. Like the static builder's section pages
8235/// (`build::build_dir_children_index`), a directory that contains no files at
8236/// any depth is not listed.
8237fn 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
8258/// The index-note frontmatter of `dir`'s immediate subfolders, keyed by
8259/// folder URL, for [`compute_subdir_entries`].
8260fn 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
8271/// Returns the sorted markdown-file list for `dir`, memoized per directory.
8272///
8273/// Shared by prev/next sibling navigation and by directory listings: both need
8274/// exactly "the files whose parent directory is `dir`, in sort order". Only
8275/// memoized once the initial scan is complete, so a partially populated result
8276/// is never frozen into the cache.
8277fn 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
8300/// Returns the immediate subdirectories of `dir`, memoized per directory.
8301///
8302/// Non-markdown files are keyed by absolute path in the repo index, so they
8303/// are relativized against `root_path` here; anything that does not sit under
8304/// the root is skipped rather than emitting a `../` entry.
8305fn 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
8342// ============================================================================
8343// Cache header helpers (extracted for testability and reuse)
8344// ============================================================================
8345
8346/// Capitalizes the first character of a string, leaving the remainder intact.
8347///
8348/// UTF-8 safe: uses character boundaries rather than byte indexing, so a
8349/// leading multi-byte character (e.g. "étiquettes") does not panic.
8350fn 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
8358/// Finalizes an HTTP response builder, falling back to a plain 500 response
8359/// if the builder was misconfigured (e.g. an invalid header value).
8360///
8361/// Our builders use fixed, known-valid headers, so the fallback is effectively
8362/// unreachable — but a malformed value must degrade to a 500, never panic the
8363/// request handler.
8364fn 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
8373/// Builds the `301 Moved Permanently` that sends a non-canonical page URL to
8374/// its canonical, trailing-slash form.
8375///
8376/// The query string is carried over verbatim, because it is part of the
8377/// request the client made and dropping it silently changes what the target
8378/// page renders. The **fragment** is deliberately *not* carried: fragments
8379/// never leave the browser, and RFC 9110 §10.2.2 says a client applies the
8380/// original request's fragment to a `Location` that has none — so emitting no
8381/// fragment is exactly how `#section` survives the hop.
8382fn 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
8396/// Generates a weak ETag from content bytes using a simple hash.
8397/// Weak ETags (W/"...") indicate semantic equivalence, not byte-for-byte identity.
8398fn 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
8408/// Generates a Last-Modified header value from a Unix timestamp.
8409fn 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
8414/// Standard cache control header value for development mode.
8415/// `no-cache` allows the browser to cache but requires revalidation on every request.
8416const CACHE_CONTROL_NO_CACHE: &str = "no-cache";
8417
8418/// Standard cache control header for truly dynamic content that shouldn't be cached.
8419const CACHE_CONTROL_NO_STORE: &str = "no-store";
8420
8421// ============================================================================
8422// Tag page link helpers
8423// ============================================================================
8424
8425/// Builds outbound links for a tag page (e.g., /tags/rust/).
8426///
8427/// Returns links to all pages tagged with this tag, plus a link back to the tag source index.
8428fn 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    // Add links to all tagged pages
8439    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    // Add link back to tag source index
8449    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
8465/// Builds outbound links for a tag source index page (e.g., /tags/).
8466///
8467/// Returns links to all individual tag pages under this source.
8468fn 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        // `MbrError::Http` is type-erased so `errors.rs` does not depend on
8494        // axum; the conversion here must keep the message and the source.
8495        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        // A leading multi-byte character must not panic (regression for
8519        // byte-slicing `&source[1..]`).
8520        assert_eq!(capitalize_first("étiquettes"), "Étiquettes");
8521        assert_eq!(capitalize_first("über"), "Über");
8522        // Non-Latin scripts have no uppercase form; content is preserved.
8523        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        // `str::starts_with` matches this sibling directory; `Path::starts_with`
8544        // compares whole components and does not.
8545        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        // Substring-only matches are not path components.
8564        assert!(!should_reload_template("/repo/.mbrx/index.html", None));
8565    }
8566
8567    /// The caller canonicalizes the template folder before handing it to
8568    /// `should_reload_template`; without that step a symlinked folder never
8569    /// matches the canonical paths the watcher reports.
8570    #[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        // What the watcher reports: a canonical path under the real directory.
8580        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        // Configured as the symlink, used verbatim: no match.
8587        assert!(!should_reload_template(&event_path, Some(link.as_path())));
8588
8589        // Canonicalized once by the caller, as `Server::init` does: match.
8590        let canonical = link.canonicalize().unwrap_or(link);
8591        assert!(should_reload_template(&event_path, Some(&canonical)));
8592    }
8593
8594    // ==================== validate_media_path Tests ====================
8595
8596    #[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        // An iOS third-party provider's container is anonymous.
8627        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        // URL-encoded ".." = "%2e%2e"
8645        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        // URL-encoded space = "%20"
8685        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    // ==================== validate_media_path External Static Folder Tests ====================
8704
8705    #[test]
8706    fn test_validate_media_path_external_static_folder_works() {
8707        // Create parent directory with content and static subdirs
8708        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        // Create a video file in the external static folder
8715        let video_file = static_dir.join("videos").join("test.mp4");
8716        std::fs::write(&video_file, "video content").unwrap();
8717
8718        // static_folder = "../static" relative to content_dir
8719        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        // Create parent directory with content and static subdirs
8727        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        // Create the same file in both locations
8734        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        // Content root should take precedence
8740        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        // Create parent directory with content and static subdirs
8748        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        // Even with an external static folder, path traversal should be rejected
8755        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        // Create a single directory
8762        let temp_dir = tempfile::tempdir().unwrap();
8763
8764        // With empty static_folder, only content root is checked
8765        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        // Create parent directory with content and static subdirs
8772        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        // Create a video file in nested directory
8780        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        // Create content directory only
8795        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        // Static folder doesn't exist - should fail gracefully
8800        let result = validate_media_path("videos/test.mp4", &content_dir, "../nonexistent");
8801        assert!(matches!(result, Err(MbrError::InvalidMediaPath(_))));
8802    }
8803
8804    // ==================== resolve_media_source_file Tests ====================
8805
8806    #[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        // Secret file OUTSIDE base - unvalidated join + is_file() would have found it
8813        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    // ==================== safe_join_asset Tests ====================
8847
8848    #[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        // Various path traversal attempts
8875        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        // Create a symlink pointing outside the base directory
8931        let link_path = temp_dir.path().join("escape");
8932        if symlink("/tmp", &link_path).is_ok() {
8933            // Try to access a file through the symlink
8934            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    /// The memoizable sibling helper (finding #18) must return exactly what the
8964    /// previous inline full-scan produced for the same directory and sort.
8965    #[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        // Reference implementation of the prior full-scan behavior.
8979        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        // Sorted by title ascending regardless of input order.
8991        assert_eq!(got[0]["title"], "Alpha");
8992        assert_eq!(got[1]["title"], "Beta");
8993    }
8994
8995    /// A directory with no markdown children yields an empty sibling list.
8996    #[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    /// A move must not rewrite `[[Mare]]` when `Mare` is still one of the
9004    /// note's names — including when it is a *labeled* alias, which the
9005    /// simplified frontmatter now carries as a plain name.
9006    #[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    // ===== directory listings served from the in-memory index =====
9023
9024    /// Only a file that lives *below* a subdirectory of `dir` contributes a
9025    /// subdirectory name; a direct child file contributes nothing.
9026    #[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        // Not below `dir` at all.
9041        assert_eq!(
9042            immediate_subdir_name(Path::new("other/x.md"), Path::new("docs")),
9043            None
9044        );
9045        // A direct child file of a non-root directory.
9046        assert_eq!(
9047            immediate_subdir_name(Path::new("docs/guide.md"), Path::new("docs")),
9048            None
9049        );
9050    }
9051
9052    /// Subdirectory entries are deduplicated and name-sorted, and their URLs
9053    /// are `/`-separated regardless of the host separator.
9054    #[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    /// `.` and the empty path both name the repository root, because that is
9077    /// the key `raw_path.parent()` produces for a root-level file.
9078    #[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    /// Builds a small on-disk fixture and returns its canonical root.
9086    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    /// The backlink index must report exactly what the full-repository grep it
9113    /// replaces reported.
9114    ///
9115    /// These are two independent implementations of the same question — the
9116    /// grep matches path spellings inside each file, the index inverts the
9117    /// renderer's own resolved links — and the server answered `links.json`
9118    /// with the grep until this index landed. Any disagreement is a
9119    /// user-visible change in the info panel and the mini graph, so it has to
9120    /// be asserted rather than assumed.
9121    #[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        // A page nobody links to: both implementations must report empty.
9143        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        // Sanity-check the fixture actually exercises the comparison, so a
9184        // future edit that silently stops producing backlinks still fails.
9185        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    /// The in-memory listing must reproduce what the per-request disk scan
9194    /// produced: same files (order included) and same subdirectories (the old
9195    /// implementation read them out of a concurrent map, so order was
9196    /// arbitrary — compare as sets).
9197    #[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            // Reference: the previous per-request implementation.
9208            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            // New: served straight from the repository index.
9234            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    /// A directory holding only assets (no markdown at any depth) still shows
9276    /// up in its parent's listing, exactly as the disk scan reported it.
9277    #[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    // ===== site.json payload =====
9310
9311    /// The site.json body must contain the markdown index and the sort/sidebar
9312    /// config, and must never materialize the media catalog (`other_files`),
9313    /// which is served by `/.mbr/media.json`.
9314    #[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, &params).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        // Deterministic: same snapshot, byte-identical body.
9349        let again = render_site_json(&repo, &params).expect("site.json renders");
9350        assert_eq!(bytes, again);
9351    }
9352
9353    /// Finding #20: N concurrent requests for the same (path, type) must trigger
9354    /// exactly one decode; every other request awaits and reads the cached
9355    /// result rather than starting its own decode.
9356    #[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        // The producer claims the slot first (as the request handler would).
9370        let producer_notify = match claim_inflight(&inflight, &key) {
9371            InflightClaim::Produce(notify) => notify,
9372            InflightClaim::Wait(_) => panic!("first claim must produce"),
9373        };
9374
9375        // Spawn several concurrent "requests" that all find the slot occupied.
9376        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                        // A waiter must never become a second producer.
9386                        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        // Give the waiters a moment to register interest.
9405        tokio::time::sleep(Duration::from_millis(50)).await;
9406
9407        // The single producer performs exactly one decode and publishes it.
9408        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    // ===== single-flight slot release (cancellation safety) =====
9424
9425    /// A producer whose future is dropped mid-flight (client disconnect) must
9426    /// release its single-flight slot, so the next request becomes the new
9427    /// producer instead of waiting on a claim nobody will ever settle.
9428    #[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                // Stands in for the `spawn_blocking` await the handler is
9443                // parked on when the client goes away.
9444                std::future::pending::<()>().await;
9445            };
9446            // `timeout` polls the inner future once, then drops it — exactly
9447            // what axum does to a handler whose connection died.
9448            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    /// Waiters parked on a cancelled producer's notify are woken rather than
9466    /// left to burn the full wait timeout.
9467    #[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    /// `claim_inflight` must admit exactly one producer per key even when many
9497    /// threads claim it at the same instant: `pin()` is only an epoch guard, so
9498    /// a `get`-then-`insert` pair can hand `Produce` to two callers.
9499    #[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                        // All threads race on the same key at the same moment.
9521                        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    /// An abandoned HLS generation must not leave the key stuck `InProgress`:
9538    /// that state has no TTL and is never evicted, so every later request would
9539    /// block for `HLS_WAIT_TIMEOUT` and then 404 until restart.
9540    #[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        // The slot is settled (not `InProgress`), so the next request gets an
9571        // immediate answer instead of waiting on a generation nobody is running.
9572        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    // ===== live reload broadcast handling =====
9582
9583    /// A lagging live-reload client must keep receiving events. Consuming the
9584    /// broadcast with a refutable `Ok(..)` pattern inside `select!` disables the
9585    /// branch permanently on the first `Lagged`, silently killing live reload
9586    /// for that tab.
9587    #[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        // Overflow the channel so the receiver falls behind.
9599        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        // Everything still queued, and everything sent afterwards, is forwarded.
9609        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    /// When the sender is dropped (server shutting down) the loop closes.
9623    #[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    // ===== derived cache invalidation =====
9634
9635    /// A file change must drop the link caches too, not just sibling nav:
9636    /// `LinkCache` has no TTL, so a survivor serves pre-edit links forever.
9637    /// The directory-listing subdirectories and the serialized site.json body
9638    /// are derived from the same data and must go with them.
9639    #[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    /// A site.json rebuild that started before a file change must not publish
9703    /// its pre-change snapshot afterwards: the invalidation already ran, so the
9704    /// stale body would be served until the *next* change.
9705    #[test]
9706    fn test_site_json_cache_rejects_store_from_a_raced_rebuild() {
9707        let cache = parking_lot::RwLock::new(SiteJsonCache::default());
9708
9709        // A request finds the slot empty and starts rebuilding.
9710        let (generation, cached) = cache.read().snapshot();
9711        assert!(cached.is_none());
9712
9713        // A file change lands while that rebuild is in flight.
9714        cache.write().invalidate();
9715
9716        // The rebuild finishes and tries to publish its stale snapshot.
9717        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        // A rebuild started after the change publishes normally.
9726        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    /// The media metadata cache must not inherit the oembed *text* cache
9734    /// budget: disabling link previews with `--oembed-cache-size 0` used to
9735    /// disable cover/chapter/caption caching along with it.
9736    #[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        // The cache built from that budget still caches a cover payload.
9750        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        // And the default budget holds many covers, not a couple of dozen.
9759        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    /// `announce_listening` routes the startup banner on `Server.gui_mode`:
9774    /// stdout in server mode, `tracing::info!` in GUI mode. That only works if
9775    /// `init` actually carries the flag off `ServerConfig` onto `Server` — if
9776    /// it were dropped and defaulted to `false`, the GUI would silently go back
9777    /// to printing to the console window it is trying to stay out of, and every
9778    /// existing test would still pass.
9779    #[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    /// Call-site smoke test for the metadata cache path: a cached cover produces
9801    /// a JPEG response, and a negative marker falls through to `None` (404).
9802    #[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    // ===== resolve_new_target_path (file-management path safety) =====
9822
9823    #[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        // Nothing was written outside the root.
9839        assert!(!base.join("../escape.md").exists());
9840    }
9841
9842    /// The same refusal for `..` spelled with backslashes, which `VaultPath`
9843    /// treats as separators on every platform.
9844    #[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    /// A symlink in the existing part of the path that leads out of the
9860    /// repository is a traversal, even though the target does not exist yet.
9861    #[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    /// Through a vault with no filesystem: valid targets resolve under the
9876    /// root, escapes are refused.
9877    #[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        // A leading slash is treated as repo-root-relative, never an escape.
9914        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    // ===== sanitize_upload_name (upload filename validation) =====
9922
9923    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        // Surrounding whitespace is trimmed.
9934        assert_eq!(
9935            sanitize_upload_name("  pic.jpeg  ", &md_exts()).as_deref(),
9936            Some("pic.jpeg")
9937        );
9938        // Multiple dots: only the final segment is the extension.
9939        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        // Executable/site-controlling types must never be uploadable: these are
9948        // what turned the media uploader into a page-hijacking primitive.
9949        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        // Media stays uploadable, extension case-insensitively.
9965        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        // No extension at all.
9987        assert_eq!(sanitize_upload_name("noext", &md_exts()), None);
9988        // Dotfile with no other extension → no extension.
9989        assert_eq!(sanitize_upload_name(".png", &md_exts()), None);
9990        // Trailing dot → empty extension.
9991        assert_eq!(sanitize_upload_name("pic.", &md_exts()), None);
9992        // Empty / whitespace-only.
9993        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        // Markdown files must be created via /.mbr/create, not uploaded.
10000        assert_eq!(sanitize_upload_name("note.md", &md_exts()), None);
10001        assert_eq!(sanitize_upload_name("note.markdown", &md_exts()), None);
10002        // Case-insensitive.
10003        assert_eq!(sanitize_upload_name("note.MD", &md_exts()), None);
10004        // A non-markdown extension is still accepted.
10005        assert_eq!(
10006            sanitize_upload_name("note.pdf", &md_exts()).as_deref(),
10007            Some("note.pdf")
10008        );
10009    }
10010
10011    // ===== Host header validation (DNS-rebinding defense) =====
10012
10013    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        // A non-loopback bind address is allowed under its own IP literal.
10049        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        // A DNS-rebinding host resolves to 127.0.0.1 but must not be trusted,
10059        // and neither may a name that merely embeds an allowed one.
10060        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        // A missing Host header is rejected (HTTP/1.1 requires one).
10073        assert!(!host_header_is_allowed(&HeaderMap::new(), loopback));
10074    }
10075
10076    // ===== /.mbr asset allowlist =====
10077
10078    #[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        // config.toml carries the Argon2 edit_token_hash.
10095        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    // ===== upload destination guard =====
10109
10110    #[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        // Targets inside .mbr/ (which do not exist yet) are rejected.
10119        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        // So are targets inside an explicit --template-folder.
10126        assert!(is_template_folder_path(
10127            &template_folder.join("index.html"),
10128            &base,
10129            Some(&base),
10130            Some(&template_folder)
10131        ));
10132        // Ordinary note folders are unaffected.
10133        assert!(!is_template_folder_path(
10134            &base.join("notes/pic.png"),
10135            &base,
10136            Some(&base),
10137            Some(&template_folder)
10138        ));
10139        // A folder that merely starts with the same characters is not `.mbr`.
10140        assert!(!is_template_folder_path(
10141            &base.join(".mbrx/pic.png"),
10142            &base,
10143            Some(&base),
10144            None
10145        ));
10146    }
10147
10148    // ===== served-path containment (symlink escape defense in depth) =====
10149
10150    #[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        // The static_folder overlay may legitimately live outside the root.
10178        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        // A path that is lexically inside the repo but canonicalizes outside it
10197        // (a symlink) must be rejected.
10198        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        // A path that does not exist at all is never servable.
10214        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    /// The path-based serving checks — the media viewers' `?path=` and the
10224    /// serving gate — accept a file reached through an accepted mount and
10225    /// refuse hidden files anywhere.
10226    #[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    // ===== dedupe_name (collision suffixing) =====
10279
10280    #[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    // --- Compression predicate --------------------------------------------
10310
10311    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    /// Builds a response big enough to clear tower-http's 32-byte `SizeAbove`
10318    /// floor, so the content-type rule is what decides the outcome.
10319    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        // Regression: gzipping video makes tower-http drop `content-length`
10331        // and `accept-ranges`, which breaks seeking and duration in WebKit.
10332        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        // DefaultPredicate exclusions must survive the composition.
10380        assert!(!predicate.should_compress(&response_with_content_type("image/png")));
10381        assert!(!predicate.should_compress(&response_with_content_type("text/event-stream")));
10382        // ...and normal text still compresses.
10383        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        // ==================== validate_media_path Property Tests ====================
10394
10395        /// Any path containing ".." should be rejected
10396        #[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            // Test various ".." patterns
10403            let test_paths = vec![
10404                format!("{}/../{}", prefix, suffix),
10405                format!("../{}/{}", prefix, suffix),
10406                format!("{}/{}/..", prefix, suffix),
10407                format!("{}%2F..%2F{}", prefix, suffix), // URL-encoded /
10408            ];
10409
10410            for path in test_paths {
10411                // Any path with ".." should be rejected as directory traversal
10412                // Note: URL-decoded path is what matters
10413                if path.contains("..") {
10414                    let result = validate_media_path(&path, temp_dir.path(), "");
10415                    // Path either doesn't exist or is rejected as traversal
10416                    prop_assert!(
10417                        result.is_err(),
10418                        "Path containing '..' should be rejected: {:?}",
10419                        path
10420                    );
10421                }
10422            }
10423        }
10424
10425        /// validate_media_path is deterministic - same input always gives same output
10426        #[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            // Both should be the same (both errors or both same Ok value)
10435            match (&result1, &result2) {
10436                (Ok(p1), Ok(p2)) => prop_assert_eq!(p1, p2),
10437                (Err(_), Err(_)) => (), // Both errors is fine
10438                _ => prop_assert!(false, "Results should be consistent: {:?} vs {:?}", result1, result2),
10439            }
10440        }
10441
10442        /// URL-encoded paths decode correctly
10443        #[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            // Create a test file
10450            let test_file = temp_dir.path().join(&filename);
10451            std::fs::write(&test_file, "test").unwrap();
10452
10453            // Test with URL-encoded path (spaces as %20)
10454            let encoded = format!("%20{}", filename); // Leading space encoded
10455            let result = validate_media_path(&encoded, temp_dir.path(), "");
10456
10457            // The decoded path " filename" doesn't exist, so should fail
10458            prop_assert!(result.is_err(), "Encoded path with non-existent target should fail");
10459
10460            // Test with the actual filename - should succeed
10461            let result = validate_media_path(&filename, temp_dir.path(), "");
10462            prop_assert!(result.is_ok(), "Valid path should succeed: {:?}", filename);
10463        }
10464
10465        /// Valid paths within repo root succeed
10466        #[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            // Create a test file
10473            let test_file = temp_dir.path().join(&filename);
10474            std::fs::write(&test_file, "test content").unwrap();
10475
10476            // Validate the path
10477            let result = validate_media_path(&filename, temp_dir.path(), "");
10478            prop_assert!(result.is_ok(), "Valid file path should succeed: {:?}", filename);
10479
10480            // Result should be the canonical path to the file
10481            if let Ok(canonical) = result {
10482                let expected_canonical = test_file.canonicalize().unwrap();
10483                prop_assert_eq!(canonical, expected_canonical);
10484            }
10485        }
10486
10487        /// Paths with leading slash are handled correctly
10488        #[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            // Create a test file
10495            let test_file = temp_dir.path().join(&filename);
10496            std::fs::write(&test_file, "test content").unwrap();
10497
10498            // Test with leading slash
10499            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            // Test without leading slash
10504            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            // Both should resolve to the same canonical path
10508            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}