package handlers
import (
"fmt"
"html/template"
"io"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"isosilo/internal/iso"
)
type Handler struct {
dir string
tmpl *template.Template
staticFS http.FileSystem
}
// breadcrumb represents a link in the navigation chain
type breadcrumb struct {
Name string
URL string
}
func New(dir string) *Handler {
h := &Handler{dir: dir}
h.staticFS = http.FS(os.DirFS("static"))
h.tmpl = template.Must(
template.New("").Funcs(template.FuncMap{
"humanSize": humanSize,
"fileIcon": fileIcon,
"urlenc": url.PathEscape,
"base": filepath.Base,
"add1": func(i int) int { return i + 1 },
"trimExt": func(s string) string { return strings.TrimSuffix(s, filepath.Ext(s)) },
}).ParseGlob("templates/*.html"),
)
return h
}
type libraryEntry struct {
Name string
RelativePath string
IsDir bool
IsISO bool
Size int64
ModTime time.Time
Description string
HasImage bool
ImageExt string
}
func (h *Handler) ListISOs(w http.ResponseWriter, r *http.Request) {
relDir := strings.TrimPrefix(r.URL.Path, "/")
fullPath := filepath.Join(h.dir, relDir)
if strings.Contains(relDir, "..") {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
entries, err := os.ReadDir(fullPath)
if err != nil {
http.Error(w, "Directory not found", http.StatusNotFound)
return
}
var items []libraryEntry
for _, e := range entries {
name := e.Name()
relPath := filepath.ToSlash(filepath.Join(relDir, name))
info, _ := e.Info()
isISO := !e.IsDir() && strings.EqualFold(filepath.Ext(name), ".iso")
item := libraryEntry{
Name: name,
RelativePath: relPath,
IsDir: e.IsDir(),
IsISO: isISO,
ModTime: info.ModTime(),
Size: info.Size(),
}
if isISO {
basePath := filepath.Join(h.dir, relDir, strings.TrimSuffix(name, ".iso"))
if d, err := os.ReadFile(basePath + ".txt"); err == nil {
item.Description = string(d)
}
for _, ext := range []string{".png", ".jpg", ".jpeg"} {
if _, err := os.Stat(basePath + ext); err == nil {
item.HasImage = true
item.ImageExt = ext
break
}
}
}
if item.IsDir || item.IsISO {
items = append(items, item)
}
}
sort.Slice(items, func(i, j int) bool {
if items[i].IsDir != items[j].IsDir {
return items[i].IsDir
}
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
})
h.tmpl.ExecuteTemplate(w, "index.html", map[string]any{
"Title": "ISO Library",
"Items": items,
"CurrentPath": relDir,
"Breadcrumbs": buildLibraryBreadcrumbs(relDir),
})
}
func (h *Handler) BrowseISO(w http.ResponseWriter, r *http.Request) {
isoPath, internalPath, ok := parsePath(r.URL.Path, "/browse/")
if !ok {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
reader, err := h.openISO(isoPath)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
defer reader.Close()
entries, err := reader.ListDir(internalPath)
if err != nil {
http.Error(w, "cannot list directory", http.StatusNotFound)
return
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDir != entries[j].IsDir {
return entries[i].IsDir
}
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
})
h.tmpl.ExecuteTemplate(w, "browse.html", map[string]any{
"Title": filepath.Base(isoPath),
"ISOName": isoPath,
"InternalPath": internalPath,
"Entries": entries,
"Breadcrumbs": buildISOBreadcrumbs(isoPath, internalPath),
})
}
func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request) {
isoPath, internalPath, ok := parsePath(r.URL.Path, "/file/")
if !ok || internalPath == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
reader, err := h.openISO(isoPath)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
defer reader.Close()
rc, size, err := reader.FileReader(internalPath)
if err != nil {
http.Error(w, "file not found", http.StatusNotFound)
return
}
defer rc.Close()
filename := filepath.Base(internalPath)
ext := strings.ToLower(filepath.Ext(filename))
ct := mime.TypeByExtension(ext)
if ct == "" {
ct = "application/octet-stream"
}
viewable := false
switch ext {
case ".pdf", ".txt", ".jpg", ".jpeg", ".png", ".gif", ".mp4", ".mp3", ".webp":
viewable = true
}
if !viewable {
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
} else {
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename=%q`, filename))
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
io.Copy(w, rc)
}
func (h *Handler) RawFile(w http.ResponseWriter, r *http.Request) {
fileName := strings.TrimPrefix(r.URL.Path, "/raw/")
http.ServeFile(w, r, filepath.Join(h.dir, fileName))
}
func (h *Handler) ServeStatic(w http.ResponseWriter, r *http.Request) {
// Serve static files with directory listing completely disabled
filePath := strings.TrimPrefix(r.URL.Path, "/static/")
if filePath == "" || strings.HasSuffix(filePath, "/") || strings.Contains(filePath, "..") {
http.NotFound(w, r)
return
}
f, err := h.staticFS.Open(filePath)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
http.NotFound(w, r)
return
}
if stat.IsDir() {
http.NotFound(w, r)
return
}
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
}
func (h *Handler) openISO(relPath string) (*iso.Reader, error) {
return iso.Open(filepath.Join(h.dir, relPath))
}
func parsePath(urlPath, prefix string) (isoPath, internalPath string, ok bool) {
rest := strings.TrimPrefix(urlPath, prefix)
decoded, _ := url.PathUnescape(rest)
lower := strings.ToLower(decoded)
idx := strings.Index(lower, ".iso")
if idx == -1 {
return decoded, "", true
}
return decoded[:idx+4], strings.TrimPrefix(decoded[idx+4:], "/"), true
}
func buildLibraryBreadcrumbs(relDir string) []breadcrumb {
crumbs := []breadcrumb{{Name: "Library", URL: "/"}}
if relDir == "" {
return crumbs
}
acc := ""
for _, p := range strings.Split(relDir, "/") {
if p == "" {
continue
}
if acc == "" {
acc = p
} else {
acc = filepath.Join(acc, p)
}
crumbs = append(crumbs, breadcrumb{Name: p, URL: "/" + filepath.ToSlash(acc)})
}
return crumbs
}
func buildISOBreadcrumbs(isoPath, internalPath string) []breadcrumb {
parentDir := filepath.Dir(isoPath)
var crumbs []breadcrumb
if parentDir == "." {
crumbs = []breadcrumb{{Name: "Library", URL: "/"}}
} else {
crumbs = buildLibraryBreadcrumbs(parentDir)
}
crumbs = append(crumbs, breadcrumb{Name: filepath.Base(isoPath), URL: "/browse/" + url.PathEscape(isoPath)})
if internalPath != "" {
acc := ""
for _, p := range strings.Split(internalPath, "/") {
if p == "" {
continue
}
if acc == "" {
acc = p
} else {
acc += "/" + p
}
crumbs = append(crumbs, breadcrumb{Name: p, URL: "/browse/" + url.PathEscape(isoPath) + "/" + acc})
}
}
return crumbs
}
func humanSize(n int64) string {
if n < 1024 {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(1024), 0
for v := n / 1024; v >= 1024; v /= 1024 {
div *= 1024
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
}
func fileIcon(name string) string {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".iso":
return "💿"
case ".pdf":
return "📄"
case ".jpg", ".png", ".jpeg":
return "🖼️"
case ".txt", ".md":
return "📝"
default:
return "📄"
}
}