ISO Library {{len .ISOs}} images
+Serving from {{.Dir}}
No .iso files found in the served directory.
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"}} + +
+ + +Serving from {{.Dir}}
No .iso files found in the served directory.
| Name | +Size | +Modified | ++ |
|---|---|---|---|
| + | — | +{{.ModTime.Format "2006-01-02 15:04"}} | ++ |
{{fileIcon .Name}}{{.Name}} |
+ {{humanSize .Size}} | +{{.ModTime.Format "2006-01-02 15:04"}} | +↓ Download | +
This directory is empty.