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