362 lines
12 KiB
Go
362 lines
12 KiB
Go
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 },
|
|
"trimExt": func(s string) string { return strings.TrimSuffix(s, filepath.Ext(s)) },
|
|
}).Parse(allTemplates),
|
|
)
|
|
return h
|
|
}
|
|
|
|
type libraryEntry struct {
|
|
Name string
|
|
RelativePath string
|
|
IsDir bool
|
|
IsISO bool
|
|
Size int64
|
|
ModTime time.Time
|
|
Description string
|
|
HasImage bool
|
|
ImageExt string
|
|
}
|
|
|
|
func (h *Handler) ListISOs(w http.ResponseWriter, r *http.Request) {
|
|
// 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)
|
|
return
|
|
}
|
|
|
|
entries, err := os.ReadDir(fullPath)
|
|
if err != nil {
|
|
http.Error(w, "Directory not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var items []libraryEntry
|
|
for _, e := range entries {
|
|
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(),
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only show Directories or ISO files
|
|
if item.IsDir || item.IsISO {
|
|
items = append(items, item)
|
|
}
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
h.tmpl.ExecuteTemplate(w, "index", map[string]any{
|
|
"Title": "ISO Library",
|
|
"Items": items,
|
|
"CurrentPath": relDir,
|
|
"Breadcrumbs": buildLibraryBreadcrumbs(relDir),
|
|
})
|
|
}
|
|
|
|
// ... BrowseISO, DownloadFile, and RawFile remain largely same as previous version ...
|
|
// ... Ensure BrowseISO and DownloadFile use the full relative path to open the ISO ...
|
|
|
|
func (h *Handler) BrowseISO(w http.ResponseWriter, r *http.Request) {
|
|
isoPath, internalPath, ok := parsePath(r.URL.Path, "/browse/")
|
|
if !ok {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
reader, err := h.openISO(isoPath)
|
|
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, "cannot list directory", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
h.tmpl.ExecuteTemplate(w, "browse", map[string]any{
|
|
"Title": filepath.Base(isoPath),
|
|
"ISOName": isoPath,
|
|
"InternalPath": internalPath,
|
|
"Entries": entries,
|
|
"Breadcrumbs": buildISOBreadcrumbs(isoPath, internalPath),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|
isoPath, internalPath, ok := parsePath(r.URL.Path, "/file/")
|
|
if !ok || internalPath == "" {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
reader, err := h.openISO(isoPath)
|
|
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, "file not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
defer rc.Close()
|
|
|
|
filename := filepath.Base(internalPath)
|
|
ext := strings.ToLower(filepath.Ext(filename))
|
|
ct := mime.TypeByExtension(ext)
|
|
if ct == "" { ct = "application/octet-stream" }
|
|
|
|
viewable := false
|
|
switch ext {
|
|
case ".pdf", ".txt", ".jpg", ".jpeg", ".png", ".gif", ".mp4", ".mp3", ".webp":
|
|
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))
|
|
}
|
|
|
|
w.Header().Set("Content-Type", ct)
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
|
io.Copy(w, rc)
|
|
}
|
|
|
|
func (h *Handler) RawFile(w http.ResponseWriter, r *http.Request) {
|
|
fileName := strings.TrimPrefix(r.URL.Path, "/raw/")
|
|
http.ServeFile(w, r, filepath.Join(h.dir, fileName))
|
|
}
|
|
|
|
func (h *Handler) openISO(relPath string) (*iso.Reader, error) {
|
|
return iso.Open(filepath.Join(h.dir, relPath))
|
|
}
|
|
|
|
func parsePath(urlPath, prefix string) (isoPath, internalPath string, ok bool) {
|
|
rest := strings.TrimPrefix(urlPath, prefix)
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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})
|
|
}
|
|
}
|
|
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 {
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
switch ext {
|
|
case ".iso": return "💿"
|
|
case ".pdf": return "📄"
|
|
case ".jpg", ".png": return "🖼️"
|
|
default: return "📄"
|
|
}
|
|
}
|
|
|
|
const allTemplates = `
|
|
{{define "css"}}
|
|
<style>
|
|
:root {
|
|
--bg: #0f1117; --surface: #1a1d27; --border: #252836;
|
|
--accent: #4f8ef7; --text: #e2e4ef; --muted: #6b7090; --radius: 8px;
|
|
}
|
|
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; }
|
|
main { max-width: 1100px; margin: 0 auto; padding: 2rem; }
|
|
.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; }
|
|
</style>
|
|
{{end}}
|
|
|
|
{{define "index"}}
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>{{.Title}}</title>{{template "css" .}}</head>
|
|
<body>
|
|
<header><a class="logo" href="/">💿 ISOSilo</a></header>
|
|
<main>
|
|
<nav class="bc">
|
|
{{range $i, $c := .Breadcrumbs}}<a href="{{$c.URL}}">{{$c.Name}}</a> {{if lt (add1 $i) (len $.Breadcrumbs)}}/{{end}} {{end}}
|
|
</nav>
|
|
|
|
<div class="grid">
|
|
{{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>
|
|
{{else}}
|
|
<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>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
</div>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
{{end}}
|
|
|
|
{{define "browse"}}
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>{{.Title}}</title>{{template "css" .}}</head>
|
|
<body>
|
|
<header><a class="logo" href="/">💿 ISOSilo</a></header>
|
|
<main>
|
|
<nav class="bc">
|
|
{{range $i, $c := .Breadcrumbs}}<a href="{{$c.URL}}">{{$c.Name}}</a> {{if lt (add1 $i) (len $.Breadcrumbs)}}/{{end}} {{end}}
|
|
</nav>
|
|
<table class="tbl">
|
|
<thead><tr><th>Name</th><th>Size</th><th>Action</th></tr></thead>
|
|
<tbody>
|
|
{{range .Entries}}
|
|
<tr>
|
|
<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>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
{{end}}
|
|
`
|