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/") // Security: Prevent path traversal attacks if fileName == "" || strings.Contains(fileName, "..") || strings.HasPrefix(fileName, "/") { http.Error(w, "Invalid path", http.StatusBadRequest) return } // Clean the path to prevent any directory traversal cleanPath := filepath.Clean(fileName) if strings.Contains(cleanPath, "..") { http.Error(w, "Invalid path", http.StatusBadRequest) return } fullPath := filepath.Join(h.dir, cleanPath) // Verify the file exists and is within the allowed directory fileInfo, err := os.Stat(fullPath) if err != nil { http.Error(w, "File not found", http.StatusNotFound) return } // Ensure it's not a directory if fileInfo.IsDir() { http.Error(w, "Not found", http.StatusNotFound) return } http.ServeFile(w, r, fullPath) } 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", ".gif", ".pcx", ".bmp": return "🖼️" case ".txt", ".md": return "📝" case ".mp3", ".mod", ".ogg", ".wav", ".flac": return "🎵" case ".mp4", ".avi", ".mov": return "🎬" case ".zip", ".rar", ".7z": return "📦" case ".exe", ".msi": return "📦" default: return "📄" } }