2026-03-23 09:15:52 +01:00
|
|
|
package handlers
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"html/template"
|
|
|
|
|
"io"
|
|
|
|
|
"mime"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"sort"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"isosilo/internal/iso"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Handler struct {
|
2026-04-15 09:08:14 +02:00
|
|
|
dir string
|
|
|
|
|
tmpl *template.Template
|
|
|
|
|
staticFS http.FileSystem
|
2026-03-23 09:15:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:13:35 +01:00
|
|
|
// breadcrumb represents a link in the navigation chain
|
|
|
|
|
type breadcrumb struct {
|
|
|
|
|
Name string
|
|
|
|
|
URL string
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 09:15:52 +01:00
|
|
|
func New(dir string) *Handler {
|
|
|
|
|
h := &Handler{dir: dir}
|
2026-04-15 09:08:14 +02:00
|
|
|
h.staticFS = http.FS(os.DirFS("static"))
|
2026-03-23 09:15:52 +01:00
|
|
|
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 },
|
2026-03-23 11:59:52 +01:00
|
|
|
"trimExt": func(s string) string { return strings.TrimSuffix(s, filepath.Ext(s)) },
|
2026-04-15 09:08:14 +02:00
|
|
|
}).ParseGlob("templates/*.html"),
|
2026-03-23 09:15:52 +01:00
|
|
|
)
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
type libraryEntry struct {
|
|
|
|
|
Name string
|
|
|
|
|
RelativePath string
|
|
|
|
|
IsDir bool
|
|
|
|
|
IsISO bool
|
|
|
|
|
Size int64
|
|
|
|
|
ModTime time.Time
|
|
|
|
|
Description string
|
|
|
|
|
HasImage bool
|
|
|
|
|
ImageExt string
|
2026-03-23 09:15:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) ListISOs(w http.ResponseWriter, r *http.Request) {
|
2026-03-23 12:11:02 +01:00
|
|
|
relDir := strings.TrimPrefix(r.URL.Path, "/")
|
|
|
|
|
fullPath := filepath.Join(h.dir, relDir)
|
|
|
|
|
|
|
|
|
|
if strings.Contains(relDir, "..") {
|
|
|
|
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
2026-03-23 09:15:52 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
entries, err := os.ReadDir(fullPath)
|
2026-03-23 09:15:52 +01:00
|
|
|
if err != nil {
|
2026-03-23 12:11:02 +01:00
|
|
|
http.Error(w, "Directory not found", http.StatusNotFound)
|
2026-03-23 09:15:52 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
var items []libraryEntry
|
2026-03-23 09:15:52 +01:00
|
|
|
for _, e := range entries {
|
2026-03-23 12:11:02 +01:00
|
|
|
name := e.Name()
|
|
|
|
|
relPath := filepath.ToSlash(filepath.Join(relDir, name))
|
|
|
|
|
info, _ := e.Info()
|
2026-04-15 09:08:14 +02:00
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
isISO := !e.IsDir() && strings.EqualFold(filepath.Ext(name), ".iso")
|
2026-04-15 09:08:14 +02:00
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
item := libraryEntry{
|
|
|
|
|
Name: name,
|
|
|
|
|
RelativePath: relPath,
|
|
|
|
|
IsDir: e.IsDir(),
|
|
|
|
|
IsISO: isISO,
|
|
|
|
|
ModTime: info.ModTime(),
|
|
|
|
|
Size: info.Size(),
|
2026-03-23 09:15:52 +01:00
|
|
|
}
|
2026-03-23 11:59:52 +01:00
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-23 11:59:52 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
if item.IsDir || item.IsISO {
|
|
|
|
|
items = append(items, item)
|
|
|
|
|
}
|
2026-03-23 09:15:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
sort.Slice(items, func(i, j int) bool {
|
2026-04-15 09:08:14 +02:00
|
|
|
if items[i].IsDir != items[j].IsDir {
|
|
|
|
|
return items[i].IsDir
|
|
|
|
|
}
|
2026-03-23 12:11:02 +01:00
|
|
|
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
|
2026-03-23 09:15:52 +01:00
|
|
|
})
|
|
|
|
|
|
2026-04-15 09:08:14 +02:00
|
|
|
h.tmpl.ExecuteTemplate(w, "index.html", map[string]any{
|
2026-03-23 12:11:02 +01:00
|
|
|
"Title": "ISO Library",
|
|
|
|
|
"Items": items,
|
|
|
|
|
"CurrentPath": relDir,
|
|
|
|
|
"Breadcrumbs": buildLibraryBreadcrumbs(relDir),
|
|
|
|
|
})
|
2026-03-23 09:15:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) BrowseISO(w http.ResponseWriter, r *http.Request) {
|
2026-03-23 12:11:02 +01:00
|
|
|
isoPath, internalPath, ok := parsePath(r.URL.Path, "/browse/")
|
2026-03-23 09:15:52 +01:00
|
|
|
if !ok {
|
|
|
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
reader, err := h.openISO(isoPath)
|
2026-03-23 09:15:52 +01:00
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer reader.Close()
|
|
|
|
|
|
|
|
|
|
entries, err := reader.ListDir(internalPath)
|
|
|
|
|
if err != nil {
|
2026-03-23 12:11:02 +01:00
|
|
|
http.Error(w, "cannot list directory", http.StatusNotFound)
|
2026-03-23 09:15:52 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sort.Slice(entries, func(i, j int) bool {
|
2026-04-15 09:08:14 +02:00
|
|
|
if entries[i].IsDir != entries[j].IsDir {
|
|
|
|
|
return entries[i].IsDir
|
|
|
|
|
}
|
2026-03-23 09:15:52 +01:00
|
|
|
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-15 09:08:14 +02:00
|
|
|
h.tmpl.ExecuteTemplate(w, "browse.html", map[string]any{
|
2026-03-23 12:11:02 +01:00
|
|
|
"Title": filepath.Base(isoPath),
|
|
|
|
|
"ISOName": isoPath,
|
|
|
|
|
"InternalPath": internalPath,
|
|
|
|
|
"Entries": entries,
|
|
|
|
|
"Breadcrumbs": buildISOBreadcrumbs(isoPath, internalPath),
|
|
|
|
|
})
|
2026-03-23 09:15:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
2026-03-23 12:11:02 +01:00
|
|
|
isoPath, internalPath, ok := parsePath(r.URL.Path, "/file/")
|
2026-03-23 09:15:52 +01:00
|
|
|
if !ok || internalPath == "" {
|
|
|
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
reader, err := h.openISO(isoPath)
|
2026-03-23 09:15:52 +01:00
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer reader.Close()
|
|
|
|
|
|
|
|
|
|
rc, size, err := reader.FileReader(internalPath)
|
|
|
|
|
if err != nil {
|
2026-03-23 12:11:02 +01:00
|
|
|
http.Error(w, "file not found", http.StatusNotFound)
|
2026-03-23 09:15:52 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer rc.Close()
|
|
|
|
|
|
|
|
|
|
filename := filepath.Base(internalPath)
|
2026-03-23 11:59:52 +01:00
|
|
|
ext := strings.ToLower(filepath.Ext(filename))
|
|
|
|
|
ct := mime.TypeByExtension(ext)
|
2026-04-15 09:08:14 +02:00
|
|
|
if ct == "" {
|
|
|
|
|
ct = "application/octet-stream"
|
|
|
|
|
}
|
2026-03-23 09:15:52 +01:00
|
|
|
|
2026-03-23 11:59:52 +01:00
|
|
|
viewable := false
|
|
|
|
|
switch ext {
|
2026-03-23 12:11:02 +01:00
|
|
|
case ".pdf", ".txt", ".jpg", ".jpeg", ".png", ".gif", ".mp4", ".mp3", ".webp":
|
2026-03-23 11:59:52 +01:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 09:15:52 +01:00
|
|
|
w.Header().Set("Content-Type", ct)
|
|
|
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
2026-03-23 12:11:02 +01:00
|
|
|
io.Copy(w, rc)
|
2026-03-23 09:15:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 11:59:52 +01:00
|
|
|
func (h *Handler) RawFile(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
fileName := strings.TrimPrefix(r.URL.Path, "/raw/")
|
2026-04-20 11:56:24 +02:00
|
|
|
|
|
|
|
|
// 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)
|
2026-03-23 11:59:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 09:08:14 +02:00
|
|
|
func (h *Handler) ServeStatic(w http.ResponseWriter, r *http.Request) {
|
2026-04-15 10:49:26 +02:00
|
|
|
// 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)
|
2026-04-15 09:08:14 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
func (h *Handler) openISO(relPath string) (*iso.Reader, error) {
|
|
|
|
|
return iso.Open(filepath.Join(h.dir, relPath))
|
2026-03-23 09:15:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
func parsePath(urlPath, prefix string) (isoPath, internalPath string, ok bool) {
|
2026-03-23 09:15:52 +01:00
|
|
|
rest := strings.TrimPrefix(urlPath, prefix)
|
2026-03-23 12:11:02 +01:00
|
|
|
decoded, _ := url.PathUnescape(rest)
|
|
|
|
|
lower := strings.ToLower(decoded)
|
|
|
|
|
idx := strings.Index(lower, ".iso")
|
2026-04-15 09:08:14 +02:00
|
|
|
if idx == -1 {
|
|
|
|
|
return decoded, "", true
|
|
|
|
|
}
|
2026-03-23 12:11:02 +01:00
|
|
|
return decoded[:idx+4], strings.TrimPrefix(decoded[idx+4:], "/"), true
|
2026-03-23 09:15:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
func buildLibraryBreadcrumbs(relDir string) []breadcrumb {
|
|
|
|
|
crumbs := []breadcrumb{{Name: "Library", URL: "/"}}
|
2026-04-15 09:08:14 +02:00
|
|
|
if relDir == "" {
|
|
|
|
|
return crumbs
|
|
|
|
|
}
|
2026-03-23 12:11:02 +01:00
|
|
|
acc := ""
|
|
|
|
|
for _, p := range strings.Split(relDir, "/") {
|
2026-04-15 09:08:14 +02:00
|
|
|
if p == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if acc == "" {
|
|
|
|
|
acc = p
|
|
|
|
|
} else {
|
|
|
|
|
acc = filepath.Join(acc, p)
|
|
|
|
|
}
|
2026-03-23 12:11:02 +01:00
|
|
|
crumbs = append(crumbs, breadcrumb{Name: p, URL: "/" + filepath.ToSlash(acc)})
|
|
|
|
|
}
|
|
|
|
|
return crumbs
|
2026-03-23 09:15:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
func buildISOBreadcrumbs(isoPath, internalPath string) []breadcrumb {
|
2026-03-23 12:13:35 +01:00
|
|
|
parentDir := filepath.Dir(isoPath)
|
|
|
|
|
var crumbs []breadcrumb
|
|
|
|
|
if parentDir == "." {
|
|
|
|
|
crumbs = []breadcrumb{{Name: "Library", URL: "/"}}
|
|
|
|
|
} else {
|
|
|
|
|
crumbs = buildLibraryBreadcrumbs(parentDir)
|
|
|
|
|
}
|
2026-04-15 09:08:14 +02:00
|
|
|
|
2026-03-23 12:11:02 +01:00
|
|
|
crumbs = append(crumbs, breadcrumb{Name: filepath.Base(isoPath), URL: "/browse/" + url.PathEscape(isoPath)})
|
|
|
|
|
if internalPath != "" {
|
|
|
|
|
acc := ""
|
|
|
|
|
for _, p := range strings.Split(internalPath, "/") {
|
2026-04-15 09:08:14 +02:00
|
|
|
if p == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if acc == "" {
|
|
|
|
|
acc = p
|
|
|
|
|
} else {
|
|
|
|
|
acc += "/" + p
|
|
|
|
|
}
|
2026-03-23 12:11:02 +01:00
|
|
|
crumbs = append(crumbs, breadcrumb{Name: p, URL: "/browse/" + url.PathEscape(isoPath) + "/" + acc})
|
2026-03-23 09:15:52 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return crumbs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func humanSize(n int64) string {
|
2026-04-15 09:08:14 +02:00
|
|
|
if n < 1024 {
|
|
|
|
|
return fmt.Sprintf("%d B", n)
|
|
|
|
|
}
|
2026-03-23 09:15:52 +01:00
|
|
|
div, exp := int64(1024), 0
|
2026-04-15 09:08:14 +02:00
|
|
|
for v := n / 1024; v >= 1024; v /= 1024 {
|
|
|
|
|
div *= 1024
|
|
|
|
|
exp++
|
|
|
|
|
}
|
2026-03-23 09:15:52 +01:00
|
|
|
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fileIcon(name string) string {
|
2026-03-23 12:11:02 +01:00
|
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
|
|
|
switch ext {
|
2026-04-15 09:08:14 +02:00
|
|
|
case ".iso":
|
|
|
|
|
return "💿"
|
|
|
|
|
case ".pdf":
|
|
|
|
|
return "📄"
|
2026-04-20 11:56:24 +02:00
|
|
|
case ".jpg", ".png", ".jpeg", ".gif", ".pcx", ".bmp":
|
2026-04-15 09:08:14 +02:00
|
|
|
return "🖼️"
|
|
|
|
|
case ".txt", ".md":
|
|
|
|
|
return "📝"
|
2026-04-20 11:56:24 +02:00
|
|
|
case ".mp3", ".mod", ".ogg", ".wav", ".flac":
|
|
|
|
|
return "🎵"
|
|
|
|
|
case ".mp4", ".avi", ".mov":
|
|
|
|
|
return "🎬"
|
|
|
|
|
case ".zip", ".rar", ".7z":
|
|
|
|
|
return "📦"
|
|
|
|
|
case ".exe", ".msi":
|
|
|
|
|
return "📦"
|
2026-04-15 09:08:14 +02:00
|
|
|
default:
|
|
|
|
|
return "📄"
|
2026-03-23 09:15:52 +01:00
|
|
|
}
|
|
|
|
|
}
|