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