1use 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
36pub type ServerResponseBody = BoxBody<Bytes, Box<dyn std::error::Error + Send + Sync>>;
38
39pub 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
46pub 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
52pub 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
85pub 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
93pub 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
101pub 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
110pub 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#[derive(Clone, Debug, Serialize, Deserialize)]
122pub struct DufsConfig {
123 pub root_path: PathBuf,
125 #[serde(default)]
127 pub path_prefix: String,
128 #[serde(default)]
130 pub allow_upload: bool,
131 #[serde(default)]
133 pub allow_delete: bool,
134 #[serde(default)]
136 pub auth_pin: Option<String>,
137 #[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#[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#[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 pub async fn set_config(&self, config: DufsConfig) {
172 let mut guard = self.active_config.write().await;
173 *guard = Some(config);
174 }
175
176 pub async fn clear_config(&self) {
178 let mut guard = self.active_config.write().await;
179 *guard = None;
180 }
181
182 pub async fn get_config(&self) -> Option<DufsConfig> {
184 self.active_config.read().await.clone()
185 }
186
187 pub async fn is_active(&self) -> bool {
189 self.active_config.read().await.is_some()
190 }
191}
192
193pub 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
206pub 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
229pub 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 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 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 return Err(StatusCode::FORBIDDEN);
266 }
267 Component::RootDir | Component::Prefix(_) => {
268 return Err(StatusCode::FORBIDDEN);
270 }
271 }
272 }
273
274 let candidate = root.join(&safe_rel);
275
276 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 #[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.starts_with(root) {
299 Ok(candidate)
300 } else {
301 Err(StatusCode::FORBIDDEN)
302 }
303}
304
305pub 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 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
345pub 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, };
351
352 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 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 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
392pub 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
405pub 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
423pub fn escape_html(input: &str) -> String {
425 input
426 .replace('&', "&")
427 .replace('<', "<")
428 .replace('>', ">")
429 .replace('"', """)
430 .replace('\'', "'")
431}
432
433pub 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 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
554pub 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 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
634fn 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
662pub 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 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 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 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 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 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 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 ↑↓</th>
1285 <th class="col-size" onclick="sortTable('size')" style="cursor:pointer;">Size ↑↓</th>
1286 <th class="col-date" onclick="sortTable('date')" style="cursor:pointer;">Date Modified ↑↓</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 • 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
1423pub 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
1461pub 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
1484pub 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 let is_upload = req
1499 .uri()
1500 .query()
1501 .map(|q| q.contains("upload"))
1502 .unwrap_or(false);
1503
1504 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 (&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 (&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 (&Method::GET, _) | (&Method::HEAD, _) => {
1533 if target_path.is_dir() {
1534 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
1568pub 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}