bruschetta

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

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