isosilo/internal/handlers/handlers.go

362 lines
12 KiB
Go
Raw Normal View History

2026-03-23 09:15:52 +01:00
package handlers
import (
"fmt"
"html/template"
"io"
"log"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"isosilo/internal/iso"
)
type Handler struct {
dir string
tmpl *template.Template
}
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 },
2026-03-23 11:59:52 +01:00
"trimExt": func(s string) string { return strings.TrimSuffix(s, filepath.Ext(s)) },
2026-03-23 09:15:52 +01:00
}).Parse(allTemplates),
)
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
// The path after / is the directory we are browsing in the library
relDir := strings.TrimPrefix(r.URL.Path, "/")
fullPath := filepath.Join(h.dir, relDir)
// Security check
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()
isISO := !e.IsDir() && strings.EqualFold(filepath.Ext(name), ".iso")
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"))
// Metadata
if d, err := os.ReadFile(basePath + ".txt"); err == nil {
item.Description = string(d)
}
// Cover Image
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
// Only show Directories or ISO files
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 {
if items[i].IsDir != items[j].IsDir { return items[i].IsDir }
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
2026-03-23 09:15:52 +01:00
})
2026-03-23 12:11:02 +01:00
h.tmpl.ExecuteTemplate(w, "index", map[string]any{
"Title": "ISO Library",
"Items": items,
"CurrentPath": relDir,
"Breadcrumbs": buildLibraryBreadcrumbs(relDir),
})
2026-03-23 09:15:52 +01:00
}
2026-03-23 12:11:02 +01:00
// ... BrowseISO, DownloadFile, and RawFile remain largely same as previous version ...
// ... Ensure BrowseISO and DownloadFile use the full relative path to open the ISO ...
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-03-23 12:11:02 +01: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-03-23 12:11:02 +01:00
h.tmpl.ExecuteTemplate(w, "browse", map[string]any{
"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-03-23 12:11:02 +01: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-03-23 12:11:02 +01:00
http.ServeFile(w, r, filepath.Join(h.dir, fileName))
2026-03-23 11:59:52 +01: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")
if idx == -1 { return decoded, "", true }
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: "/"}}
if relDir == "" { return crumbs }
acc := ""
for _, p := range strings.Split(relDir, "/") {
if p == "" { continue }
acc = filepath.Join(acc, p)
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 {
crumbs := buildLibraryBreadcrumbs(filepath.Dir(isoPath))
if crumbs[len(crumbs)-1].Name == "." { crumbs = crumbs[:len(crumbs)-1] }
crumbs = append(crumbs, breadcrumb{Name: filepath.Base(isoPath), URL: "/browse/" + url.PathEscape(isoPath)})
if internalPath != "" {
acc := ""
for _, p := range strings.Split(internalPath, "/") {
if p == "" { continue }
if acc == "" { acc = p } else { acc += "/" + p }
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-03-23 12:11:02 +01:00
if n < 1024 { return fmt.Sprintf("%d B", n) }
2026-03-23 09:15:52 +01:00
div, exp := int64(1024), 0
2026-03-23 12:11:02 +01: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 {
case ".iso": return "💿"
case ".pdf": return "📄"
case ".jpg", ".png": return "🖼️"
default: return "📄"
2026-03-23 09:15:52 +01:00
}
}
const allTemplates = `
{{define "css"}}
<style>
:root {
2026-03-23 12:11:02 +01:00
--bg: #0f1117; --surface: #1a1d27; --border: #252836;
--accent: #4f8ef7; --text: #e2e4ef; --muted: #6b7090; --radius: 8px;
2026-03-23 09:15:52 +01:00
}
2026-03-23 12:11:02 +01:00
body { background: var(--bg); color: var(--text); font-family: system-ui; margin: 0; }
header { background: #12151f; border-bottom: 1px solid var(--border); padding: 0 2rem; height: 56px; display: flex; align-items: center; }
.logo { color: var(--accent); font-weight: bold; text-decoration: none; font-family: monospace; }
2026-03-23 09:15:52 +01:00
main { max-width: 1100px; margin: 0 auto; padding: 2rem; }
2026-03-23 12:11:02 +01:00
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; display: flex; flex-direction: column; text-decoration: none; color: inherit; transition: transform 0.1s; }
.card:hover { transform: translateY(-3px); border-color: var(--accent); }
.card-img { width: 100%; height: 150px; object-fit: cover; background: #000; }
.folder-icon { height: 150px; display: flex; align-items: center; justify-content: center; font-size: 4rem; background: #1c202d; color: #f7c948; }
.iso-icon { height: 150px; display: flex; align-items: center; justify-content: center; font-size: 4rem; background: #12151f; }
.card-body { padding: 1rem; }
.card-name { font-weight: bold; color: var(--accent); margin-bottom: 0.5rem; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-desc { font-size: 0.8rem; color: var(--muted); height: 3em; overflow: hidden; }
.bc { background: var(--surface); padding: 0.6rem 1rem; border-radius: 4px; margin-bottom: 1.5rem; font-size: 0.85rem; }
.bc a { color: var(--accent); text-decoration: none; }
.tbl { width: 100%; border-collapse: collapse; background: var(--surface); border-radius: var(--radius); overflow: hidden; }
.tbl th { text-align: left; padding: 1rem; background: #1e2132; color: var(--muted); font-size: 0.75rem; }
.tbl td { padding: 1rem; border-bottom: 1px solid var(--border); }
.dl-btn { border: 1px solid var(--accent); color: var(--accent); padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.7rem; text-decoration: none; }
2026-03-23 09:15:52 +01:00
</style>
{{end}}
2026-03-23 12:11:02 +01:00
{{define "index"}}
<!DOCTYPE html>
<html>
<head><title>{{.Title}}</title>{{template "css" .}}</head>
2026-03-23 09:15:52 +01:00
<body>
<header><a class="logo" href="/">💿 ISOSilo</a></header>
<main>
2026-03-23 12:11:02 +01:00
<nav class="bc">
{{range $i, $c := .Breadcrumbs}}<a href="{{$c.URL}}">{{$c.Name}}</a> {{if lt (add1 $i) (len $.Breadcrumbs)}}/{{end}} {{end}}
</nav>
2026-03-23 09:15:52 +01:00
<div class="grid">
2026-03-23 12:11:02 +01:00
{{range .Items}}
{{if .IsDir}}
<a href="/{{urlenc .RelativePath}}" class="card">
<div class="folder-icon">📁</div>
<div class="card-body"><span class="card-name">{{.Name}}</span></div>
</a>
2026-03-23 11:59:52 +01:00
{{else}}
2026-03-23 12:11:02 +01:00
<div class="card">
<a href="/browse/{{urlenc .RelativePath}}">
{{if .HasImage}}<img src="/raw/{{urlenc (trimExt .RelativePath)}}{{.ImageExt}}" class="card-img">
{{else}}<div class="iso-icon">💿</div>{{end}}
</a>
<div class="card-body">
<a href="/browse/{{urlenc .RelativePath}}" class="card-name">{{.Name}}</a>
<p class="card-desc">{{if .Description}}{{.Description}}{{else}}ISO Disk Image{{end}}</p>
<div style="margin-top:1rem; display:flex; gap:0.5rem;">
<a href="/raw/{{urlenc .RelativePath}}" class="dl-btn" download>Download ISO</a>
</div>
2026-03-23 11:59:52 +01:00
</div>
</div>
2026-03-23 12:11:02 +01:00
{{end}}
2026-03-23 09:15:52 +01:00
{{end}}
</div>
</main>
</body>
</html>
{{end}}
2026-03-23 12:11:02 +01:00
{{define "browse"}}
<!DOCTYPE html>
<html>
<head><title>{{.Title}}</title>{{template "css" .}}</head>
2026-03-23 09:15:52 +01:00
<body>
<header><a class="logo" href="/">💿 ISOSilo</a></header>
<main>
<nav class="bc">
2026-03-23 12:11:02 +01:00
{{range $i, $c := .Breadcrumbs}}<a href="{{$c.URL}}">{{$c.Name}}</a> {{if lt (add1 $i) (len $.Breadcrumbs)}}/{{end}} {{end}}
2026-03-23 09:15:52 +01:00
</nav>
<table class="tbl">
2026-03-23 12:11:02 +01:00
<thead><tr><th>Name</th><th>Size</th><th>Action</th></tr></thead>
2026-03-23 09:15:52 +01:00
<tbody>
2026-03-23 12:11:02 +01:00
{{range .Entries}}
2026-03-23 09:15:52 +01:00
<tr>
2026-03-23 12:11:02 +01:00
<td>
{{if .IsDir}}📁 <a href="/browse/{{urlenc $.ISOName}}/{{urlenc .Path}}" style="color:var(--text); text-decoration:none;">{{.Name}}</a>
{{else}}{{fileIcon .Name}} <a href="/file/{{urlenc $.ISOName}}/{{urlenc .Path}}" target="_blank" style="color:var(--text); text-decoration:none;">{{.Name}}</a>{{end}}
</td>
<td>{{if .IsDir}}{{else}}{{humanSize .Size}}{{end}}</td>
<td>{{if not .IsDir}}<a href="/file/{{urlenc $.ISOName}}/{{urlenc .Path}}" class="dl-btn" download>Download</a>{{end}}</td>
2026-03-23 09:15:52 +01:00
</tr>
{{end}}
</tbody>
</table>
</main>
</body>
</html>
{{end}}
`