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>📁</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">✕</button> 142 </div> 143 <div class="view-toggle"> 144 <button id="btn-list" class="{{ if eq .View "list" }}active{{ end }}" onclick="setView('list')">☰ List</button> 145 <button id="btn-thumb" class="{{ if eq .View "thumb" }}active{{ end }}" onclick="setView('thumb')">▦ 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">📁</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">📁</span><a href="{{ viewSuffix $.View .Path }}">{{ .Name }}/</a> 179 {{ else if isImage .Ext }} 180 <span class="file-icon img">📷</span><a href="{{ .Path }}">{{ .Name }}</a> 181 {{ else }} 182 <span class="file-icon">📄</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">↑</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">📁</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">📄</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) · {{ .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 }