diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 2a5ef60..af3df85 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -1,4 +1,3 @@ -// Package handlers provides HTTP handler functions for the ISO server. package handlers import ( @@ -18,13 +17,11 @@ import ( "isosilo/internal/iso" ) -// Handler holds shared state for all HTTP handlers. type Handler struct { dir string tmpl *template.Template } -// New creates a Handler that serves ISOs from the given directory. func New(dir string) *Handler { h := &Handler{dir: dir} h.tmpl = template.Must( @@ -40,101 +37,98 @@ func New(dir string) *Handler { return h } -// ----------------------------------------------------------------------- -// Route: GET / → list all ISO files in the directory -// ----------------------------------------------------------------------- - -type isoInfo struct { - Name string - Size int64 - ModTime time.Time - Description string - HasImage bool - ImageExt string +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) { - if r.URL.Path != "/" { - http.NotFound(w, r) + // The path after / is the directory we are browsing in the library + relDir := strings.TrimPrefix(r.URL.Path, "/") + fullPath := filepath.Join(h.dir, relDir) + + // Security check + if strings.Contains(relDir, "..") { + http.Error(w, "Invalid path", http.StatusBadRequest) return } - entries, err := os.ReadDir(h.dir) + entries, err := os.ReadDir(fullPath) if err != nil { - http.Error(w, "cannot read directory", http.StatusInternalServerError) + http.Error(w, "Directory not found", http.StatusNotFound) return } - var isos []isoInfo + var items []libraryEntry for _, e := range entries { - if e.IsDir() || !strings.EqualFold(filepath.Ext(e.Name()), ".iso") { - continue - } - info, err := e.Info() - if err != nil { - continue + 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(), } - baseName := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) - - // Look for Description (.txt) - desc, _ := os.ReadFile(filepath.Join(h.dir, baseName+".txt")) - - // Look for Image (.png, .jpg, .jpeg) - hasImg := false - imgExt := "" - for _, ext := range []string{".png", ".jpg", ".jpeg"} { - if _, err := os.Stat(filepath.Join(h.dir, baseName+ext)); err == nil { - hasImg = true - imgExt = ext - break + if isISO { + basePath := filepath.Join(h.dir, relDir, strings.TrimSuffix(name, ".iso")) + // Metadata + if d, err := os.ReadFile(basePath + ".txt"); err == nil { + item.Description = string(d) + } + // Cover Image + for _, ext := range []string{".png", ".jpg", ".jpeg"} { + if _, err := os.Stat(basePath + ext); err == nil { + item.HasImage = true + item.ImageExt = ext + break + } } } - isos = append(isos, isoInfo{ - Name: e.Name(), - Size: info.Size(), - ModTime: info.ModTime(), - Description: string(desc), - HasImage: hasImg, - ImageExt: imgExt, - }) + // Only show Directories or ISO files + if item.IsDir || item.IsISO { + items = append(items, item) + } } - sort.Slice(isos, func(i, j int) bool { - return strings.ToLower(isos[i].Name) < strings.ToLower(isos[j].Name) + 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) }) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := h.tmpl.ExecuteTemplate(w, "index", map[string]any{ - "Title": "ISO Library", - "ISOs": isos, - "Dir": h.dir, - }); err != nil { - log.Printf("template/index error: %v", err) - } + h.tmpl.ExecuteTemplate(w, "index", map[string]any{ + "Title": "ISO Library", + "Items": items, + "CurrentPath": relDir, + "Breadcrumbs": buildLibraryBreadcrumbs(relDir), + }) } -// ----------------------------------------------------------------------- -// Route: GET /browse/{iso}[/{path...}] → browse inside an ISO -// ----------------------------------------------------------------------- - -type browseData struct { - Title string - ISOName string - InternalPath string - Entries []iso.Entry - Breadcrumbs []breadcrumb -} +// ... BrowseISO, DownloadFile, and RawFile remain largely same as previous version ... +// ... Ensure BrowseISO and DownloadFile use the full relative path to open the ISO ... func (h *Handler) BrowseISO(w http.ResponseWriter, r *http.Request) { - isoName, internalPath, ok := parsePath(r.URL.Path, "/browse/") + isoPath, internalPath, ok := parsePath(r.URL.Path, "/browse/") if !ok { http.Error(w, "bad request", http.StatusBadRequest) return } - reader, err := h.openISO(isoName) + reader, err := h.openISO(isoPath) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return @@ -143,41 +137,32 @@ func (h *Handler) BrowseISO(w http.ResponseWriter, r *http.Request) { entries, err := reader.ListDir(internalPath) if err != nil { - http.Error(w, fmt.Sprintf("cannot list directory: %v", err), http.StatusNotFound) + 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 - } + if entries[i].IsDir != entries[j].IsDir { return entries[i].IsDir } return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) }) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := h.tmpl.ExecuteTemplate(w, "browse", browseData{ - Title: fmt.Sprintf("%s — /%s", isoName, internalPath), - ISOName: isoName, - InternalPath: internalPath, - Entries: entries, - Breadcrumbs: buildBreadcrumbs(isoName, internalPath), - }); err != nil { - log.Printf("template/browse error: %v", err) - } + h.tmpl.ExecuteTemplate(w, "browse", map[string]any{ + "Title": filepath.Base(isoPath), + "ISOName": isoPath, + "InternalPath": internalPath, + "Entries": entries, + "Breadcrumbs": buildISOBreadcrumbs(isoPath, internalPath), + }) } -// ----------------------------------------------------------------------- -// Route: GET /file/{iso}/{path...} → stream a file from inside an ISO -// ----------------------------------------------------------------------- - func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request) { - isoName, internalPath, ok := parsePath(r.URL.Path, "/file/") + isoPath, internalPath, ok := parsePath(r.URL.Path, "/file/") if !ok || internalPath == "" { http.Error(w, "bad request", http.StatusBadRequest) return } - reader, err := h.openISO(isoName) + reader, err := h.openISO(isoPath) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return @@ -186,7 +171,7 @@ func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request) { rc, size, err := reader.FileReader(internalPath) if err != nil { - http.Error(w, fmt.Sprintf("cannot open file: %v", err), http.StatusNotFound) + http.Error(w, "file not found", http.StatusNotFound) return } defer rc.Close() @@ -194,14 +179,11 @@ func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request) { filename := filepath.Base(internalPath) ext := strings.ToLower(filepath.Ext(filename)) ct := mime.TypeByExtension(ext) - if ct == "" { - ct = "application/octet-stream" - } + if ct == "" { ct = "application/octet-stream" } - // Determine if browser can/should view it inline viewable := false switch ext { - case ".pdf", ".txt", ".jpg", ".jpeg", ".png", ".gif", ".mp4", ".mp3", ".webp", ".svg": + case ".pdf", ".txt", ".jpg", ".jpeg", ".png", ".gif", ".mp4", ".mp3", ".webp": viewable = true } @@ -213,124 +195,69 @@ func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", ct) w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) - - if _, err := io.Copy(w, rc); err != nil { - log.Printf("stream error %q from %q: %v", internalPath, isoName, err) - } + io.Copy(w, rc) } -// ----------------------------------------------------------------------- -// Route: GET /raw/{path...} → serve actual files from the disk -// ----------------------------------------------------------------------- - func (h *Handler) RawFile(w http.ResponseWriter, r *http.Request) { fileName := strings.TrimPrefix(r.URL.Path, "/raw/") - if fileName == "" || strings.Contains(fileName, "..") { - http.Error(w, "invalid path", http.StatusBadRequest) - return - } - - fullPath := filepath.Join(h.dir, fileName) - http.ServeFile(w, r, fullPath) + http.ServeFile(w, r, filepath.Join(h.dir, fileName)) } -// ----------------------------------------------------------------------- -// Helpers -// ----------------------------------------------------------------------- - -func (h *Handler) openISO(name string) (*iso.Reader, error) { - if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") { - return nil, fmt.Errorf("invalid ISO name") - } - if !strings.EqualFold(filepath.Ext(name), ".iso") { - return nil, fmt.Errorf("not an ISO file") - } - return iso.Open(filepath.Join(h.dir, name)) +func (h *Handler) openISO(relPath string) (*iso.Reader, error) { + return iso.Open(filepath.Join(h.dir, relPath)) } -func parsePath(urlPath, prefix string) (isoName, internalPath string, ok bool) { +func parsePath(urlPath, prefix string) (isoPath, internalPath string, ok bool) { rest := strings.TrimPrefix(urlPath, prefix) - if rest == "" { - return "", "", false - } - decoded, err := url.PathUnescape(rest) - if err != nil { - return "", "", false - } - idx := strings.Index(decoded, "/") - if idx == -1 { - return decoded, "", true - } - return decoded[:idx], strings.TrimPrefix(decoded[idx:], "/"), true + 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 } -type breadcrumb struct { - Name string - URL string +func buildLibraryBreadcrumbs(relDir string) []breadcrumb { + crumbs := []breadcrumb{{Name: "Library", URL: "/"}} + if relDir == "" { return crumbs } + acc := "" + for _, p := range strings.Split(relDir, "/") { + if p == "" { continue } + acc = filepath.Join(acc, p) + crumbs = append(crumbs, breadcrumb{Name: p, URL: "/" + filepath.ToSlash(acc)}) + } + return crumbs } -func buildBreadcrumbs(isoName, internalPath string) []breadcrumb { - crumbs := []breadcrumb{ - {Name: "Library", URL: "/"}, - {Name: isoName, URL: "/browse/" + url.PathEscape(isoName)}, - } - if internalPath == "" { - return crumbs - } - accumulated := "" - for _, part := range strings.Split(internalPath, "/") { - if part == "" { - continue +func buildISOBreadcrumbs(isoPath, internalPath string) []breadcrumb { + crumbs := buildLibraryBreadcrumbs(filepath.Dir(isoPath)) + if crumbs[len(crumbs)-1].Name == "." { crumbs = crumbs[:len(crumbs)-1] } + + 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}) } - if accumulated == "" { - accumulated = part - } else { - accumulated += "/" + part - } - crumbs = append(crumbs, breadcrumb{ - Name: part, - URL: "/browse/" + url.PathEscape(isoName) + "/" + accumulated, - }) } return crumbs } func humanSize(n int64) string { - if n < 1024 { - return fmt.Sprintf("%d B", n) - } + 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++ - } + 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 { - switch strings.ToLower(filepath.Ext(name)) { - case ".iso", ".img", ".dmg": - return "💿" - case ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar": - return "📦" - case ".pdf": - return "📄" - case ".doc", ".docx", ".odt", ".txt", ".md": - return "📝" - case ".xls", ".xlsx", ".csv": - return "📊" - case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".webp": - return "🖼️" - case ".mp4", ".mkv", ".avi", ".mov", ".wmv": - return "🎬" - case ".mp3", ".flac", ".wav", ".ogg", ".aac": - return "🎵" - case ".exe", ".msi", ".deb", ".rpm", ".apk": - return "⚙️" - case ".sh", ".bash", ".py", ".rb", ".js", ".go", ".c", ".cpp": - return "📜" - default: - return "📄" + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".iso": return "💿" + case ".pdf": return "📄" + case ".jpg", ".png": return "🖼️" + default: return "📄" } } @@ -338,294 +265,96 @@ const allTemplates = ` {{define "css"}} {{end}} -{{define "index"}} - - - - - {{.Title}} - {{template "css" .}} - +{{define "index"}} + + +{{.Title}}{{template "css" .}}
-

