commit 1cc55d91ef1134c2744cd6080789bea92aee24c3 Author: visionmercer <62051836+visionmercer@users.noreply.github.com> Date: Mon Mar 23 09:15:52 2026 +0100 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c34d3c --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# ISOSilo + +A lightweight Go web server that lets you browse and download files from ISO 9660 images via your browser — without mounting them. + +## Features + +- **Library view** — lists all `.iso` files in a directory as clickable cards +- **In-ISO browsing** — navigate directories inside any ISO just like a file manager +- **File downloads** — download individual files directly from within an ISO +- **Clean UI** — dark-themed, responsive interface with file-type icons +- **Safe** — path traversal protection; only `.iso` files in the served directory are accessible +- **Zero mount required** — reads ISO images directly using the ISO 9660 library + +## Installation + +### Prerequisites + +- Go 1.22 or later + +### Build from source + +```bash +git clone https://github.com/yourname/isosilo +cd isosilo +go mod tidy # fetches github.com/kdomanski/iso9660 +go build -o isosilo . +``` + +### Run with Docker + +```bash +docker build -t isosilo . +docker run -p 8080:8080 -v /path/to/your/isos:/isos isosilo +``` + +## Usage + +``` +isosilo [flags] + +Flags: + -dir string Directory containing ISO files to serve (default: current directory) + -addr string Address to listen on (default: :8080) +``` + +### Examples + +```bash +# Serve ISOs from the current directory on port 8080 +./isosilo + +# Serve a specific directory on a custom port +./isosilo -dir /mnt/isos -addr :9000 + +# Serve on all interfaces (default) or localhost only +./isosilo -addr 127.0.0.1:8080 +``` + +Then open http://localhost:8080 in your browser. + +## URL Structure + +| URL | Description | +|-----|-------------| +| `GET /` | List all ISO files in the served directory | +| `GET /browse/{iso}` | Browse the root of an ISO | +| `GET /browse/{iso}/{path}` | Browse a subdirectory inside an ISO | +| `GET /file/{iso}/{path}` | Download a file from inside an ISO | + +## Dependencies + +- [`github.com/kdomanski/iso9660`](https://github.com/kdomanski/iso9660) — pure-Go ISO 9660 reader + +## Project Layout + +``` +isosilo/ +├── main.go # Entry point, CLI flags, route registration +├── go.mod +├── go.sum +├── Dockerfile +└── internal/ + ├── iso/ + │ └── reader.go # ISO open/list/read abstraction + └── handlers/ + └── handlers.go # HTTP handlers + HTML templates +``` + +## Security Notes + +- The server only serves files with a `.iso` extension from the configured directory. +- ISO names are validated to prevent path traversal (`..`, `/`, `\`). +- Internal ISO paths are cleaned before use. +- There is **no authentication**. Do not expose this server to the public internet without adding auth (e.g. a reverse proxy with basic auth). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..eaeaf63 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module isosilo + +go 1.22 + +require github.com/kdomanski/iso9660 v0.4.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..01218bd --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kdomanski/iso9660 v0.4.0 h1:BPKKdcINz3m0MdjIMwS0wx1nofsOjxOq8TOr45WGHFg= +github.com/kdomanski/iso9660 v0.4.0/go.mod h1:OxUSupHsO9ceI8lBLPJKWBTphLemjrCQY8LPXM7qSzU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/Dockerfile b/internal/Dockerfile new file mode 100644 index 0000000..23b58ab --- /dev/null +++ b/internal/Dockerfile @@ -0,0 +1,20 @@ +# ── Build stage ────────────────────────────────────────────────────────────── +FROM golang:1.22-alpine AS builder + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /isosilo . + +# ── Runtime stage ───────────────────────────────────────────────────────────── +FROM scratch + +COPY --from=builder /isosilo /isosilo + +# ISOs should be mounted here at runtime. +VOLUME ["/isos"] + +EXPOSE 8080 +ENTRYPOINT ["/isosilo", "-dir", "/isos", "-addr", ":8080"] diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..302562e --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,552 @@ +// Package handlers provides HTTP handler functions for the ISO server. +package handlers + +import ( + "fmt" + "html/template" + "io" + "log" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "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( + template.New("").Funcs(template.FuncMap{ + "humanSize": humanSize, + "fileIcon": fileIcon, + "urlenc": url.PathEscape, + "base": filepath.Base, + "add1": func(i int) int { return i + 1 }, + }).Parse(allTemplates), + ) + return h +} + +// ----------------------------------------------------------------------- +// Route: GET / → list all ISO files in the directory +// ----------------------------------------------------------------------- + +type isoInfo struct { + Name string + Size int64 + ModTime time.Time +} + +func (h *Handler) ListISOs(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + entries, err := os.ReadDir(h.dir) + if err != nil { + http.Error(w, "cannot read directory", http.StatusInternalServerError) + return + } + + var isos []isoInfo + for _, e := range entries { + if e.IsDir() || !strings.EqualFold(filepath.Ext(e.Name()), ".iso") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + isos = append(isos, isoInfo{Name: e.Name(), Size: info.Size(), ModTime: info.ModTime()}) + } + + sort.Slice(isos, func(i, j int) bool { + return strings.ToLower(isos[i].Name) < strings.ToLower(isos[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) + } +} + +// ----------------------------------------------------------------------- +// Route: GET /browse/{iso}[/{path...}] → browse inside an ISO +// ----------------------------------------------------------------------- + +type browseData struct { + Title string + ISOName string + InternalPath string + Entries []iso.Entry + Breadcrumbs []breadcrumb +} + +func (h *Handler) BrowseISO(w http.ResponseWriter, r *http.Request) { + isoName, internalPath, ok := parsePath(r.URL.Path, "/browse/") + if !ok { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + reader, err := h.openISO(isoName) + 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, fmt.Sprintf("cannot list directory: %v", err), http.StatusNotFound) + return + } + + // Dirs first, then files; both alphabetically. + 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) + }) + + 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) + } +} + +// ----------------------------------------------------------------------- +// 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/") + if !ok || internalPath == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + reader, err := h.openISO(isoName) + 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, fmt.Sprintf("cannot open file: %v", err), http.StatusNotFound) + return + } + defer rc.Close() + + filename := filepath.Base(internalPath) + ct := mime.TypeByExtension(filepath.Ext(filename)) + if ct == "" { + ct = "application/octet-stream" + } + + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename)) + 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) + } +} + +// ----------------------------------------------------------------------- +// 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)) +} + +// parsePath splits "/browse/myfile.iso/some/dir" → ("myfile.iso", "some/dir", true). +func parsePath(urlPath, prefix string) (isoName, 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 +} + +type breadcrumb struct { + Name string + URL string +} + +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 + } + 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) + } + 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 { + 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 "📄" + } +} + +// allTemplates contains both the index and browse HTML templates. +const allTemplates = ` +{{define "css"}} + +{{end}} + +{{define "index"}} + + + + + {{.Title}} + {{template "css" .}} + + +
+
+

