Skip to main content

lanflare_core/server/
dufs.rs

1//! Dufs-alike static file and directory server for LanFlare peers on the local network.
2//!
3//! Provides:
4//! - Recursive directory browsing with breadcrumbs and fast search/filter
5//! - Streaming file downloads with HTTP Range support (`206 Partial Content`)
6//! - On-the-fly directory ZIP streaming (`?zip`) with bounded RAM and zero temporary files
7//! - Optional PIN protection and peer upload support
8//! - JSON directory listing API (`?json` or `Accept: application/json`)
9//! - Robust path validation with Android storage/symlink compatibility
10
11use std::io::Write;
12use std::net::{IpAddr, SocketAddr};
13use std::path::{Component, Path, PathBuf};
14use std::sync::Arc;
15use std::time::SystemTime;
16
17use bytes::Bytes;
18use futures_util::TryStreamExt;
19use http_body_util::combinators::BoxBody;
20use http_body_util::{BodyExt, Full};
21use hyper::body::{Frame, Incoming};
22use hyper::header::{
23    ACCEPT_RANGES, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE,
24    CONTENT_TYPE, RANGE, WWW_AUTHENTICATE,
25};
26use hyper::{Method, Request, Response, StatusCode};
27use serde::{Deserialize, Serialize};
28use tokio::io::AsyncSeekExt;
29use tokio::net::TcpListener;
30use tokio::sync::RwLock;
31use tokio_util::io::ReaderStream;
32use tokio_util::sync::CancellationToken;
33
34use crate::client::transfer::mime_guess_simple;
35
36/// Standard boxed HTTP response body for hyper v1.
37pub type ServerResponseBody = BoxBody<Bytes, Box<dyn std::error::Error + Send + Sync>>;
38
39/// Helper creating a boxed full body from bytes.
40pub fn full_box_body<B: Into<Bytes>>(bytes: B) -> ServerResponseBody {
41    Full::new(bytes.into())
42        .map_err(|never| -> Box<dyn std::error::Error + Send + Sync> { match never {} })
43        .boxed()
44}
45
46/// Helper converting a `Full<Bytes>` to `ServerResponseBody`.
47pub fn full_to_box_body(full: Full<Bytes>) -> ServerResponseBody {
48    full.map_err(|never| -> Box<dyn std::error::Error + Send + Sync> { match never {} })
49        .boxed()
50}
51
52/// Custom Body wrapper ensuring `Send + Sync + 'static` for boxed hyper v1 responses.
53pub struct SyncStreamBody<S>(pub std::sync::Mutex<S>);
54
55impl<S, T, E> hyper::body::Body for SyncStreamBody<S>
56where
57    S: futures_util::Stream<Item = Result<T, E>> + Unpin,
58    T: hyper::body::Buf,
59    E: Into<Box<dyn std::error::Error + Send + Sync>>,
60{
61    type Data = T;
62    type Error = Box<dyn std::error::Error + Send + Sync>;
63
64    fn poll_frame(
65        self: std::pin::Pin<&mut Self>,
66        cx: &mut std::task::Context<'_>,
67    ) -> std::task::Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
68        let mut guard = match self.0.lock() {
69            Ok(g) => g,
70            Err(poisoned) => poisoned.into_inner(),
71        };
72        match std::pin::Pin::new(&mut *guard).poll_next(cx) {
73            std::task::Poll::Ready(Some(Ok(data))) => {
74                std::task::Poll::Ready(Some(Ok(Frame::data(data))))
75            }
76            std::task::Poll::Ready(Some(Err(err))) => {
77                std::task::Poll::Ready(Some(Err(err.into())))
78            }
79            std::task::Poll::Ready(None) => std::task::Poll::Ready(None),
80            std::task::Poll::Pending => std::task::Poll::Pending,
81        }
82    }
83}
84
85/// Helper creating a boxed stream body.
86pub fn stream_box_body<S>(stream: S) -> ServerResponseBody
87where
88    S: futures_util::Stream<Item = Result<Bytes, std::io::Error>> + Unpin + Send + 'static,
89{
90    SyncStreamBody(std::sync::Mutex::new(stream)).boxed()
91}
92
93/// Helper building an empty response.
94pub fn empty_box_response(status: StatusCode) -> Response<ServerResponseBody> {
95    Response::builder()
96        .status(status)
97        .body(full_box_body(Bytes::new()))
98        .unwrap()
99}
100
101/// Helper building an HTML or text response.
102pub fn html_response(status: StatusCode, html: String) -> Response<ServerResponseBody> {
103    Response::builder()
104        .status(status)
105        .header(CONTENT_TYPE, "text/html; charset=utf-8")
106        .body(full_box_body(html))
107        .unwrap()
108}
109
110/// Helper building a JSON response.
111pub fn json_response<T: Serialize>(status: StatusCode, data: &T) -> Response<ServerResponseBody> {
112    let json_bytes = serde_json::to_vec(data).unwrap_or_default();
113    Response::builder()
114        .status(status)
115        .header(CONTENT_TYPE, "application/json; charset=utf-8")
116        .body(full_box_body(json_bytes))
117        .unwrap()
118}
119
120/// Configuration options for the Dufs-alike file server.
121#[derive(Clone, Debug, Serialize, Deserialize)]
122pub struct DufsConfig {
123    /// Root directory path being served.
124    pub root_path: PathBuf,
125    /// Path prefix when mounted on a sub-route (e.g. "" or "/dufs").
126    #[serde(default)]
127    pub path_prefix: String,
128    /// Whether peers on the network may upload files to this folder.
129    #[serde(default)]
130    pub allow_upload: bool,
131    /// Whether peers on the network may delete files/folders.
132    #[serde(default)]
133    pub allow_delete: bool,
134    /// Optional security PIN/password required for access.
135    #[serde(default)]
136    pub auth_pin: Option<String>,
137    /// Display title for the web portal.
138    #[serde(default = "default_dufs_title")]
139    pub title: String,
140}
141
142fn default_dufs_title() -> String {
143    "LanFlare File Share".to_string()
144}
145
146/// Directory entry DTO returned by JSON API and used by HTML renderer.
147#[derive(Clone, Debug, Serialize, Deserialize)]
148pub struct DufsEntryDto {
149    pub name: String,
150    pub path: String,
151    pub is_dir: bool,
152    pub size: u64,
153    pub modified: u64,
154    pub mime_type: String,
155}
156
157/// Manager coordinating the active Dufs server instance.
158#[derive(Debug, Default)]
159pub struct DufsManager {
160    active_config: RwLock<Option<DufsConfig>>,
161}
162
163impl DufsManager {
164    pub fn new() -> Self {
165        Self {
166            active_config: RwLock::new(None),
167        }
168    }
169
170    /// Sets or replaces the active dufs folder sharing configuration.
171    pub async fn set_config(&self, config: DufsConfig) {
172        let mut guard = self.active_config.write().await;
173        *guard = Some(config);
174    }
175
176    /// Clears the active dufs configuration, disabling folder sharing.
177    pub async fn clear_config(&self) {
178        let mut guard = self.active_config.write().await;
179        *guard = None;
180    }
181
182    /// Retrieves the current dufs configuration if active.
183    pub async fn get_config(&self) -> Option<DufsConfig> {
184        self.active_config.read().await.clone()
185    }
186
187    /// Checks whether folder sharing is currently active.
188    pub async fn is_active(&self) -> bool {
189        self.active_config.read().await.is_some()
190    }
191}
192
193/// Handle to a standalone running Dufs HTTP server.
194pub struct DufsServerHandle {
195    pub port: u16,
196    pub cancel_token: CancellationToken,
197    pub join_handle: tokio::task::JoinHandle<()>,
198}
199
200impl DufsServerHandle {
201    pub fn stop(&self) {
202        self.cancel_token.cancel();
203    }
204}
205
206/// Percent-decodes a URL path string.
207pub fn percent_decode(input: &str) -> String {
208    let mut result = Vec::with_capacity(input.len());
209    let bytes = input.as_bytes();
210    let mut i = 0;
211    while i < bytes.len() {
212        if bytes[i] == b'%'
213            && i + 2 < bytes.len()
214            && let (Some(h1), Some(h2)) = (
215                (bytes[i + 1] as char).to_digit(16),
216                (bytes[i + 2] as char).to_digit(16),
217            )
218        {
219            result.push((h1 * 16 + h2) as u8);
220            i += 3;
221            continue;
222        }
223        result.push(bytes[i]);
224        i += 1;
225    }
226    String::from_utf8(result).unwrap_or_else(|_| input.to_string())
227}
228
229/// Safely resolves and validates a URL path against a root directory.
230///
231/// Special care for Android:
232/// 1. Strips any path prefix.
233/// 2. Normalizes path components lexically (prevents `..` traversal).
234/// 3. Handles Android symlinks/mount namespaces (`/storage/emulated/0` vs `/data/media/0`).
235pub fn resolve_and_validate_path(
236    root: &Path,
237    url_path: &str,
238    prefix: &str,
239) -> Result<PathBuf, StatusCode> {
240    let decoded = percent_decode(url_path);
241
242    let stripped = if !prefix.is_empty() && decoded.starts_with(prefix) {
243        &decoded[prefix.len()..]
244    } else {
245        &decoded
246    };
247
248    let cleaned_relative = Path::new(stripped.trim_start_matches('/'));
249
250    // Check every component: no parent `..` or root component allowed
251    let mut safe_rel = PathBuf::new();
252    for comp in cleaned_relative.components() {
253        match comp {
254            Component::Normal(part) => {
255                let part_str = part.to_string_lossy();
256                // Reject null characters or malicious control characters
257                if part_str.contains('\0') {
258                    return Err(StatusCode::BAD_REQUEST);
259                }
260                safe_rel.push(part);
261            }
262            Component::CurDir => continue,
263            Component::ParentDir => {
264                // Illegal directory traversal
265                return Err(StatusCode::FORBIDDEN);
266            }
267            Component::RootDir | Component::Prefix(_) => {
268                // Reject absolute components
269                return Err(StatusCode::FORBIDDEN);
270            }
271        }
272    }
273
274    let candidate = root.join(&safe_rel);
275
276    // Canonicalization check with Android fallback:
277    if let (Ok(canon_cand), Ok(canon_root)) = (candidate.canonicalize(), root.canonicalize()) {
278        if canon_cand.starts_with(&canon_root) {
279            return Ok(canon_cand);
280        }
281        // Special Android check: /sdcard vs /storage/emulated/0 vs /data/media/0
282        #[cfg(target_os = "android")]
283        {
284            let cand_str = canon_cand.to_string_lossy();
285            let root_str = canon_root.to_string_lossy();
286            if cand_str.contains("/0/") && root_str.contains("/0/") {
287                let cand_suffix = cand_str.split_once("/0/").map(|x| x.1).unwrap_or("");
288                let root_suffix = root_str.split_once("/0/").map(|x| x.1).unwrap_or("");
289                if cand_suffix.starts_with(root_suffix) {
290                    return Ok(canon_cand);
291                }
292            }
293        }
294        return Err(StatusCode::FORBIDDEN);
295    }
296
297    // If candidate does not exist yet (e.g. for upload), ensure candidate lexically starts with root
298    if candidate.starts_with(root) {
299        Ok(candidate)
300    } else {
301        Err(StatusCode::FORBIDDEN)
302    }
303}
304
305/// Helper parsing HTTP Range header: `bytes=start-end` or `bytes=start-`.
306pub fn parse_range_header(header_val: &str, file_size: u64) -> Option<(u64, u64)> {
307    let trimmed = header_val.trim();
308    if !trimmed.starts_with("bytes=") {
309        return None;
310    }
311    let spec = &trimmed[6..];
312    if let Some((start_str, end_str)) = spec.split_once('-') {
313        let start_str = start_str.trim();
314        let end_str = end_str.trim();
315
316        if start_str.is_empty() {
317            // Suffix range: bytes=-500 (last 500 bytes)
318            if let Ok(suffix_len) = end_str.parse::<u64>() {
319                if suffix_len == 0 || file_size == 0 {
320                    return None;
321                }
322                let start = file_size.saturating_sub(suffix_len);
323                return Some((start, file_size - 1));
324            }
325        } else if let Ok(start) = start_str.parse::<u64>() {
326            if start >= file_size {
327                return None;
328            }
329            let end = if end_str.is_empty() {
330                file_size - 1
331            } else if let Ok(parsed_end) = end_str.parse::<u64>() {
332                std::cmp::min(parsed_end, file_size - 1)
333            } else {
334                return None;
335            };
336
337            if start <= end {
338                return Some((start, end));
339            }
340        }
341    }
342    None
343}
344
345/// Checks PIN authentication for the request (via Basic Auth or query param ?pin=).
346pub fn check_dufs_auth(req: &Request<Incoming>, config: &DufsConfig) -> bool {
347    let expected_pin = match &config.auth_pin {
348        Some(pin) if !pin.is_empty() => pin,
349        _ => return true, // No PIN required
350    };
351
352    // 1. Check query parameter: `?pin=<pin>`
353    if let Some(query) = req.uri().query() {
354        for pair in query.split('&') {
355            if let Some((k, v)) = pair.split_once('=')
356                && k == "pin" && percent_decode(v) == *expected_pin
357            {
358                return true;
359            }
360        }
361    }
362
363    // 2. Check Authorization: Basic <base64>
364    if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION)
365        && let Ok(auth_str) = auth_val.to_str()
366        && auth_str.starts_with("Basic ")
367    {
368        use base64::Engine;
369        let b64 = &auth_str[6..].trim();
370        if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(b64)
371            && let Ok(decoded_str) = String::from_utf8(decoded)
372        {
373            // Allow PIN in either password or username field (in case user filled username prompt)
374            let pass = if let Some((user, pass)) = decoded_str.split_once(':') {
375                if pass.is_empty() && !user.is_empty() {
376                    user
377                } else {
378                    pass
379                }
380            } else {
381                &decoded_str
382            };
383            if pass == expected_pin {
384                return true;
385            }
386        }
387    }
388
389    false
390}
391
392/// Returns a 401 Unauthorized response prompting for PIN.
393pub fn unauthorized_response(title: &str) -> Response<ServerResponseBody> {
394    Response::builder()
395        .status(StatusCode::UNAUTHORIZED)
396        .header(
397            WWW_AUTHENTICATE,
398            format!("Basic realm=\"Enter Password (Leave Username Blank / Not Required) - {}\"", title),
399        )
400        .header(CONTENT_TYPE, "text/plain; charset=utf-8")
401        .body(full_box_body("401 Unauthorized - Enter password only (no username required)\n"))
402        .unwrap()
403}
404
405/// Helper converting byte size into human-readable string.
406pub fn format_size(bytes: u64) -> String {
407    const KB: f64 = 1024.0;
408    const MB: f64 = KB * 1024.0;
409    const GB: f64 = MB * 1024.0;
410
411    let b = bytes as f64;
412    if b >= GB {
413        format!("{:.2} GB", b / GB)
414    } else if b >= MB {
415        format!("{:.1} MB", b / MB)
416    } else if b >= KB {
417        format!("{:.1} KB", b / KB)
418    } else {
419        format!("{bytes} B")
420    }
421}
422
423/// Escapes HTML special characters.
424pub fn escape_html(input: &str) -> String {
425    input
426        .replace('&', "&amp;")
427        .replace('<', "&lt;")
428        .replace('>', "&gt;")
429        .replace('"', "&quot;")
430        .replace('\'', "&#39;")
431}
432
433/// Streams an entire folder as a `.zip` archive on the fly.
434///
435/// Streams an entire folder as a `.zip` archive on the fly.
436///
437/// Compresses using a temporary file in temp_dir / cache_dir and streams in 64KB chunks
438/// with Content-Length header, automatically cleaning up upon completion.
439pub async fn stream_zip_folder(
440    dir_path: PathBuf,
441    zip_name: String,
442) -> Result<Response<ServerResponseBody>, StatusCode> {
443    if !dir_path.is_dir() {
444        return Err(StatusCode::NOT_FOUND);
445    }
446
447    let temp_zip_path = std::env::temp_dir().join(format!(
448        "lanflare_zip_{}_{}.tmp",
449        std::process::id(),
450        uuid::Uuid::new_v4()
451    ));
452
453    let out_file = match std::fs::File::create(&temp_zip_path) {
454        Ok(f) => f,
455        Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
456    };
457
458    let walk_dir = dir_path.clone();
459    let zip_res = tokio::task::spawn_blocking(move || {
460        let mut zip = zip::ZipWriter::new(out_file);
461
462        let options = zip::write::SimpleFileOptions::default()
463            .compression_method(zip::CompressionMethod::Deflated);
464
465        fn add_dir_contents<W: Write + std::io::Seek>(
466            zip: &mut zip::ZipWriter<W>,
467            dir: &Path,
468            base_dir: &Path,
469            options: zip::write::SimpleFileOptions,
470        ) -> std::io::Result<()> {
471            for entry in std::fs::read_dir(dir)? {
472                let entry = entry?;
473                let path = entry.path();
474                let rel_path = match path.strip_prefix(base_dir) {
475                    Ok(p) => p.to_string_lossy().replace('\\', "/"),
476                    Err(_) => continue,
477                };
478
479                if path.is_dir() {
480                    zip.add_directory(format!("{rel_path}/"), options)?;
481                    add_dir_contents(zip, &path, base_dir, options)?;
482                } else if path.is_file() {
483                    zip.start_file(rel_path, options)?;
484                    let mut file = std::fs::File::open(&path)?;
485                    std::io::copy(&mut file, zip)?;
486                }
487            }
488            Ok(())
489        }
490
491        let res = add_dir_contents(&mut zip, &walk_dir, &walk_dir, options);
492        let _ = zip.finish();
493        res
494    })
495    .await;
496
497    if zip_res.is_err() || zip_res.unwrap().is_err() {
498        let _ = tokio::fs::remove_file(&temp_zip_path).await;
499        return Err(StatusCode::INTERNAL_SERVER_ERROR);
500    }
501
502    let meta = match tokio::fs::metadata(&temp_zip_path).await {
503        Ok(m) => m,
504        Err(_) => {
505            let _ = tokio::fs::remove_file(&temp_zip_path).await;
506            return Err(StatusCode::INTERNAL_SERVER_ERROR);
507        }
508    };
509    let file_size = meta.len();
510
511    let async_file = match tokio::fs::File::open(&temp_zip_path).await {
512        Ok(f) => f,
513        Err(_) => {
514            let _ = tokio::fs::remove_file(&temp_zip_path).await;
515            return Err(StatusCode::INTERNAL_SERVER_ERROR);
516        }
517    };
518
519    // Auto-remove temp file after streaming finishes or drops
520    struct TempCleaner {
521        path: PathBuf,
522    }
523    impl Drop for TempCleaner {
524        fn drop(&mut self) {
525            let _ = std::fs::remove_file(&self.path);
526        }
527    }
528    let cleaner = Arc::new(TempCleaner {
529        path: temp_zip_path,
530    });
531
532    let reader_stream = ReaderStream::with_capacity(async_file, 64 * 1024);
533    let cleaned_stream = reader_stream.map_ok(move |bytes| {
534        let _guard = &cleaner;
535        bytes
536    });
537
538    let body = stream_box_body(cleaned_stream);
539
540    let clean_zip_name = zip_name.trim_end_matches(".zip");
541    let disposition = format!("attachment; filename=\"{clean_zip_name}.zip\"");
542
543    let res = Response::builder()
544        .status(StatusCode::OK)
545        .header(CONTENT_TYPE, "application/zip")
546        .header(CONTENT_LENGTH, file_size)
547        .header(CONTENT_DISPOSITION, disposition)
548        .body(body)
549        .unwrap();
550
551    Ok(res)
552}
553
554/// Serves a single file with streaming and HTTP Range support.
555pub async fn serve_file(
556    file_path: &Path,
557    req: &Request<Incoming>,
558) -> Result<Response<ServerResponseBody>, StatusCode> {
559    let meta = match tokio::fs::metadata(file_path).await {
560        Ok(m) => m,
561        Err(_) => return Err(StatusCode::NOT_FOUND),
562    };
563
564    let file_size = meta.len();
565    let file_name = file_path
566        .file_name()
567        .and_then(|n| n.to_str())
568        .unwrap_or("file");
569
570    let mime_type = mime_guess_simple(file_name);
571
572    let mut file = match tokio::fs::File::open(file_path).await {
573        Ok(f) => f,
574        Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
575    };
576
577    let is_download_attachment = req
578        .uri()
579        .query()
580        .map(|q| q.contains("download"))
581        .unwrap_or(false);
582
583    let disposition = if is_download_attachment {
584        format!("attachment; filename=\"{}\"", file_name.replace('"', "\\\""))
585    } else {
586        format!("inline; filename=\"{}\"", file_name.replace('"', "\\\""))
587    };
588
589    // Check Range header
590    let range_header = req
591        .headers()
592        .get(RANGE)
593        .and_then(|v| v.to_str().ok())
594        .and_then(|h| parse_range_header(h, file_size));
595
596    if let Some((start, end)) = range_header {
597        let length = end - start + 1;
598        if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
599            return Err(StatusCode::RANGE_NOT_SATISFIABLE);
600        }
601
602        use tokio::io::AsyncReadExt;
603        let stream = ReaderStream::with_capacity(file.take(length), 64 * 1024);
604        let body = stream_box_body(stream);
605
606        let res = Response::builder()
607            .status(StatusCode::PARTIAL_CONTENT)
608            .header(CONTENT_TYPE, mime_type)
609            .header(CONTENT_RANGE, format!("bytes {start}-{end}/{file_size}"))
610            .header(CONTENT_LENGTH, length)
611            .header(ACCEPT_RANGES, "bytes")
612            .header(CONTENT_DISPOSITION, disposition)
613            .body(body)
614            .unwrap();
615
616        Ok(res)
617    } else {
618        let stream = ReaderStream::with_capacity(file, 64 * 1024);
619        let body = stream_box_body(stream);
620
621        let res = Response::builder()
622            .status(StatusCode::OK)
623            .header(CONTENT_TYPE, mime_type)
624            .header(CONTENT_LENGTH, file_size)
625            .header(ACCEPT_RANGES, "bytes")
626            .header(CONTENT_DISPOSITION, disposition)
627            .body(body)
628            .unwrap();
629
630        Ok(res)
631    }
632}
633
634/// Generates directory breadcrumb navigation items.
635fn build_breadcrumbs(relative_path: &Path, prefix: &str) -> Vec<(String, String)> {
636    let mut crumbs = Vec::new();
637    let root_url = if prefix.is_empty() {
638        "/".to_string()
639    } else {
640        format!("{prefix}/")
641    };
642    crumbs.push(("Home".to_string(), root_url));
643
644    let mut current_accum = String::new();
645    for comp in relative_path.components() {
646        if let Component::Normal(seg) = comp {
647            let seg_str = seg.to_string_lossy().to_string();
648            current_accum.push('/');
649            current_accum.push_str(&seg_str);
650
651            let url = if prefix.is_empty() {
652                format!("{current_accum}/")
653            } else {
654                format!("{prefix}{current_accum}/")
655            };
656            crumbs.push((seg_str, url));
657        }
658    }
659    crumbs
660}
661
662/// Serves a directory listing as HTML or JSON.
663pub async fn serve_directory(
664    dir_path: &Path,
665    root_path: &Path,
666    req: &Request<Incoming>,
667    config: &DufsConfig,
668) -> Result<Response<ServerResponseBody>, StatusCode> {
669    // 1. If query is ?zip, stream the directory as a zip
670    if let Some(query) = req.uri().query()
671        && (query == "zip" || query.starts_with("zip&") || query.ends_with("&zip"))
672    {
673        let folder_name = dir_path
674            .file_name()
675            .and_then(|n| n.to_str())
676            .unwrap_or("folder")
677            .to_string();
678        return stream_zip_folder(dir_path.to_path_buf(), folder_name).await;
679    }
680
681    // 2. Read directory entries
682    let mut dir_reader = match tokio::fs::read_dir(dir_path).await {
683        Ok(r) => r,
684        Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
685    };
686
687    let mut entries = Vec::new();
688    let rel_to_root = dir_path.strip_prefix(root_path).unwrap_or(Path::new(""));
689
690    while let Ok(Some(entry)) = dir_reader.next_entry().await {
691        let name = entry.file_name().to_string_lossy().to_string();
692        // Skip hidden files starting with '.'
693        if name.starts_with('.') {
694            continue;
695        }
696
697        let meta = match entry.metadata().await {
698            Ok(m) => m,
699            Err(_) => continue,
700        };
701
702        let is_dir = meta.is_dir();
703        let size = if is_dir { 0 } else { meta.len() };
704        let modified = meta
705            .modified()
706            .unwrap_or(SystemTime::UNIX_EPOCH)
707            .duration_since(SystemTime::UNIX_EPOCH)
708            .unwrap_or_default()
709            .as_secs();
710
711        let mime = if is_dir {
712            "inode/directory".to_string()
713        } else {
714            mime_guess_simple(&name)
715        };
716
717        let sub_rel = if rel_to_root.as_os_str().is_empty() {
718            name.clone()
719        } else {
720            format!("{}/{}", rel_to_root.to_string_lossy(), name)
721        };
722
723        let url_path = if config.path_prefix.is_empty() {
724            format!("/{sub_rel}")
725        } else {
726            format!("{}{}/{sub_rel}", config.path_prefix, "")
727        };
728
729        entries.push(DufsEntryDto {
730            name,
731            path: url_path,
732            is_dir,
733            size,
734            modified,
735            mime_type: mime,
736        });
737    }
738
739    // Sort: directories first, then alphabetical by name
740    entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
741        (true, false) => std::cmp::Ordering::Less,
742        (false, true) => std::cmp::Ordering::Greater,
743        _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
744    });
745
746    // 3. Return JSON if requested
747    let accepts_json = req
748        .headers()
749        .get(hyper::header::ACCEPT)
750        .and_then(|v| v.to_str().ok())
751        .map(|a| a.contains("application/json"))
752        .unwrap_or(false)
753        || req.uri().query().map(|q| q.contains("json")).unwrap_or(false);
754
755    if accepts_json {
756        return Ok(json_response(StatusCode::OK, &entries));
757    }
758
759    // 4. Render HTML page
760    let breadcrumbs = build_breadcrumbs(rel_to_root, &config.path_prefix);
761
762    let mut breadcrumbs_html = String::new();
763    for (i, (crumb_name, crumb_url)) in breadcrumbs.iter().enumerate() {
764        if i > 0 {
765            breadcrumbs_html.push_str(r#"<span class="crumb-sep">/</span>"#);
766        }
767        let encoded_name = escape_html(crumb_name);
768        breadcrumbs_html.push_str(&format!(
769            r#"<a class="crumb-link" href="{crumb_url}">{encoded_name}</a>"#
770        ));
771    }
772
773    let mut parent_row_html = String::new();
774    if rel_to_root.components().count() > 0 {
775        let parent_url = if let Some(parent) = rel_to_root.parent() {
776            if parent.as_os_str().is_empty() {
777                if config.path_prefix.is_empty() {
778                    "/".to_string()
779                } else {
780                    format!("{}/", config.path_prefix)
781                }
782            } else {
783                format!("{}/{}/", config.path_prefix, parent.to_string_lossy())
784            }
785        } else {
786            "/".to_string()
787        };
788
789        parent_row_html = format!(
790            r#"<tr class="entry-row parent-row">
791                <td class="col-icon">
792                    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
793                </td>
794                <td class="col-name"><a href="{parent_url}" class="name-link">..</a></td>
795                <td class="col-size">-</td>
796                <td class="col-date">-</td>
797                <td class="col-actions"></td>
798            </tr>"#
799        );
800    }
801
802    let mut rows_html = String::new();
803    for entry in &entries {
804        let encoded_name = escape_html(&entry.name);
805        let href = if entry.is_dir {
806            format!("{}/", entry.path)
807        } else {
808            entry.path.clone()
809        };
810
811        let size_str = if entry.is_dir {
812            "-".to_string()
813        } else {
814            format_size(entry.size)
815        };
816
817        let date_str = {
818            let dt = chrono::DateTime::from_timestamp(entry.modified as i64, 0)
819                .unwrap_or_default();
820            dt.format("%Y-%m-%d %H:%M").to_string()
821        };
822
823        let icon_svg = if entry.is_dir {
824            r#"<svg class="icon-folder" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>"#
825        } else if entry.mime_type.starts_with("image/") {
826            r#"<svg class="icon-image" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>"#
827        } else if entry.mime_type.starts_with("video/") {
828            r#"<svg class="icon-video" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>"#
829        } else if entry.mime_type.starts_with("audio/") {
830            r#"<svg class="icon-audio" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>"#
831        } else if entry.mime_type == "application/pdf" {
832            r#"<svg class="icon-pdf" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>"#
833        } else if entry.mime_type == "application/zip" || entry.mime_type.contains("zip") || entry.mime_type.contains("tar") {
834            r#"<svg class="icon-zip" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>"#
835        } else {
836            r#"<svg class="icon-file" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>"#
837        };
838
839        let action_btn = if entry.is_dir {
840            format!(
841                r#"<a href="{href}?zip" class="action-btn" title="Download folder as ZIP">
842                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
843                    ZIP
844                </a>"#
845            )
846        } else {
847            format!(
848                r#"<a href="{href}?download" download="{encoded_name}" class="action-btn" title="Download file">
849                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
850                    Download
851                </a>"#
852            )
853        };
854
855        rows_html.push_str(&format!(
856            r#"<tr class="entry-row" data-name="{encoded_name}" data-isdir="{}">
857                <td class="col-icon">{icon_svg}</td>
858                <td class="col-name"><a href="{href}" class="name-link">{encoded_name}</a></td>
859                <td class="col-size" data-bytes="{}">{size_str}</td>
860                <td class="col-date" data-time="{}">{date_str}</td>
861                <td class="col-actions">{action_btn}</td>
862            </tr>"#,
863            if entry.is_dir { "true" } else { "false" },
864            entry.size,
865            entry.modified
866        ));
867    }
868
869    let upload_section_html = if config.allow_upload {
870        r#"<div class="upload-bar" id="uploadBar">
871            <input type="file" id="fileInput" multiple style="display:none;" />
872            <button class="btn btn-primary" onclick="document.getElementById('fileInput').click()">
873                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
874                Upload Files
875            </button>
876            <span class="upload-tip">or drag & drop files anywhere</span>
877        </div>"#
878    } else {
879        ""
880    };
881
882    let title_escaped = escape_html(&config.title);
883
884    let html = format!(
885        r#"<!DOCTYPE html>
886<html lang="en">
887<head>
888    <meta charset="UTF-8">
889    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
890    <meta name="color-scheme" content="light dark">
891    <title>{title_escaped}</title>
892    <script>
893        (function() {{
894            try {{
895                var saved = localStorage.getItem('lanflare_theme');
896                if (saved === 'dark' || saved === 'light') {{
897                    document.documentElement.setAttribute('data-theme', saved);
898                }}
899            }} catch(e) {{}}
900        }})();
901    </script>
902    <style>
903        :root {{
904            color-scheme: light dark;
905            --bg-body: #f8fafc;
906            --bg-card: #ffffff;
907            --bg-row-hover: #f1f5f9;
908            --border: #e2e8f0;
909            --border-row: rgba(0, 0, 0, 0.05);
910            --text-main: #0f172a;
911            --text-muted: #64748b;
912            --accent: #4f46e5;
913            --accent-hover: #4338ca;
914            --folder-color: #d97706;
915            --file-color: #4f46e5;
916            --badge-bg: rgba(79, 70, 229, 0.08);
917            --badge-text: #4f46e5;
918            --badge-border: rgba(79, 70, 229, 0.2);
919            --search-bg: #f8fafc;
920            --search-border: #cbd5e1;
921            --th-bg: #f8fafc;
922            --btn-outline-bg: transparent;
923            --btn-outline-border: #cbd5e1;
924            --btn-outline-text: #334155;
925            --btn-outline-hover-bg: #f1f5f9;
926            --btn-outline-hover-border: #94a3b8;
927            --action-btn-bg: #f8fafc;
928            --action-btn-border: #cbd5e1;
929            --action-btn-text: #334155;
930            --card-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
931            --header-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
932            --upload-bar-bg: rgba(79, 70, 229, 0.04);
933            --upload-bar-border: rgba(79, 70, 229, 0.25);
934            --crumb-link: #4f46e5;
935            --name-hover: #4f46e5;
936        }}
937        @media (prefers-color-scheme: dark) {{
938            :root:not([data-theme="light"]) {{
939                --bg-body: #0b0f19;
940                --bg-card: #111827;
941                --bg-row-hover: #1f293d;
942                --border: #1e293b;
943                --border-row: rgba(255, 255, 255, 0.04);
944                --text-main: #f8fafc;
945                --text-muted: #94a3b8;
946                --accent: #6366f1;
947                --accent-hover: #4f46e5;
948                --folder-color: #fbbf24;
949                --file-color: #818cf8;
950                --badge-bg: rgba(99, 102, 241, 0.2);
951                --badge-text: #818cf8;
952                --badge-border: rgba(99, 102, 241, 0.4);
953                --search-bg: #0f172a;
954                --search-border: #1e293b;
955                --th-bg: #0d121f;
956                --btn-outline-bg: transparent;
957                --btn-outline-border: #1e293b;
958                --btn-outline-text: #f8fafc;
959                --btn-outline-hover-bg: #1e293b;
960                --btn-outline-hover-border: #475569;
961                --action-btn-bg: #1e293b;
962                --action-btn-border: #334155;
963                --action-btn-text: #cbd5e1;
964                --card-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
965                --header-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
966                --upload-bar-bg: rgba(99, 102, 241, 0.06);
967                --upload-bar-border: rgba(99, 102, 241, 0.4);
968                --crumb-link: #818cf8;
969                --name-hover: #818cf8;
970            }}
971        }}
972        :root[data-theme="dark"] {{
973            --bg-body: #0b0f19;
974            --bg-card: #111827;
975            --bg-row-hover: #1f293d;
976            --border: #1e293b;
977            --border-row: rgba(255, 255, 255, 0.04);
978            --text-main: #f8fafc;
979            --text-muted: #94a3b8;
980            --accent: #6366f1;
981            --accent-hover: #4f46e5;
982            --folder-color: #fbbf24;
983            --file-color: #818cf8;
984            --badge-bg: rgba(99, 102, 241, 0.2);
985            --badge-text: #818cf8;
986            --badge-border: rgba(99, 102, 241, 0.4);
987            --search-bg: #0f172a;
988            --search-border: #1e293b;
989            --th-bg: #0d121f;
990            --btn-outline-bg: transparent;
991            --btn-outline-border: #1e293b;
992            --btn-outline-text: #f8fafc;
993            --btn-outline-hover-bg: #1e293b;
994            --btn-outline-hover-border: #475569;
995            --action-btn-bg: #1e293b;
996            --action-btn-border: #334155;
997            --action-btn-text: #cbd5e1;
998            --card-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
999            --header-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
1000            --upload-bar-bg: rgba(99, 102, 241, 0.06);
1001            --upload-bar-border: rgba(99, 102, 241, 0.4);
1002            --crumb-link: #818cf8;
1003            --name-hover: #818cf8;
1004        }}
1005        * {{ box-sizing: border-box; margin: 0; padding: 0; }}
1006        body {{
1007            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
1008            background: var(--bg-body);
1009            color: var(--text-main);
1010            min-height: 100vh;
1011            display: flex;
1012            flex-direction: column;
1013            padding: 16px;
1014            transition: background 0.2s ease, color 0.2s ease;
1015        }}
1016        .container {{
1017            max-width: 960px;
1018            width: 100%;
1019            margin: 0 auto;
1020            display: flex;
1021            flex-direction: column;
1022            gap: 16px;
1023        }}
1024        .header {{
1025            display: flex;
1026            align-items: center;
1027            justify-content: space-between;
1028            flex-wrap: wrap;
1029            gap: 12px;
1030            padding: 16px 20px;
1031            background: var(--bg-card);
1032            border: 1px solid var(--border);
1033            border-radius: 16px;
1034            box-shadow: var(--header-shadow);
1035            transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
1036        }}
1037        .brand {{
1038            display: flex;
1039            align-items: center;
1040            gap: 12px;
1041        }}
1042        .brand-logo {{
1043            width: 36px;
1044            height: 36px;
1045            border-radius: 10px;
1046            background: linear-gradient(135deg, #6366f1, #8b5cf6);
1047            display: flex;
1048            align-items: center;
1049            justify-content: center;
1050            color: white;
1051            box-shadow: 0 2px 10px rgba(99,102,241,0.4);
1052        }}
1053        .brand-title {{
1054            font-size: 17px;
1055            font-weight: 700;
1056            color: var(--text-main);
1057        }}
1058        .brand-badge {{
1059            font-size: 10px;
1060            font-weight: 600;
1061            padding: 2px 6px;
1062            border-radius: 6px;
1063            background: var(--badge-bg);
1064            color: var(--badge-text);
1065            border: 1px solid var(--badge-border);
1066            margin-left: 6px;
1067        }}
1068        .top-actions {{
1069            display: flex;
1070            align-items: center;
1071            gap: 10px;
1072            flex-wrap: wrap;
1073        }}
1074        .search-box {{
1075            position: relative;
1076            display: flex;
1077            align-items: center;
1078        }}
1079        .search-box input {{
1080            background: var(--search-bg);
1081            border: 1px solid var(--search-border);
1082            color: var(--text-main);
1083            padding: 8px 12px 8px 34px;
1084            border-radius: 10px;
1085            font-size: 13px;
1086            outline: none;
1087            width: 180px;
1088            transition: all 0.2s;
1089        }}
1090        .search-box input:focus {{
1091            border-color: var(--accent);
1092            width: 220px;
1093            box-shadow: 0 0 0 2px rgba(99,102,241,0.25);
1094        }}
1095        .search-icon {{
1096            position: absolute;
1097            left: 10px;
1098            color: var(--text-muted);
1099            pointer-events: none;
1100        }}
1101        .btn {{
1102            display: inline-flex;
1103            align-items: center;
1104            gap: 6px;
1105            padding: 8px 14px;
1106            border-radius: 10px;
1107            font-size: 12px;
1108            font-weight: 600;
1109            cursor: pointer;
1110            border: none;
1111            text-decoration: none;
1112            transition: all 0.15s;
1113        }}
1114        .btn-primary {{
1115            background: var(--accent);
1116            color: white;
1117        }}
1118        .btn-primary:hover {{ background: var(--accent-hover); }}
1119        .btn-outline {{
1120            background: var(--btn-outline-bg);
1121            border: 1px solid var(--btn-outline-border);
1122            color: var(--btn-outline-text);
1123        }}
1124        .btn-outline:hover {{
1125            background: var(--btn-outline-hover-bg);
1126            border-color: var(--btn-outline-hover-border);
1127        }}
1128        .breadcrumbs-bar {{
1129            display: flex;
1130            align-items: center;
1131            gap: 6px;
1132            padding: 10px 16px;
1133            background: var(--bg-card);
1134            border: 1px solid var(--border);
1135            border-radius: 12px;
1136            font-size: 13px;
1137            overflow-x: auto;
1138            white-space: nowrap;
1139        }}
1140        .crumb-link {{
1141            color: var(--crumb-link);
1142            text-decoration: none;
1143            font-weight: 500;
1144        }}
1145        .crumb-link:hover {{ text-decoration: underline; }}
1146        .crumb-sep {{ color: var(--text-muted); opacity: 0.6; }}
1147        .upload-bar {{
1148            display: flex;
1149            align-items: center;
1150            gap: 12px;
1151            padding: 12px 18px;
1152            background: var(--upload-bar-bg);
1153            border: 1px dashed var(--upload-bar-border);
1154            border-radius: 12px;
1155        }}
1156        .upload-tip {{ font-size: 12px; color: var(--text-muted); }}
1157        .table-card {{
1158            background: var(--bg-card);
1159            border: 1px solid var(--border);
1160            border-radius: 16px;
1161            overflow: hidden;
1162            box-shadow: var(--card-shadow);
1163        }}
1164        table {{
1165            width: 100%;
1166            border-collapse: collapse;
1167            text-align: left;
1168        }}
1169        th {{
1170            padding: 12px 16px;
1171            font-size: 11px;
1172            text-transform: uppercase;
1173            letter-spacing: 0.05em;
1174            color: var(--text-muted);
1175            border-bottom: 1px solid var(--border);
1176            font-weight: 600;
1177            background: var(--th-bg);
1178        }}
1179        td {{
1180            padding: 12px 16px;
1181            font-size: 13px;
1182            border-bottom: 1px solid var(--border-row);
1183            vertical-align: middle;
1184        }}
1185        .entry-row:hover td {{
1186            background: var(--bg-row-hover);
1187        }}
1188        .col-icon {{ width: 36px; padding-right: 0; }}
1189        .icon-folder {{ color: var(--folder-color); }}
1190        .icon-image {{ color: #38bdf8; }}
1191        .icon-video {{ color: #f43f5e; }}
1192        .icon-audio {{ color: #a855f7; }}
1193        .icon-pdf {{ color: #ef4444; }}
1194        .icon-zip {{ color: #eab308; }}
1195        .icon-file {{ color: var(--text-muted); }}
1196        .col-name {{ font-weight: 500; min-width: 180px; }}
1197        .name-link {{
1198            color: var(--text-main);
1199            text-decoration: none;
1200            word-break: break-all;
1201        }}
1202        .name-link:hover {{ color: var(--name-hover); text-decoration: underline; }}
1203        .col-size {{ width: 110px; font-family: monospace; font-size: 12px; color: var(--text-muted); }}
1204        .col-date {{ width: 150px; font-size: 12px; color: var(--text-muted); }}
1205        .col-actions {{ width: 100px; text-align: right; }}
1206        .action-btn {{
1207            display: inline-flex;
1208            align-items: center;
1209            gap: 4px;
1210            padding: 5px 10px;
1211            border-radius: 8px;
1212            background: var(--action-btn-bg);
1213            border: 1px solid var(--action-btn-border);
1214            color: var(--action-btn-text);
1215            font-size: 11px;
1216            font-weight: 600;
1217            text-decoration: none;
1218            transition: all 0.15s;
1219        }}
1220        .action-btn:hover {{
1221            background: var(--accent);
1222            border-color: var(--accent);
1223            color: white;
1224        }}
1225        .footer {{
1226            text-align: center;
1227            font-size: 11px;
1228            color: var(--text-muted);
1229            margin-top: 10px;
1230            padding-bottom: 20px;
1231        }}
1232        @media (max-width: 640px) {{
1233            body {{ padding: 10px; }}
1234            .col-date {{ display: none; }}
1235            .col-size {{ width: 80px; }}
1236            .search-box input {{ width: 140px; }}
1237            .search-box input:focus {{ width: 170px; }}
1238        }}
1239    </style>
1240</head>
1241<body>
1242    <div class="container">
1243        <!-- Header -->
1244        <header class="header">
1245            <div class="brand">
1246                <div class="brand-logo">
1247                    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>
1248                </div>
1249                <div>
1250                    <span class="brand-title">{title_escaped}</span>
1251                    <span class="brand-badge">Dufs Mode</span>
1252                </div>
1253            </div>
1254            <div class="top-actions">
1255                <div class="search-box">
1256                    <svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
1257                    <input type="text" id="searchInput" placeholder="Filter files..." oninput="filterFiles()" />
1258                </div>
1259                <button id="themeToggle" class="btn btn-outline" onclick="toggleTheme()" title="Toggle theme (System / Light / Dark)" aria-label="Toggle theme">
1260                    <svg id="iconSun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none;"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
1261                    <svg id="iconMoon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none;"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
1262                    <span id="themeText">Theme</span>
1263                </button>
1264                <a href="?zip" class="btn btn-outline" title="Download entire folder as ZIP">
1265                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
1266                    Download ZIP
1267                </a>
1268            </div>
1269        </header>
1270
1271        <!-- Breadcrumbs -->
1272        <nav class="breadcrumbs-bar">
1273            {breadcrumbs_html}
1274        </nav>
1275
1276        {upload_section_html}
1277
1278        <!-- Table Card -->
1279        <main class="table-card">
1280            <table>
1281                <thead>
1282                    <tr>
1283                        <th class="col-icon"></th>
1284                        <th class="col-name" onclick="sortTable('name')" style="cursor:pointer;">Name &uarr;&darr;</th>
1285                        <th class="col-size" onclick="sortTable('size')" style="cursor:pointer;">Size &uarr;&darr;</th>
1286                        <th class="col-date" onclick="sortTable('date')" style="cursor:pointer;">Date Modified &uarr;&darr;</th>
1287                        <th class="col-actions">Actions</th>
1288                    </tr>
1289                </thead>
1290                <tbody id="entriesBody">
1291                    {parent_row_html}
1292                    {rows_html}
1293                </tbody>
1294            </table>
1295        </main>
1296
1297        <footer class="footer">
1298            Powered by LanFlare &bull; Dufs-compatible High Speed Local LAN File Server
1299        </footer>
1300    </div>
1301
1302    <script>
1303        function filterFiles() {{
1304            const val = document.getElementById('searchInput').value.toLowerCase().trim();
1305            const rows = document.querySelectorAll('.entry-row:not(.parent-row)');
1306            rows.forEach(r => {{
1307                const name = (r.getAttribute('data-name') || '').toLowerCase();
1308                r.style.display = name.includes(val) ? '' : 'none';
1309            }});
1310        }}
1311
1312        let sortDirections = {{}};
1313        function sortTable(key) {{
1314            const tbody = document.getElementById('entriesBody');
1315            const parentRow = tbody.querySelector('.parent-row');
1316            const rows = Array.from(tbody.querySelectorAll('.entry-row:not(.parent-row)'));
1317            const asc = !sortDirections[key];
1318            sortDirections[key] = asc;
1319
1320            rows.sort((a, b) => {{
1321                const isDirA = a.getAttribute('data-isdir') === 'true';
1322                const isDirB = b.getAttribute('data-isdir') === 'true';
1323                if (isDirA !== isDirB) return isDirA ? -1 : 1;
1324
1325                if (key === 'name') {{
1326                    const nameA = a.getAttribute('data-name').toLowerCase();
1327                    const nameB = b.getAttribute('data-name').toLowerCase();
1328                    return asc ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA);
1329                }} else if (key === 'size') {{
1330                    const sizeA = parseInt(a.querySelector('.col-size').getAttribute('data-bytes') || '0', 10);
1331                    const sizeB = parseInt(b.querySelector('.col-size').getAttribute('data-bytes') || '0', 10);
1332                    return asc ? sizeA - sizeB : sizeB - sizeA;
1333                }} else if (key === 'date') {{
1334                    const timeA = parseInt(a.querySelector('.col-date').getAttribute('data-time') || '0', 10);
1335                    const timeB = parseInt(b.querySelector('.col-date').getAttribute('data-time') || '0', 10);
1336                    return asc ? timeA - timeB : timeB - timeA;
1337                }}
1338                return 0;
1339            }});
1340
1341            rows.forEach(r => tbody.appendChild(r));
1342        }}
1343
1344        // Theme switcher
1345        function isDark() {{
1346            var t = document.documentElement.getAttribute('data-theme');
1347            if (t === 'dark') return true;
1348            if (t === 'light') return false;
1349            return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
1350        }}
1351        function updateThemeUI() {{
1352            var dark = isDark();
1353            var sun = document.getElementById('iconSun');
1354            var moon = document.getElementById('iconMoon');
1355            var text = document.getElementById('themeText');
1356            if (sun && moon) {{
1357                sun.style.display = dark ? 'inline-block' : 'none';
1358                moon.style.display = dark ? 'none' : 'inline-block';
1359            }}
1360            if (text) {{
1361                text.textContent = dark ? 'Light' : 'Dark';
1362            }}
1363        }}
1364        function toggleTheme() {{
1365            var next = isDark() ? 'light' : 'dark';
1366            document.documentElement.setAttribute('data-theme', next);
1367            try {{ localStorage.setItem('lanflare_theme', next); }} catch(e) {{}}
1368            updateThemeUI();
1369        }}
1370        updateThemeUI();
1371        if (window.matchMedia) {{
1372            window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {{
1373                if (!localStorage.getItem('lanflare_theme')) {{
1374                    updateThemeUI();
1375                }}
1376            }});
1377        }}
1378
1379        // Upload handler if enabled
1380        const fileInput = document.getElementById('fileInput');
1381        if (fileInput) {{
1382            fileInput.addEventListener('change', async () => {{
1383                const files = fileInput.files;
1384                if (!files || files.length === 0) return;
1385                for (const f of files) {{
1386                    const form = new FormData();
1387                    form.append('file', f);
1388                    try {{
1389                        const uploadUrl = window.location.pathname + (window.location.pathname.endsWith('/') ? '' : '/') + encodeURIComponent(f.name) + '?upload';
1390                        await fetch(uploadUrl, {{ method: 'PUT', body: f }});
1391                    }} catch (e) {{
1392                        console.error('Upload failed:', e);
1393                    }}
1394                }}
1395                window.location.reload();
1396            }});
1397
1398            // Drag and drop support
1399            window.addEventListener('dragover', e => e.preventDefault());
1400            window.addEventListener('drop', async e => {{
1401                e.preventDefault();
1402                const files = e.dataTransfer.files;
1403                if (!files || files.length === 0) return;
1404                for (const f of files) {{
1405                    try {{
1406                        const uploadUrl = window.location.pathname + (window.location.pathname.endsWith('/') ? '' : '/') + encodeURIComponent(f.name) + '?upload';
1407                        await fetch(uploadUrl, {{ method: 'PUT', body: f }});
1408                    }} catch (err) {{
1409                        console.error('Drop upload failed:', err);
1410                    }}
1411                }}
1412                window.location.reload();
1413            }});
1414        }}
1415    </script>
1416</body>
1417</html>"#
1418    );
1419
1420    Ok(html_response(StatusCode::OK, html))
1421}
1422
1423/// Handles uploading a file into the target directory.
1424pub async fn handle_upload_file(
1425    file_path: &Path,
1426    mut req: Request<Incoming>,
1427) -> Result<Response<ServerResponseBody>, StatusCode> {
1428    use http_body_util::BodyExt;
1429    use tokio::io::AsyncWriteExt;
1430
1431    if let Some(parent) = file_path.parent() {
1432        let _ = tokio::fs::create_dir_all(parent).await;
1433    }
1434
1435    let mut out_file = match tokio::fs::File::create(file_path).await {
1436        Ok(f) => f,
1437        Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
1438    };
1439
1440    let body = req.body_mut();
1441    while let Some(frame_res) = body.frame().await {
1442        let frame = match frame_res {
1443            Ok(f) => f,
1444            Err(_) => return Err(StatusCode::BAD_REQUEST),
1445        };
1446        if let Ok(data) = frame.into_data()
1447            && out_file.write_all(&data).await.is_err()
1448        {
1449            return Err(StatusCode::INTERNAL_SERVER_ERROR);
1450        }
1451    }
1452
1453    let _ = out_file.flush().await;
1454    Ok(Response::builder()
1455        .status(StatusCode::CREATED)
1456        .header(CONTENT_TYPE, "text/plain")
1457        .body(full_box_body("File uploaded successfully\n"))
1458        .unwrap())
1459}
1460
1461/// Handles deleting a file or directory.
1462pub async fn handle_delete_file(
1463    path: &Path,
1464) -> Result<Response<ServerResponseBody>, StatusCode> {
1465    if !path.exists() {
1466        return Err(StatusCode::NOT_FOUND);
1467    }
1468
1469    if path.is_dir() {
1470        if tokio::fs::remove_dir_all(path).await.is_ok() {
1471            Ok(empty_box_response(StatusCode::NO_CONTENT))
1472        } else {
1473            Err(StatusCode::INTERNAL_SERVER_ERROR)
1474        }
1475    } else {
1476        if tokio::fs::remove_file(path).await.is_ok() {
1477            Ok(empty_box_response(StatusCode::NO_CONTENT))
1478        } else {
1479            Err(StatusCode::INTERNAL_SERVER_ERROR)
1480        }
1481    }
1482}
1483
1484/// Dispatches an incoming HTTP request for the Dufs file server.
1485pub async fn handle_dufs_request(
1486    req: Request<Incoming>,
1487    _remote_ip: IpAddr,
1488    config: &DufsConfig,
1489) -> Result<Response<ServerResponseBody>, hyper::Error> {
1490    if !check_dufs_auth(&req, config) {
1491        return Ok(unauthorized_response(&config.title));
1492    }
1493
1494    let method = req.method();
1495    let uri_path = req.uri().path();
1496
1497    // Check if query is upload
1498    let is_upload = req
1499        .uri()
1500        .query()
1501        .map(|q| q.contains("upload"))
1502        .unwrap_or(false);
1503
1504    // Resolve target path safely
1505    let target_path = match resolve_and_validate_path(
1506        &config.root_path,
1507        uri_path,
1508        &config.path_prefix,
1509    ) {
1510        Ok(p) => p,
1511        Err(status) => return Ok(empty_box_response(status)),
1512    };
1513
1514    let response = match (method, is_upload) {
1515        // Upload file: PUT or POST with ?upload
1516        (&Method::PUT, _) | (&Method::POST, true) if config.allow_upload => {
1517            match handle_upload_file(&target_path, req).await {
1518                Ok(res) => res,
1519                Err(status) => empty_box_response(status),
1520            }
1521        }
1522
1523        // Delete file or folder
1524        (&Method::DELETE, _) if config.allow_delete => {
1525            match handle_delete_file(&target_path).await {
1526                Ok(res) => res,
1527                Err(status) => empty_box_response(status),
1528            }
1529        }
1530
1531        // GET or HEAD file or directory
1532        (&Method::GET, _) | (&Method::HEAD, _) => {
1533            if target_path.is_dir() {
1534                // Ensure directory URL ends with '/' for clean relative links
1535                if !uri_path.ends_with('/') {
1536                    let new_path = if let Some(q) = req.uri().query() {
1537                        format!("{uri_path}/?{q}")
1538                    } else {
1539                        format!("{uri_path}/")
1540                    };
1541                    return Ok(Response::builder()
1542                        .status(StatusCode::FOUND)
1543                        .header(hyper::header::LOCATION, new_path)
1544                        .body(full_box_body(Bytes::new()))
1545                        .unwrap());
1546                }
1547
1548                match serve_directory(&target_path, &config.root_path, &req, config).await {
1549                    Ok(res) => res,
1550                    Err(status) => empty_box_response(status),
1551                }
1552            } else if target_path.is_file() {
1553                match serve_file(&target_path, &req).await {
1554                    Ok(res) => res,
1555                    Err(status) => empty_box_response(status),
1556                }
1557            } else {
1558                empty_box_response(StatusCode::NOT_FOUND)
1559            }
1560        }
1561
1562        _ => empty_box_response(StatusCode::METHOD_NOT_ALLOWED),
1563    };
1564
1565    Ok(response)
1566}
1567
1568/// Starts an independent standalone HTTP server for dufs on the given port.
1569pub async fn start_standalone_dufs_server(
1570    config: DufsConfig,
1571    port: u16,
1572) -> anyhow::Result<DufsServerHandle> {
1573    let cancel_token = CancellationToken::new();
1574    let addr = SocketAddr::from(([0, 0, 0, 0], port));
1575    let listener = TcpListener::bind(addr).await?;
1576    let bound_port = listener.local_addr()?.port();
1577
1578    let config_arc = Arc::new(config);
1579    let cancel = cancel_token.clone();
1580
1581    let join_handle = tokio::spawn(async move {
1582        loop {
1583            tokio::select! {
1584                _ = cancel.cancelled() => break,
1585                accept_res = listener.accept() => {
1586                    let (stream, remote_addr) = match accept_res {
1587                        Ok(res) => res,
1588                        Err(_) => continue,
1589                    };
1590                    let cfg = config_arc.clone();
1591                    let io = hyper_util::rt::TokioIo::new(stream);
1592
1593                    tokio::spawn(async move {
1594                        let service = hyper::service::service_fn(move |req| {
1595                            let cfg = cfg.clone();
1596                            async move {
1597                                handle_dufs_request(req, remote_addr.ip(), &cfg).await
1598                            }
1599                        });
1600
1601                        let _ = hyper::server::conn::http1::Builder::new()
1602                            .max_buf_size(128 * 1024)
1603                            .serve_connection(io, service)
1604                            .await;
1605                    });
1606                }
1607            }
1608        }
1609    });
1610
1611    Ok(DufsServerHandle {
1612        port: bound_port,
1613        cancel_token,
1614        join_handle,
1615    })
1616}