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>📁</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">✕</button> 140 </div> 141 <div class="view-toggle"> 142 <button id="btn-list" class="{{ if eq .View "list" }}active{{ end }}" onclick="setView('list')">☰ List</button> 143 <button id="btn-thumb" class="{{ if eq .View "thumb" }}active{{ end }}" onclick="setView('thumb')">▦ 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">📁</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">📁</span><a href="{{ viewSuffix $.View .Path }}">{{ .Name }}/</a> 177 {{ else if isImage .Ext }} 178 <span class="file-icon img">📷</span><a href="{{ .Path }}">{{ .Name }}</a> 179 {{ else }} 180 <span class="file-icon">📄</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">↑</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">📁</span> 211 {{ else if isImage .Ext }} 212 <img src="/__thumb{{ .Path }}" alt="{{ .Name }}" loading="lazy"> 213 {{ else }} 214 <span class="big-icon">📄</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) · {{ .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 }