ISO Library {{len .ISOs}} images

+

Serving from {{.Dir}}

+ + {{if .ISOs}} +
+ {{range .ISOs}} + + 💿 + {{.Name}} + + {{humanSize .Size}} + {{.ModTime.Format "2006-01-02"}} + + + {{end}} +
+ {{else}} +

No .iso files found in the served directory.

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

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

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

This directory is empty.

+ {{end}} + +
+ + +{{end}} +` diff --git a/internal/iso/reader.go b/internal/iso/reader.go new file mode 100644 index 0000000..22f758d --- /dev/null +++ b/internal/iso/reader.go @@ -0,0 +1,190 @@ +// Package iso provides utilities for reading ISO 9660 image files. +package iso + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/kdomanski/iso9660" +) + +// Entry represents a file or directory inside an ISO image. +type Entry struct { + Name string + Path string // Full path within the ISO (forward-slash separated) + IsDir bool + Size int64 + ModTime time.Time +} + +// Reader wraps an ISO image file. +type Reader struct { + path string + image *iso9660.Image + file *os.File +} + +// Open opens an ISO file for reading. Call Close() when done. +func Open(isoPath string) (*Reader, error) { + f, err := os.Open(isoPath) + if err != nil { + return nil, fmt.Errorf("opening iso: %w", err) + } + + img, err := iso9660.OpenImage(f) + if err != nil { + f.Close() + return nil, fmt.Errorf("parsing iso: %w", err) + } + + return &Reader{path: isoPath, image: img, file: f}, nil +} + +// Close releases the underlying file handle. +func (r *Reader) Close() error { + return r.file.Close() +} + +// ListDir returns the entries in the given directory path within the ISO. +// Use an empty string or "/" for the root directory. +func (r *Reader) ListDir(dirPath string) ([]Entry, error) { + dirPath = cleanPath(dirPath) + + root, err := r.image.RootDir() + if err != nil { + return nil, fmt.Errorf("reading root: %w", err) + } + + // Navigate to the target directory. + dir, err := navigate(root, dirPath) + if err != nil { + return nil, err + } + + children, err := dir.GetChildren() + if err != nil { + return nil, fmt.Errorf("listing directory: %w", err) + } + + entries := make([]Entry, 0, len(children)) + for _, child := range children { + name := child.Name() + // Skip the "." and ".." entries that some ISOs include. + if name == "." || name == ".." || name == "" { + continue + } + + var entryPath string + if dirPath == "" { + entryPath = name + } else { + entryPath = dirPath + "/" + name + } + + entries = append(entries, Entry{ + Name: name, + Path: entryPath, + IsDir: child.IsDir(), + Size: child.Size(), + ModTime: child.ModTime(), + }) + } + + return entries, nil +} + +// FileReader opens a file inside the ISO for streaming. +// The caller is responsible for closing the returned ReadCloser. +func (r *Reader) FileReader(filePath string) (io.ReadCloser, int64, error) { + filePath = cleanPath(filePath) + if filePath == "" { + return nil, 0, fmt.Errorf("no file path specified") + } + + root, err := r.image.RootDir() + if err != nil { + return nil, 0, fmt.Errorf("reading root: %w", err) + } + + // Split path into directory + filename. + dir := filepath.Dir(filePath) + name := filepath.Base(filePath) + + var parent *iso9660.File + if dir == "." || dir == "/" { + parent = root + } else { + parent, err = navigate(root, cleanPath(dir)) + if err != nil { + return nil, 0, err + } + } + + children, err := parent.GetChildren() + if err != nil { + return nil, 0, fmt.Errorf("listing parent dir: %w", err) + } + + for _, child := range children { + if strings.EqualFold(child.Name(), name) { + if child.IsDir() { + return nil, 0, fmt.Errorf("%q is a directory", filePath) + } + r := child.Reader() + if r == nil { + return nil, 0, fmt.Errorf("could not open reader for %q", filePath) + } + return io.NopCloser(r), child.Size(), nil + } + } + + return nil, 0, fmt.Errorf("file not found: %q", filePath) +} + +// navigate traverses the directory tree inside an ISO to find the +// directory at cleanedPath (e.g. "foo/bar/baz"). +func navigate(root *iso9660.File, cleanedPath string) (*iso9660.File, error) { + if cleanedPath == "" { + return root, nil + } + + parts := strings.Split(cleanedPath, "/") + current := root + + for _, part := range parts { + if part == "" { + continue + } + children, err := current.GetChildren() + if err != nil { + return nil, fmt.Errorf("listing dir: %w", err) + } + found := false + for _, child := range children { + if strings.EqualFold(child.Name(), part) { + if !child.IsDir() { + return nil, fmt.Errorf("%q is not a directory", part) + } + current = child + found = true + break + } + } + if !found { + return nil, fmt.Errorf("directory not found: %q", part) + } + } + + return current, nil +} + +// cleanPath normalises a path: strips leading slash and cleans traversal. +func cleanPath(p string) string { + p = filepath.ToSlash(filepath.Clean("/" + p)) + p = strings.TrimPrefix(p, "/") + return p +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ce1bd50 --- /dev/null +++ b/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + + "isosilo/internal/handlers" +) + +func main() { + dir := flag.String("dir", ".", "Directory containing ISO files to serve") + addr := flag.String("addr", ":8080", "Address to listen on") + 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) + } + + 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...} → download a file from inside ISO + mux.HandleFunc("/", h.ListISOs) + mux.HandleFunc("/browse/", h.BrowseISO) + mux.HandleFunc("/file/", h.DownloadFile) + + fmt.Printf("ISOSilo listening on %s\n", *addr) + fmt.Printf("Serving ISOs from: %s\n", *dir) + log.Fatal(http.ListenAndServe(*addr, mux)) +}