isosilo/internal/handlers/handlers.go

552 lines
14 KiB
Go
Raw Normal View History

2026-03-23 09:15:52 +01:00
// 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}}
`