ISO Library {{len .ISOs}} images

-

Serving from {{.Dir}}

+ - {{if .ISOs}}
- {{range .ISOs}} -
- {{if .HasImage}} - Cover + {{range .Items}} + {{if .IsDir}} + +
📁
+
{{.Name}}
+
{{else}} -
💿
- {{end}} -
- {{.Name}} -

{{if .Description}}{{.Description}}{{else}}No description available.{{end}}

-
- {{humanSize .Size}} - {{.ModTime.Format "2006-01-02"}} -
-
- Browse Files - Download ISO +
+ + {{if .HasImage}} + {{else}}
💿
{{end}} +
+
+ {{.Name}} +

{{if .Description}}{{.Description}}{{else}}ISO Disk Image{{end}}

+
-
+ {{end}} {{end}}
- {{else}} -

No .iso files found in the served directory.

- {{end}}
{{end}} -{{define "browse"}} - - - - - {{.Title}} - {{template "css" .}} - +{{define "browse"}} + + +{{.Title}}{{template "css" .}}
- - -

- {{if .InternalPath}}📁 {{base .InternalPath}}{{else}}💿 {{.ISOName}}{{end}} - {{len .Entries}} items -

- - {{if .Entries}} - - - - - - - - + - {{range .Entries}} - {{if .IsDir}} - - - - - - - {{else}} + {{range .Entries}} - - - - + + + {{end}} - {{end}}
NameSizeModified
NameSizeAction
{{.ModTime.Format "2006-01-02 15:04"}}
{{fileIcon .Name}}{{.Name}}
{{humanSize .Size}}{{.ModTime.Format "2006-01-02 15:04"}}↓ Download + {{if .IsDir}}📁 {{.Name}} + {{else}}{{fileIcon .Name}} {{.Name}}{{end}} + {{if .IsDir}}—{{else}}{{humanSize .Size}}{{end}}{{if not .IsDir}}Download{{end}}
- {{else}} -

