bruschetta

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

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>&#128193;</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')">&#9776; List</button>
    132     <button id="btn-thumb" onclick="setView('thumb')">&#9638; 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">&#128193;</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">&#128193;</span><a href="{{ .Path }}">{{ .Name }}/</a>
    166               {{ else if isImage .Ext }}
    167                 <span class="file-icon img">&#128247;</span><a href="{{ .Path }}">{{ .Name }}</a>
    168               {{ else }}
    169                 <span class="file-icon">&#128196;</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">&#8593;</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">&#128193;</span>
    200               {{ else if isImage .Ext }}
    201                 <img src="/__thumb{{ .Path }}" alt="{{ .Name }}" loading="lazy">
    202               {{ else }}
    203                 <span class="big-icon">&#128196;</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) &nbsp;·&nbsp; {{ .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 }