Initial commit
This commit is contained in:
commit
1cc55d91ef
7 changed files with 912 additions and 0 deletions
94
README.md
Normal file
94
README.md
Normal file
|
|
@ -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).
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
module isosilo
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require github.com/kdomanski/iso9660 v0.4.0
|
||||||
10
go.sum
Normal file
10
go.sum
Normal file
|
|
@ -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=
|
||||||
20
internal/Dockerfile
Normal file
20
internal/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
552
internal/handlers/handlers.go
Normal file
552
internal/handlers/handlers.go
Normal file
|
|
@ -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"}}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1117;
|
||||||
|
--surface: #1a1d27;
|
||||||
|
--border: #252836;
|
||||||
|
--accent: #4f8ef7;
|
||||||
|
--purple: #7c5cbf;
|
||||||
|
--text: #e2e4ef;
|
||||||
|
--muted: #6b7090;
|
||||||
|
--yellow: #f7c948;
|
||||||
|
--radius: 8px;
|
||||||
|
--mono: 'JetBrains Mono','Fira Code','Cascadia Code',monospace;
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
code { font-family: var(--mono); }
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: linear-gradient(135deg,#12151f 0%,#1c1f30 100%);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 56px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
main { max-width: 1100px; margin: 0 auto; padding: 2rem; }
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-size: .68rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
background: var(--purple);
|
||||||
|
color: #fff;
|
||||||
|
padding: .15em .6em;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.stats { font-size: .82rem; color: var(--muted); margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* Breadcrumbs */
|
||||||
|
.bc {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: .25rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .8rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: .55rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.bc .sep { color: var(--border); }
|
||||||
|
.bc .cur { color: var(--text); }
|
||||||
|
|
||||||
|
/* ISO grid */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px,1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.2rem 1.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .45rem;
|
||||||
|
transition: border-color .15s, transform .15s;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.card:hover { border-color: var(--accent); transform: translateY(-2px); text-decoration: none; }
|
||||||
|
.card-icon { font-size: 1.8rem; }
|
||||||
|
.card-name { font-family: var(--mono); font-size: .88rem; font-weight: 600; color: var(--accent); word-break: break-all; }
|
||||||
|
.card-meta { font-size: .78rem; color: var(--muted); display: flex; gap: 1rem; }
|
||||||
|
|
||||||
|
/* File table */
|
||||||
|
.tbl {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tbl thead { background: #1e2132; }
|
||||||
|
.tbl th {
|
||||||
|
padding: .7rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: .74rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tbl td { padding: .65rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||||
|
.tbl tr:last-child td { border-bottom: none; }
|
||||||
|
.tbl tbody tr { background: var(--surface); transition: background .1s; }
|
||||||
|
.tbl tbody tr:hover { background: #1e2235; }
|
||||||
|
|
||||||
|
.nc { display: flex; align-items: center; gap: .55rem; font-family: var(--mono); font-size: .86rem; }
|
||||||
|
.nc a { color: var(--text); }
|
||||||
|
.nc a:hover { color: var(--accent); text-decoration: none; }
|
||||||
|
.dir-row .nc a { color: var(--yellow); }
|
||||||
|
.dir-row .nc a:hover { color: #ffd97a; }
|
||||||
|
|
||||||
|
.sz { font-family: var(--mono); font-size: .8rem; color: var(--muted); white-space: nowrap; }
|
||||||
|
.dt { font-size: .78rem; color: var(--muted); white-space: nowrap; }
|
||||||
|
.ac { text-align: right; }
|
||||||
|
|
||||||
|
.dl {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .3rem;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: .28rem .65rem;
|
||||||
|
font-size: .76rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.dl:hover { background: var(--accent); color: #fff; text-decoration: none; }
|
||||||
|
|
||||||
|
.empty { text-align: center; color: var(--muted); padding: 3rem 0; }
|
||||||
|
|
||||||
|
@media(max-width:600px) {
|
||||||
|
main { padding: 1rem; }
|
||||||
|
.dt, .tbl th:nth-child(3), .tbl td:nth-child(3) { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "index"}}<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
{{template "css" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><a class="logo" href="/">💿 ISOSilo</a></header>
|
||||||
|
<main>
|
||||||
|
<h1>ISO Library <span class="badge">{{len .ISOs}} images</span></h1>
|
||||||
|
<p class="stats">Serving from <code>{{.Dir}}</code></p>
|
||||||
|
|
||||||
|
{{if .ISOs}}
|
||||||
|
<div class="grid">
|
||||||
|
{{range .ISOs}}
|
||||||
|
<a class="card" href="/browse/{{urlenc .Name}}">
|
||||||
|
<span class="card-icon">💿</span>
|
||||||
|
<span class="card-name">{{.Name}}</span>
|
||||||
|
<span class="card-meta">
|
||||||
|
<span>{{humanSize .Size}}</span>
|
||||||
|
<span>{{.ModTime.Format "2006-01-02"}}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty"><p>No .iso files found in the served directory.</p></div>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "browse"}}<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
{{template "css" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><a class="logo" href="/">💿 ISOSilo</a></header>
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<nav class="bc">
|
||||||
|
{{$crumbs := .Breadcrumbs}}{{$last := len $crumbs}}
|
||||||
|
{{range $i, $c := $crumbs}}
|
||||||
|
{{if lt (add1 $i) $last}}<a href="{{$c.URL}}">{{$c.Name}}</a><span class="sep"> / </span>
|
||||||
|
{{else}}<span class="cur">{{$c.Name}}</span>{{end}}
|
||||||
|
{{end}}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
{{if .InternalPath}}📁 {{base .InternalPath}}{{else}}💿 {{.ISOName}}{{end}}
|
||||||
|
<span class="badge">{{len .Entries}} items</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{{if .Entries}}
|
||||||
|
<table class="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:55%">Name</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Modified</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Entries}}
|
||||||
|
{{if .IsDir}}
|
||||||
|
<tr class="dir-row">
|
||||||
|
<td><div class="nc"><span>📁</span><a href="/browse/{{urlenc $.ISOName}}/{{urlenc .Path}}">{{.Name}}</a></div></td>
|
||||||
|
<td class="sz">—</td>
|
||||||
|
<td class="dt">{{.ModTime.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td class="ac"></td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr>
|
||||||
|
<td><div class="nc"><span>{{fileIcon .Name}}</span><a href="/file/{{urlenc $.ISOName}}/{{urlenc .Path}}" download>{{.Name}}</a></div></td>
|
||||||
|
<td class="sz">{{humanSize .Size}}</td>
|
||||||
|
<td class="dt">{{.ModTime.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td class="ac"><a class="dl" href="/file/{{urlenc $.ISOName}}/{{urlenc .Path}}" download>↓ Download</a></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty"><p>This directory is empty.</p></div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
`
|
||||||
190
internal/iso/reader.go
Normal file
190
internal/iso/reader.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
41
main.go
Normal file
41
main.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue