bruschetta

A simple golang web based file browser
Log | Files | Refs | LICENSE

main.go (19392B)


      1 package main
      2 
      3 import (
      4 	"encoding/json"
      5 	"flag"
      6 	"fmt"
      7 	"html/template"
      8 	"image"
      9 	"image/color"
     10 	"image/draw"
     11 	"image/jpeg"
     12 	_ "image/gif"
     13 	_ "image/png"
     14 	"log"
     15 	"net/http"
     16 	"os"
     17 	"path/filepath"
     18 	"sort"
     19 	"strings"
     20 	"time"
     21 )
     22 
     23 var rootDir string
     24 
     25 type FileEntry struct {
     26 	Name    string
     27 	Path    string
     28 	IsDir   bool
     29 	Size    int64
     30 	ModTime time.Time
     31 	Ext     string
     32 }
     33 
     34 type TreeNode struct {
     35 	Name     string      `json:"name"`
     36 	Path     string      `json:"path"`
     37 	Children []*TreeNode `json:"children,omitempty"`
     38 }
     39 
     40 type Breadcrumb struct {
     41 	Name string
     42 	Path string
     43 }
     44 
     45 type PageData struct {
     46 	CurrentPath string
     47 	Breadcrumbs []Breadcrumb
     48 	Files       []FileEntry
     49 	TreeJSON    template.JS
     50 	View        string
     51 }
     52 
     53 const tmpl = `<!DOCTYPE html>
     54 <html lang="en">
     55 <head>
     56 <meta charset="UTF-8">
     57 <meta name="viewport" content="width=device-width, initial-scale=1.0">
     58 <title>{{ .CurrentPath }}</title>
     59 <style>
     60 *,::after,::before{box-sizing:border-box;margin:0;padding:0;list-style:none}
     61 a:hover{text-decoration:underline}
     62 body,html{height:100%;overflow:hidden}
     63 body{background:#1a1a1a;color:#d4d4d4;font-family:Consolas,Menlo,'DejaVu Sans Mono',monospace;font-size:13px;display:flex;flex-direction:column}
     64 footer{padding:6px 16px;color:#777}
     65 header{border-bottom:1px solid #333;padding:10px 16px;display:flex;align-items:center;gap:12px}
     66 main{flex:1;overflow-y:auto}
     67 @media (max-width: 640px) { .sidebar { display: none; } .file-table td.date { display: none; } .file-table td.size { min-width: 50px; } .thumb-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; padding: 8px; } }
     68 .breadcrumb .current{color:#777}
     69 .breadcrumb .sep{color:#777;margin:0 2px}
     70 .breadcrumb a,.file-table thead th:hover,.thumb-label a{color:#d4d4d4}
     71 .breadcrumb a:hover,.thumb-label a:hover,a{color:#5b9bd5;text-decoration:none}
     72 .breadcrumb,.tree-item,.view-toggle{display:flex;gap:4px}
     73 .breadcrumb{align-items:center;flex-wrap:wrap;flex:1}
     74 .empty{padding:48px;text-align:center;color:#777}
     75 .file-icon.dir,.thumb-preview .dir-icon{color:#c8a94e}
     76 .file-icon.img{color:#7cbf7c}
     77 .file-icon{margin-right:6px;color:#777}
     78 .file-table tbody tr,.file-table thead th{border-bottom:1px solid #333}
     79 .file-table tbody tr:hover,.tree-item.active,.tree-item:hover{background:#2e2e2e}
     80 .file-table td.date{color:#777;min-width:130px}
     81 .file-table td.name{width:100%;white-space:normal;word-break:break-all}
     82 .file-table td.size{color:#777;text-align:right;min-width:70px}
     83 .file-table td{padding:7px 12px;white-space:nowrap}
     84 .file-table thead th,.tree-item{cursor:pointer;user-select:none;white-space:nowrap}
     85 .file-table thead th.sort-asc::after{content:' ↑';color:#5b9bd5}
     86 .file-table thead th.sort-desc::after{content:' ↓';color:#5b9bd5}
     87 .file-table thead th{background:#222;padding:8px 12px;text-align:left;font-weight:400;color:#777;font-size:11px;letter-spacing:.08em;text-transform:uppercase}
     88 .file-table thead{position:sticky;top:0}
     89 .file-table{width:100%;border-collapse:collapse}
     90 .layout{display:flex;flex:1;overflow:hidden}
     91 .sidebar,.thumb-item,footer,header{background:#222}
     92 .sidebar{width:220px;min-width:220px;border-right:1px solid #333;overflow-y:auto;overflow-x:hidden;padding:8px 0;flex-shrink:0}
     93 .sidebar-title{font-size:10px;letter-spacing:.1em;color:#777;padding:4px 12px 8px;text-transform:uppercase}
     94 .thumb-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;padding:16px}
     95 .thumb-item:hover{border-color:#5b9bd5}
     96 .thumb-item{border:1px solid #333;overflow:hidden}
     97 .thumb-item a,.thumb-item a:hover{text-decoration:none}
     98 .thumb-label,footer{border-top:1px solid #333;font-size:11px}
     99 .thumb-label{padding:6px 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
    100 .thumb-meta{font-size:10px;color:#777;padding:0 8px 6px}
    101 .thumb-preview .big-icon{font-size:36px;color:#777}
    102 .thumb-preview img{width:100%;height:100%;object-fit:cover}
    103 .thumb-preview{width:100%;height:110px;display:flex;align-items:center;justify-content:center;background:#2a2a2a;overflow:hidden}
    104 .tree-children.open{display:block}
    105 .tree-children{display:none;padding-left:12px}
    106 .tree-icon{color:#777;flex-shrink:0}
    107 .tree-item.active{color:#5b9bd5}
    108 .tree-item{align-items:center;padding:3px 8px;overflow:hidden;color:#d4d4d4;font-size:12px}
    109 .tree-label{overflow:hidden;text-overflow:ellipsis}
    110 .tree-toggle{width:12px;text-align:center;color:#777;flex-shrink:0;font-size:10px}
    111 .view-toggle button.active{background:#1a1a1a;color:#5b9bd5;border-color:#5b9bd5}
    112 .view-toggle button:hover{background:#2e2e2e;color:#d4d4d4}
    113 .view-toggle button{background:#2a2a2a;border:1px solid #333;color:#777;padding:4px 10px;cursor:pointer;font-size:12px;font-family:inherit}
    114 .view-toggle{margin-left:auto}
    115 .filter-box{display:flex;align-items:center;gap:6px;margin-left:auto}
    116 .filter-box input{background:#2a2a2a;border:1px solid #333;color:#d4d4d4;font-family:inherit;font-size:12px;padding:4px 10px;width:180px;outline:none}
    117 .filter-box input::placeholder{color:#555}
    118 .filter-box input:focus{border-color:#5b9bd5}
    119 .filter-clear{background:none;border:none;color:#555;cursor:pointer;font-size:14px;padding:0 2px;line-height:1}
    120 .filter-clear:hover{color:#d4d4d4}
    121 </style>
    122 </head>
    123 <body>
    124 
    125 <header>
    126   <span>&#128193;</span>
    127   <div class="breadcrumb">
    128     {{ range $i, $crumb := .Breadcrumbs }}
    129       {{ if gt $i 0 }}<span class="sep">/</span>{{ end }}
    130       {{ if eq $i (sub1 (len $.Breadcrumbs)) }}
    131         <span class="current">{{ $crumb.Name }}</span>
    132       {{ else }}
    133         <a href="{{ viewSuffix $.View $crumb.Path }}">{{ $crumb.Name }}</a>
    134       {{ end }}
    135     {{ end }}
    136   </div>
    137   <div class="filter-box">
    138     <input type="text" id="filter-input" placeholder="Filter..." oninput="filterView(this.value)" autocomplete="off" spellcheck="false">
    139     <button class="filter-clear" id="filter-clear" onclick="clearFilter()" title="Clear filter" style="display:none">&#10005;</button>
    140   </div>
    141   <div class="view-toggle">
    142     <button id="btn-list" class="{{ if eq .View "list" }}active{{ end }}" onclick="setView('list')">&#9776; List</button>
    143     <button id="btn-thumb" class="{{ if eq .View "thumb" }}active{{ end }}" onclick="setView('thumb')">&#9638; Thumbs</button>
    144   </div>
    145 </header>
    146 
    147 <div class="layout">
    148   <nav class="sidebar">
    149     <div class="sidebar-title">Folders</div>
    150     <ul id="tree-root"></ul>
    151   </nav>
    152 
    153   <main>
    154     <div id="view-list"{{ if eq .View "thumb" }} style="display:none"{{ end }}>
    155       {{ if .Files }}
    156       <table class="file-table" id="file-table">
    157         <thead>
    158           <tr>
    159             <th class="name" onclick="sortTable(0)">Name</th>
    160             <th class="size" onclick="sortTable(1)">Size</th>
    161             <th class="date" onclick="sortTable(2)">Modified</th>
    162           </tr>
    163         </thead>
    164         <tbody>
    165           {{ if ne .CurrentPath "/" }}
    166           <tr>
    167             <td class="name"><span class="file-icon dir">&#128193;</span><a href="{{ viewSuffix $.View "../" }}">../</a></td>
    168             <td class="size">—</td>
    169             <td class="date">—</td>
    170           </tr>
    171           {{ end }}
    172           {{ range .Files }}
    173           <tr data-name="{{ .Name }}" data-size="{{ .Size }}" data-date="{{ .ModTime.Unix }}">
    174             <td class="name">
    175               {{ if .IsDir }}
    176                 <span class="file-icon dir">&#128193;</span><a href="{{ viewSuffix $.View .Path }}">{{ .Name }}/</a>
    177               {{ else if isImage .Ext }}
    178                 <span class="file-icon img">&#128247;</span><a href="{{ .Path }}">{{ .Name }}</a>
    179               {{ else }}
    180                 <span class="file-icon">&#128196;</span><a href="{{ .Path }}">{{ .Name }}</a>
    181               {{ end }}
    182             </td>
    183             <td class="size">{{ if .IsDir }}—{{ else }}{{ formatSize .Size }}{{ end }}</td>
    184             <td class="date">{{ formatTime .ModTime }}</td>
    185           </tr>
    186           {{ end }}
    187         </tbody>
    188       </table>
    189       {{ else }}
    190       <div class="empty">Empty directory</div>
    191       {{ end }}
    192     </div>
    193 
    194     <div id="view-thumb"{{ if eq .View "list" }} style="display:none"{{ end }}>
    195       {{ if .Files }}
    196       <div class="thumb-grid">
    197         {{ if ne .CurrentPath "/" }}
    198         <div class="thumb-item">
    199           <a href="{{ viewSuffix $.View "../" }}" style="display:block">
    200             <div class="thumb-preview"><span class="big-icon dir-icon">&#8593;</span></div>
    201             <div class="thumb-label">../</div>
    202           </a>
    203         </div>
    204         {{ end }}
    205         {{ range .Files }}
    206         <div class="thumb-item">
    207           <a href="{{ if .IsDir }}{{ viewSuffix $.View .Path }}{{ else }}{{ .Path }}{{ end }}" style="display:block">
    208             <div class="thumb-preview">
    209               {{ if .IsDir }}
    210                 <span class="big-icon dir-icon">&#128193;</span>
    211               {{ else if isImage .Ext }}
    212                 <img src="/__thumb{{ .Path }}" alt="{{ .Name }}" loading="lazy">
    213               {{ else }}
    214                 <span class="big-icon">&#128196;</span>
    215               {{ end }}
    216             </div>
    217             <div class="thumb-label" title="{{ .Name }}">{{ .Name }}{{ if .IsDir }}/{{ end }}</div>
    218             <div class="thumb-meta">{{ if .IsDir }}folder{{ else }}{{ formatSize .Size }}{{ end }}</div>
    219           </a>
    220         </div>
    221         {{ end }}
    222       </div>
    223       {{ else }}
    224       <div class="empty">Empty directory</div>
    225       {{ end }}
    226     </div>
    227   </main>
    228 </div>
    229 
    230 <footer>{{ len .Files }} item(s) &nbsp;·&nbsp; {{ .CurrentPath }}</footer>
    231 
    232 <script>
    233 const TREE_DATA = {{ .TreeJSON }};
    234 const CURRENT_PATH = '{{ .CurrentPath }}';
    235 const VIEW = '{{ .View }}';
    236 
    237 function setView(v) {
    238   if (v === VIEW) return;
    239   window.location.href = v === 'thumb' ? window.location.pathname + '?v=thumb' : window.location.pathname;
    240 }
    241 
    242 function filterView(query) {
    243   const q = query.trim().toLowerCase();
    244   document.getElementById('filter-clear').style.display = q ? '' : 'none';
    245 
    246   // Filter rows, skipping parent
    247   document.querySelectorAll('#file-table tbody tr').forEach(row => {
    248     const name = (row.dataset.name || '').toLowerCase();
    249     if (!name) { row.style.display = ''; return; }
    250     row.style.display = (!q || name.includes(q)) ? '' : 'none';
    251   });
    252 
    253   document.querySelectorAll('#view-thumb .thumb-item').forEach(item => {
    254     const label = item.querySelector('.thumb-label');
    255     if (!label) { item.style.display = ''; return; }
    256     const name = label.textContent.trim().toLowerCase();
    257     // The ../ item text is "../" — keep it always visible
    258     if (name === '../') { item.style.display = ''; return; }
    259     item.style.display = (!q || name.includes(q)) ? '' : 'none';
    260   });
    261 }
    262 
    263 function clearFilter() {
    264   const input = document.getElementById('filter-input');
    265   input.value = '';
    266   filterView('');
    267   input.focus();
    268 }
    269 
    270 function viewHref(path) {
    271   return VIEW === 'thumb' ? path + '?v=thumb' : path;
    272 }
    273 
    274 let sortCol = -1, sortDir = 1;
    275 function sortTable(col) {
    276   const tbody = document.querySelector('#file-table tbody');
    277   const rows = Array.from(tbody.querySelectorAll('tr'));
    278   const parentRow = rows.find(r => r.querySelector('a[href="../"]'));
    279   const dataRows = rows.filter(r => r !== parentRow);
    280 
    281   if (sortCol === col) sortDir *= -1;
    282   else { sortCol = col; sortDir = 1; }
    283 
    284   dataRows.sort((a, b) => {
    285     let av, bv;
    286     if (col === 0) { av = (a.dataset.name||'').toLowerCase(); bv = (b.dataset.name||'').toLowerCase(); }
    287     else if (col === 1) { av = parseInt(a.dataset.size||0); bv = parseInt(b.dataset.size||0); }
    288     else { av = parseInt(a.dataset.date||0); bv = parseInt(b.dataset.date||0); }
    289     return av < bv ? -sortDir : av > bv ? sortDir : 0;
    290   });
    291 
    292   document.querySelectorAll('#file-table thead th').forEach((th, i) => {
    293     th.className = th.className.replace(/ ?sort-(asc|desc)/g, '');
    294     if (i === col) th.className += sortDir > 0 ? ' sort-asc' : ' sort-desc';
    295   });
    296 
    297   tbody.innerHTML = '';
    298   if (parentRow) tbody.appendChild(parentRow);
    299   dataRows.forEach(r => tbody.appendChild(r));
    300 }
    301 
    302 function buildTree(nodes, container, depth) {
    303   if (!nodes || !nodes.length) return;
    304   nodes.forEach(node => {
    305     const li = document.createElement('li');
    306     const item = document.createElement('div');
    307     item.className = 'tree-item' + (node.path === CURRENT_PATH ? ' active' : '');
    308     item.style.paddingLeft = (8 + depth * 12) + 'px';
    309 
    310     const toggle = document.createElement('span');
    311     toggle.className = 'tree-toggle';
    312     toggle.textContent = (node.children && node.children.length) ? '▶' : ' ';
    313 
    314     const icon = document.createElement('span');
    315     icon.className = 'tree-icon';
    316     icon.textContent = '📁';
    317 
    318     const label = document.createElement('span');
    319     label.className = 'tree-label';
    320     label.textContent = node.name;
    321 
    322     item.append(toggle, icon, label);
    323     li.appendChild(item);
    324 
    325     let childUl = null;
    326     if (node.children && node.children.length) {
    327       childUl = document.createElement('ul');
    328       childUl.className = 'tree-children';
    329       buildTree(node.children, childUl, depth + 1);
    330       li.appendChild(childUl);
    331       toggle.addEventListener('click', e => {
    332         e.stopPropagation();
    333         toggle.textContent = childUl.classList.toggle('open') ? '▼' : '▶';
    334       });
    335     }
    336 
    337     item.addEventListener('click', () => { window.location.href = viewHref(node.path); });
    338 
    339     if (CURRENT_PATH.startsWith(node.path) && childUl) {
    340       childUl.classList.add('open');
    341       toggle.textContent = '▼';
    342     }
    343 
    344     container.appendChild(li);
    345   });
    346 }
    347 
    348 buildTree(TREE_DATA, document.getElementById('tree-root'), 0);
    349 </script>
    350 </body>
    351 </html>`
    352 
    353 func main() {
    354 	var dir string
    355 	var port int
    356 	flag.StringVar(&dir, "d", ".", "Directory to serve")
    357 	flag.IntVar(&port, "p", 8080, "Port to listen on")
    358 	flag.Parse()
    359 
    360 	absDir, err := filepath.Abs(dir)
    361 	if err != nil {
    362 		log.Fatalf("Invalid directory: %v", err)
    363 	}
    364 	if _, err := os.Stat(absDir); os.IsNotExist(err) {
    365 		log.Fatalf("Directory does not exist: %s", absDir)
    366 	}
    367 
    368 	rootDir = absDir
    369 	log.Printf("Serving %s on :%d", rootDir, port)
    370 
    371 	http.HandleFunc("/__thumb/", thumbHandler)
    372 	http.HandleFunc("/", listingHandler)
    373 
    374 	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), nil))
    375 }
    376 
    377 func safeFS(urlPath string) (string, bool) {
    378 	fsPath := filepath.Join(rootDir, filepath.FromSlash(urlPath))
    379 	clean := filepath.Clean(fsPath)
    380 	rootClean := filepath.Clean(rootDir)
    381 	return clean, strings.HasPrefix(clean, rootClean+string(os.PathSeparator)) || clean == rootClean
    382 }
    383 
    384 func listingHandler(w http.ResponseWriter, r *http.Request) {
    385 	urlPath := r.URL.Path
    386 	if urlPath == "" {
    387 		urlPath = "/"
    388 	}
    389 
    390 	fsPath, ok := safeFS(urlPath)
    391 	if !ok {
    392 		http.Error(w, "Forbidden", http.StatusForbidden)
    393 		return
    394 	}
    395 
    396 	info, err := os.Stat(fsPath)
    397 	if err != nil {
    398 		http.NotFound(w, r)
    399 		return
    400 	}
    401 
    402 	if !info.IsDir() {
    403 		http.ServeFile(w, r, fsPath)
    404 		return
    405 	}
    406 
    407 	if !strings.HasSuffix(r.URL.Path, "/") {
    408 		redir := r.URL.Path + "/"
    409 		if v := r.URL.Query().Get("v"); v == "thumb" {
    410 			redir += "?v=thumb"
    411 		}
    412 		http.Redirect(w, r, redir, http.StatusMovedPermanently)
    413 		return
    414 	}
    415 
    416 	entries, err := os.ReadDir(fsPath)
    417 	if err != nil {
    418 		http.Error(w, "Cannot read directory", http.StatusInternalServerError)
    419 		return
    420 	}
    421 
    422 	var files []FileEntry
    423 	for _, e := range entries {
    424 		name := e.Name()
    425 		if strings.HasPrefix(name, ".") {
    426 			continue
    427 		}
    428 		fi, err := e.Info()
    429 		if err != nil {
    430 			continue
    431 		}
    432 		entryPath := urlPath + name
    433 		if e.IsDir() {
    434 			entryPath += "/"
    435 		}
    436 		ext := ""
    437 		if !e.IsDir() {
    438 			ext = strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
    439 		}
    440 		files = append(files, FileEntry{
    441 			Name:    name,
    442 			Path:    entryPath,
    443 			IsDir:   e.IsDir(),
    444 			Size:    fi.Size(),
    445 			ModTime: fi.ModTime(),
    446 			Ext:     ext,
    447 		})
    448 	}
    449 
    450 	sort.Slice(files, func(i, j int) bool {
    451 		if files[i].IsDir != files[j].IsDir {
    452 			return files[i].IsDir
    453 		}
    454 		return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
    455 	})
    456 
    457 	tree := buildTree(rootDir, "/")
    458 	treeBytes, _ := json.Marshal(tree)
    459 
    460 	view := r.URL.Query().Get("v")
    461 	if view != "thumb" {
    462 		view = "list"
    463 	}
    464 
    465 	data := PageData{
    466 		CurrentPath: urlPath,
    467 		Breadcrumbs: buildBreadcrumbs(urlPath),
    468 		Files:       files,
    469 		TreeJSON:    template.JS(treeBytes),
    470 		View:        view,
    471 	}
    472 
    473 	funcMap := template.FuncMap{
    474 		"formatSize": formatSize,
    475 		"formatTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
    476 		"isImage":    isImageExt,
    477 		"sub1":       func(n int) int { return n - 1 },
    478 		"viewSuffix": func(view, path string) string {
    479 			if view == "thumb" {
    480 				return path + "?v=thumb"
    481 			}
    482 			return path
    483 		},
    484 	}
    485 
    486 	t, err := template.New("page").Funcs(funcMap).Parse(tmpl)
    487 	if err != nil {
    488 		http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError)
    489 		return
    490 	}
    491 
    492 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
    493 	if err := t.Execute(w, data); err != nil {
    494 		log.Printf("Template error: %v", err)
    495 	}
    496 }
    497 
    498 func thumbHandler(w http.ResponseWriter, r *http.Request) {
    499 	urlPath := strings.TrimPrefix(r.URL.Path, "/__thumb")
    500 	fsPath, ok := safeFS(urlPath)
    501 	if !ok {
    502 		http.Error(w, "Forbidden", http.StatusForbidden)
    503 		return
    504 	}
    505 
    506 	f, err := os.Open(fsPath)
    507 	if err != nil {
    508 		servePlaceholder(w)
    509 		return
    510 	}
    511 	defer f.Close()
    512 
    513 	img, _, err := image.Decode(f)
    514 	if err != nil {
    515 		servePlaceholder(w)
    516 		return
    517 	}
    518 
    519 	thumb := resizeNN(img, 200, 150)
    520 	w.Header().Set("Content-Type", "image/jpeg")
    521 	w.Header().Set("Cache-Control", "max-age=3600")
    522 	jpeg.Encode(w, thumb, &jpeg.Options{Quality: 75})
    523 }
    524 
    525 func resizeNN(src image.Image, maxW, maxH int) image.Image {
    526 	b := src.Bounds()
    527 	srcW, srcH := b.Dx(), b.Dy()
    528 	scale := float64(maxW) / float64(srcW)
    529 	if s := float64(maxH) / float64(srcH); s < scale {
    530 		scale = s
    531 	}
    532 	newW := int(float64(srcW)*scale + 0.5)
    533 	newH := int(float64(srcH)*scale + 0.5)
    534 	if newW < 1 { newW = 1 }
    535 	if newH < 1 { newH = 1 }
    536 	dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
    537 	for y := 0; y < newH; y++ {
    538 		for x := 0; x < newW; x++ {
    539 			dst.Set(x, y, src.At(b.Min.X+int(float64(x)/scale), b.Min.Y+int(float64(y)/scale)))
    540 		}
    541 	}
    542 	return dst
    543 }
    544 
    545 func servePlaceholder(w http.ResponseWriter) {
    546 	img := image.NewRGBA(image.Rect(0, 0, 200, 150))
    547 	draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{40, 40, 40, 255}}, image.Point{}, draw.Src)
    548 	w.Header().Set("Content-Type", "image/jpeg")
    549 	jpeg.Encode(w, img, &jpeg.Options{Quality: 60})
    550 }
    551 
    552 func buildBreadcrumbs(urlPath string) []Breadcrumb {
    553 	crumbs := []Breadcrumb{{Name: "root", Path: "/"}}
    554 	cur := "/"
    555 	for _, p := range strings.Split(strings.Trim(urlPath, "/"), "/") {
    556 		if p == "" { continue }
    557 		cur = cur + p + "/"
    558 		crumbs = append(crumbs, Breadcrumb{Name: p, Path: cur})
    559 	}
    560 	return crumbs
    561 }
    562 
    563 func buildTree(basePath, urlBase string) []*TreeNode {
    564 	entries, err := os.ReadDir(basePath)
    565 	if err != nil { return nil }
    566 	var nodes []*TreeNode
    567 	for _, e := range entries {
    568 		if !e.IsDir() { continue }
    569 		name := e.Name()
    570 		if strings.HasPrefix(name, ".") { continue }
    571 		nodePath := urlBase + name + "/"
    572 		node := &TreeNode{Name: name, Path: nodePath}
    573 		node.Children = buildTree(filepath.Join(basePath, name), nodePath)
    574 		nodes = append(nodes, node)
    575 	}
    576 	return nodes
    577 }
    578 
    579 func isImageExt(ext string) bool {
    580 	switch ext {
    581 	case "jpg", "jpeg", "png", "gif", "webp", "bmp", "avif":
    582 		return true
    583 	}
    584 	return false
    585 }
    586 
    587 func formatSize(n int64) string {
    588 	if n < 1024 { return fmt.Sprintf("%d B", n) }
    589 	div, exp := int64(1024), 0
    590 	for v := n / 1024; v >= 1024; v /= 1024 { div *= 1024; exp++ }
    591 	return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
    592 }