bruschetta

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

main.go (20191B)


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