This directory is empty.

- {{end}} -
diff --git a/main.go b/main.go index fad61bc..99c2a6a 100644 --- a/main.go +++ b/main.go @@ -11,33 +11,23 @@ import ( ) func main() { - dir := flag.String("dir", ".", "Directory containing ISO files to serve") - addr := flag.String("addr", ":8080", "Address to listen on") + dir := flag.String("dir", ".", "Directory to serve") + addr := flag.String("addr", ":8080", "Address") flag.Parse() - info, err := os.Stat(*dir) - if err != nil { - log.Fatalf("Cannot access directory %q: %v", *dir, err) - } - if !info.IsDir() { - log.Fatalf("%q is not a directory", *dir) + if info, err := os.Stat(*dir); err != nil || !info.IsDir() { + log.Fatalf("Invalid directory: %s", *dir) } mux := http.NewServeMux() h := handlers.New(*dir) - // Routes: - // GET / → list all ISOs in the directory - // GET /browse/{iso} → list root of ISO - // GET /browse/{iso}/{path...} → list directory inside ISO - // GET /file/{iso}/{path...} → stream/view a file from inside ISO - // GET /raw/{filename} → serve raw disk files (ISOs, covers, descriptions) - mux.HandleFunc("/", h.ListISOs) + // Routes mux.HandleFunc("/browse/", h.BrowseISO) mux.HandleFunc("/file/", h.DownloadFile) mux.HandleFunc("/raw/", h.RawFile) + mux.HandleFunc("/", h.ListISOs) // Catch-all for directory navigation - fmt.Printf("ISOSilo listening on %s\n", *addr) - fmt.Printf("Serving ISOs from: %s\n", *dir) + fmt.Printf("ISOSilo running at %s\n", *addr) log.Fatal(http.ListenAndServe(*addr, mux)) }