add support for sub folders
This commit is contained in:
parent
d2a360caf1
commit
2c1c506a45
2 changed files with 181 additions and 462 deletions
|
|
@ -1,4 +1,3 @@
|
||||||
// Package handlers provides HTTP handler functions for the ISO server.
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -18,13 +17,11 @@ import (
|
||||||
"isosilo/internal/iso"
|
"isosilo/internal/iso"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler holds shared state for all HTTP handlers.
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
dir string
|
dir string
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Handler that serves ISOs from the given directory.
|
|
||||||
func New(dir string) *Handler {
|
func New(dir string) *Handler {
|
||||||
h := &Handler{dir: dir}
|
h := &Handler{dir: dir}
|
||||||
h.tmpl = template.Must(
|
h.tmpl = template.Must(
|
||||||
|
|
@ -40,101 +37,98 @@ func New(dir string) *Handler {
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
type libraryEntry struct {
|
||||||
// Route: GET / → list all ISO files in the directory
|
Name string
|
||||||
// -----------------------------------------------------------------------
|
RelativePath string
|
||||||
|
IsDir bool
|
||||||
type isoInfo struct {
|
IsISO bool
|
||||||
Name string
|
Size int64
|
||||||
Size int64
|
ModTime time.Time
|
||||||
ModTime time.Time
|
Description string
|
||||||
Description string
|
HasImage bool
|
||||||
HasImage bool
|
ImageExt string
|
||||||
ImageExt string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ListISOs(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListISOs(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
// The path after / is the directory we are browsing in the library
|
||||||
http.NotFound(w, r)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(h.dir)
|
entries, err := os.ReadDir(fullPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "cannot read directory", http.StatusInternalServerError)
|
http.Error(w, "Directory not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var isos []isoInfo
|
var items []libraryEntry
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() || !strings.EqualFold(filepath.Ext(e.Name()), ".iso") {
|
name := e.Name()
|
||||||
continue
|
relPath := filepath.ToSlash(filepath.Join(relDir, name))
|
||||||
}
|
info, _ := e.Info()
|
||||||
info, err := e.Info()
|
|
||||||
if err != nil {
|
isISO := !e.IsDir() && strings.EqualFold(filepath.Ext(name), ".iso")
|
||||||
continue
|
|
||||||
|
item := libraryEntry{
|
||||||
|
Name: name,
|
||||||
|
RelativePath: relPath,
|
||||||
|
IsDir: e.IsDir(),
|
||||||
|
IsISO: isISO,
|
||||||
|
ModTime: info.ModTime(),
|
||||||
|
Size: info.Size(),
|
||||||
}
|
}
|
||||||
|
|
||||||
baseName := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
|
if isISO {
|
||||||
|
basePath := filepath.Join(h.dir, relDir, strings.TrimSuffix(name, ".iso"))
|
||||||
// Look for Description (.txt)
|
// Metadata
|
||||||
desc, _ := os.ReadFile(filepath.Join(h.dir, baseName+".txt"))
|
if d, err := os.ReadFile(basePath + ".txt"); err == nil {
|
||||||
|
item.Description = string(d)
|
||||||
// Look for Image (.png, .jpg, .jpeg)
|
}
|
||||||
hasImg := false
|
// Cover Image
|
||||||
imgExt := ""
|
for _, ext := range []string{".png", ".jpg", ".jpeg"} {
|
||||||
for _, ext := range []string{".png", ".jpg", ".jpeg"} {
|
if _, err := os.Stat(basePath + ext); err == nil {
|
||||||
if _, err := os.Stat(filepath.Join(h.dir, baseName+ext)); err == nil {
|
item.HasImage = true
|
||||||
hasImg = true
|
item.ImageExt = ext
|
||||||
imgExt = ext
|
break
|
||||||
break
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isos = append(isos, isoInfo{
|
// Only show Directories or ISO files
|
||||||
Name: e.Name(),
|
if item.IsDir || item.IsISO {
|
||||||
Size: info.Size(),
|
items = append(items, item)
|
||||||
ModTime: info.ModTime(),
|
}
|
||||||
Description: string(desc),
|
|
||||||
HasImage: hasImg,
|
|
||||||
ImageExt: imgExt,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(isos, func(i, j int) bool {
|
sort.Slice(items, func(i, j int) bool {
|
||||||
return strings.ToLower(isos[i].Name) < strings.ToLower(isos[j].Name)
|
if items[i].IsDir != items[j].IsDir { return items[i].IsDir }
|
||||||
|
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
h.tmpl.ExecuteTemplate(w, "index", map[string]any{
|
||||||
if err := h.tmpl.ExecuteTemplate(w, "index", map[string]any{
|
"Title": "ISO Library",
|
||||||
"Title": "ISO Library",
|
"Items": items,
|
||||||
"ISOs": isos,
|
"CurrentPath": relDir,
|
||||||
"Dir": h.dir,
|
"Breadcrumbs": buildLibraryBreadcrumbs(relDir),
|
||||||
}); err != nil {
|
})
|
||||||
log.Printf("template/index error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// ... BrowseISO, DownloadFile, and RawFile remain largely same as previous version ...
|
||||||
// Route: GET /browse/{iso}[/{path...}] → browse inside an ISO
|
// ... Ensure BrowseISO and DownloadFile use the full relative path to open the 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) {
|
func (h *Handler) BrowseISO(w http.ResponseWriter, r *http.Request) {
|
||||||
isoName, internalPath, ok := parsePath(r.URL.Path, "/browse/")
|
isoPath, internalPath, ok := parsePath(r.URL.Path, "/browse/")
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := h.openISO(isoName)
|
reader, err := h.openISO(isoPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusNotFound)
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
return
|
return
|
||||||
|
|
@ -143,41 +137,32 @@ func (h *Handler) BrowseISO(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
entries, err := reader.ListDir(internalPath)
|
entries, err := reader.ListDir(internalPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("cannot list directory: %v", err), http.StatusNotFound)
|
http.Error(w, "cannot list directory", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
if entries[i].IsDir != entries[j].IsDir {
|
if entries[i].IsDir != entries[j].IsDir { return entries[i].IsDir }
|
||||||
return entries[i].IsDir
|
|
||||||
}
|
|
||||||
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
|
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
h.tmpl.ExecuteTemplate(w, "browse", map[string]any{
|
||||||
if err := h.tmpl.ExecuteTemplate(w, "browse", browseData{
|
"Title": filepath.Base(isoPath),
|
||||||
Title: fmt.Sprintf("%s — /%s", isoName, internalPath),
|
"ISOName": isoPath,
|
||||||
ISOName: isoName,
|
"InternalPath": internalPath,
|
||||||
InternalPath: internalPath,
|
"Entries": entries,
|
||||||
Entries: entries,
|
"Breadcrumbs": buildISOBreadcrumbs(isoPath, internalPath),
|
||||||
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) {
|
func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
isoName, internalPath, ok := parsePath(r.URL.Path, "/file/")
|
isoPath, internalPath, ok := parsePath(r.URL.Path, "/file/")
|
||||||
if !ok || internalPath == "" {
|
if !ok || internalPath == "" {
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := h.openISO(isoName)
|
reader, err := h.openISO(isoPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusNotFound)
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
return
|
return
|
||||||
|
|
@ -186,7 +171,7 @@ func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
rc, size, err := reader.FileReader(internalPath)
|
rc, size, err := reader.FileReader(internalPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("cannot open file: %v", err), http.StatusNotFound)
|
http.Error(w, "file not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rc.Close()
|
defer rc.Close()
|
||||||
|
|
@ -194,14 +179,11 @@ func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
filename := filepath.Base(internalPath)
|
filename := filepath.Base(internalPath)
|
||||||
ext := strings.ToLower(filepath.Ext(filename))
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
ct := mime.TypeByExtension(ext)
|
ct := mime.TypeByExtension(ext)
|
||||||
if ct == "" {
|
if ct == "" { ct = "application/octet-stream" }
|
||||||
ct = "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if browser can/should view it inline
|
|
||||||
viewable := false
|
viewable := false
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".pdf", ".txt", ".jpg", ".jpeg", ".png", ".gif", ".mp4", ".mp3", ".webp", ".svg":
|
case ".pdf", ".txt", ".jpg", ".jpeg", ".png", ".gif", ".mp4", ".mp3", ".webp":
|
||||||
viewable = true
|
viewable = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,124 +195,69 @@ func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
w.Header().Set("Content-Type", ct)
|
w.Header().Set("Content-Type", ct)
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
||||||
|
io.Copy(w, rc)
|
||||||
if _, err := io.Copy(w, rc); err != nil {
|
|
||||||
log.Printf("stream error %q from %q: %v", internalPath, isoName, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Route: GET /raw/{path...} → serve actual files from the disk
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *Handler) RawFile(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) RawFile(w http.ResponseWriter, r *http.Request) {
|
||||||
fileName := strings.TrimPrefix(r.URL.Path, "/raw/")
|
fileName := strings.TrimPrefix(r.URL.Path, "/raw/")
|
||||||
if fileName == "" || strings.Contains(fileName, "..") {
|
http.ServeFile(w, r, filepath.Join(h.dir, fileName))
|
||||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fullPath := filepath.Join(h.dir, fileName)
|
|
||||||
http.ServeFile(w, r, fullPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
func (h *Handler) openISO(relPath string) (*iso.Reader, error) {
|
||||||
// Helpers
|
return iso.Open(filepath.Join(h.dir, relPath))
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parsePath(urlPath, prefix string) (isoName, internalPath string, ok bool) {
|
func parsePath(urlPath, prefix string) (isoPath, internalPath string, ok bool) {
|
||||||
rest := strings.TrimPrefix(urlPath, prefix)
|
rest := strings.TrimPrefix(urlPath, prefix)
|
||||||
if rest == "" {
|
decoded, _ := url.PathUnescape(rest)
|
||||||
return "", "", false
|
lower := strings.ToLower(decoded)
|
||||||
}
|
idx := strings.Index(lower, ".iso")
|
||||||
decoded, err := url.PathUnescape(rest)
|
if idx == -1 { return decoded, "", true }
|
||||||
if err != nil {
|
return decoded[:idx+4], strings.TrimPrefix(decoded[idx+4:], "/"), true
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
idx := strings.Index(decoded, "/")
|
|
||||||
if idx == -1 {
|
|
||||||
return decoded, "", true
|
|
||||||
}
|
|
||||||
return decoded[:idx], strings.TrimPrefix(decoded[idx:], "/"), true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type breadcrumb struct {
|
func buildLibraryBreadcrumbs(relDir string) []breadcrumb {
|
||||||
Name string
|
crumbs := []breadcrumb{{Name: "Library", URL: "/"}}
|
||||||
URL string
|
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 buildBreadcrumbs(isoName, internalPath string) []breadcrumb {
|
func buildISOBreadcrumbs(isoPath, internalPath string) []breadcrumb {
|
||||||
crumbs := []breadcrumb{
|
crumbs := buildLibraryBreadcrumbs(filepath.Dir(isoPath))
|
||||||
{Name: "Library", URL: "/"},
|
if crumbs[len(crumbs)-1].Name == "." { crumbs = crumbs[:len(crumbs)-1] }
|
||||||
{Name: isoName, URL: "/browse/" + url.PathEscape(isoName)},
|
|
||||||
}
|
crumbs = append(crumbs, breadcrumb{Name: filepath.Base(isoPath), URL: "/browse/" + url.PathEscape(isoPath)})
|
||||||
if internalPath == "" {
|
if internalPath != "" {
|
||||||
return crumbs
|
acc := ""
|
||||||
}
|
for _, p := range strings.Split(internalPath, "/") {
|
||||||
accumulated := ""
|
if p == "" { continue }
|
||||||
for _, part := range strings.Split(internalPath, "/") {
|
if acc == "" { acc = p } else { acc += "/" + p }
|
||||||
if part == "" {
|
crumbs = append(crumbs, breadcrumb{Name: p, URL: "/browse/" + url.PathEscape(isoPath) + "/" + acc})
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if accumulated == "" {
|
|
||||||
accumulated = part
|
|
||||||
} else {
|
|
||||||
accumulated += "/" + part
|
|
||||||
}
|
|
||||||
crumbs = append(crumbs, breadcrumb{
|
|
||||||
Name: part,
|
|
||||||
URL: "/browse/" + url.PathEscape(isoName) + "/" + accumulated,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return crumbs
|
return crumbs
|
||||||
}
|
}
|
||||||
|
|
||||||
func humanSize(n int64) string {
|
func humanSize(n int64) string {
|
||||||
if n < 1024 {
|
if n < 1024 { return fmt.Sprintf("%d B", n) }
|
||||||
return fmt.Sprintf("%d B", n)
|
|
||||||
}
|
|
||||||
div, exp := int64(1024), 0
|
div, exp := int64(1024), 0
|
||||||
for v := n / 1024; v >= 1024; v /= 1024 {
|
for v := n / 1024; v >= 1024; v /= 1024 { div *= 1024; exp++ }
|
||||||
div *= 1024
|
|
||||||
exp++
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
|
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||||
}
|
}
|
||||||
|
|
||||||
func fileIcon(name string) string {
|
func fileIcon(name string) string {
|
||||||
switch strings.ToLower(filepath.Ext(name)) {
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
case ".iso", ".img", ".dmg":
|
switch ext {
|
||||||
return "💿"
|
case ".iso": return "💿"
|
||||||
case ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar":
|
case ".pdf": return "📄"
|
||||||
return "📦"
|
case ".jpg", ".png": return "🖼️"
|
||||||
case ".pdf":
|
default: return "📄"
|
||||||
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 "📄"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,294 +265,96 @@ const allTemplates = `
|
||||||
{{define "css"}}
|
{{define "css"}}
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1117;
|
--bg: #0f1117; --surface: #1a1d27; --border: #252836;
|
||||||
--surface: #1a1d27;
|
--accent: #4f8ef7; --text: #e2e4ef; --muted: #6b7090; --radius: 8px;
|
||||||
--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: system-ui; margin: 0; }
|
||||||
body {
|
header { background: #12151f; border-bottom: 1px solid var(--border); padding: 0 2rem; height: 56px; display: flex; align-items: center; }
|
||||||
background: var(--bg);
|
.logo { color: var(--accent); font-weight: bold; text-decoration: none; font-family: monospace; }
|
||||||
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; }
|
main { max-width: 1100px; margin: 0 auto; padding: 2rem; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
|
||||||
h1 {
|
.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; }
|
||||||
font-size: 1.4rem;
|
.card:hover { transform: translateY(-3px); border-color: var(--accent); }
|
||||||
font-weight: 700;
|
.card-img { width: 100%; height: 150px; object-fit: cover; background: #000; }
|
||||||
margin-bottom: 1.5rem;
|
.folder-icon { height: 150px; display: flex; align-items: center; justify-content: center; font-size: 4rem; background: #1c202d; color: #f7c948; }
|
||||||
display: flex;
|
.iso-icon { height: 150px; display: flex; align-items: center; justify-content: center; font-size: 4rem; background: #12151f; }
|
||||||
align-items: center;
|
.card-body { padding: 1rem; }
|
||||||
gap: .6rem;
|
.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; }
|
||||||
.badge {
|
.bc { background: var(--surface); padding: 0.6rem 1rem; border-radius: 4px; margin-bottom: 1.5rem; font-size: 0.85rem; }
|
||||||
font-size: .68rem;
|
.bc a { color: var(--accent); text-decoration: none; }
|
||||||
font-family: var(--mono);
|
.tbl { width: 100%; border-collapse: collapse; background: var(--surface); border-radius: var(--radius); overflow: hidden; }
|
||||||
background: var(--purple);
|
.tbl th { text-align: left; padding: 1rem; background: #1e2132; color: var(--muted); font-size: 0.75rem; }
|
||||||
color: #fff;
|
.tbl td { padding: 1rem; border-bottom: 1px solid var(--border); }
|
||||||
padding: .15em .6em;
|
.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; }
|
||||||
border-radius: 999px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: .04em;
|
|
||||||
}
|
|
||||||
.stats { font-size: .82rem; color: var(--muted); margin-bottom: 1rem; }
|
|
||||||
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px,1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
transition: border-color .15s, transform .15s;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
|
||||||
|
|
||||||
.card-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 180px;
|
|
||||||
object-fit: cover;
|
|
||||||
background: #12151f;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.card-no-img {
|
|
||||||
height: 100px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 3rem;
|
|
||||||
background: #12151f;
|
|
||||||
}
|
|
||||||
.card-body { padding: 1.2rem; display: flex; flex-direction: column; flex-grow: 1; }
|
|
||||||
.card-name { font-family: var(--mono); font-size: .95rem; font-weight: 600; color: var(--accent); word-break: break-all; margin-bottom: 0.5rem; }
|
|
||||||
.card-desc { font-size: .8rem; color: var(--muted); margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; flex-grow: 1; }
|
|
||||||
.card-meta { font-size: .78rem; color: var(--muted); display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.card-actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
|
|
||||||
|
|
||||||
.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;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.dl:hover { background: var(--accent); color: #fff; text-decoration: none; }
|
|
||||||
.dl.secondary { color: var(--muted); border-color: var(--border); }
|
|
||||||
.dl.secondary:hover { background: var(--border); color: var(--text); }
|
|
||||||
|
|
||||||
.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>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "index"}}<!DOCTYPE html>
|
{{define "index"}}
|
||||||
<html lang="en">
|
<!DOCTYPE html>
|
||||||
<head>
|
<html>
|
||||||
<meta charset="UTF-8">
|
<head><title>{{.Title}}</title>{{template "css" .}}</head>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>{{.Title}}</title>
|
|
||||||
{{template "css" .}}
|
|
||||||
</head>
|
|
||||||
<body>
|
<body>
|
||||||
<header><a class="logo" href="/">💿 ISOSilo</a></header>
|
<header><a class="logo" href="/">💿 ISOSilo</a></header>
|
||||||
<main>
|
<main>
|
||||||
<h1>ISO Library <span class="badge">{{len .ISOs}} images</span></h1>
|
<nav class="bc">
|
||||||
<p class="stats">Serving from <code>{{.Dir}}</code></p>
|
{{range $i, $c := .Breadcrumbs}}<a href="{{$c.URL}}">{{$c.Name}}</a> {{if lt (add1 $i) (len $.Breadcrumbs)}}/{{end}} {{end}}
|
||||||
|
</nav>
|
||||||
|
|
||||||
{{if .ISOs}}
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{{range .ISOs}}
|
{{range .Items}}
|
||||||
<div class="card">
|
{{if .IsDir}}
|
||||||
{{if .HasImage}}
|
<a href="/{{urlenc .RelativePath}}" class="card">
|
||||||
<img src="/raw/{{urlenc (trimExt .Name)}}{{.ImageExt}}" class="card-img" alt="Cover">
|
<div class="folder-icon">📁</div>
|
||||||
|
<div class="card-body"><span class="card-name">{{.Name}}</span></div>
|
||||||
|
</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="card-no-img">💿</div>
|
<div class="card">
|
||||||
{{end}}
|
<a href="/browse/{{urlenc .RelativePath}}">
|
||||||
<div class="card-body">
|
{{if .HasImage}}<img src="/raw/{{urlenc (trimExt .RelativePath)}}{{.ImageExt}}" class="card-img">
|
||||||
<a href="/browse/{{urlenc .Name}}" class="card-name">{{.Name}}</a>
|
{{else}}<div class="iso-icon">💿</div>{{end}}
|
||||||
<p class="card-desc">{{if .Description}}{{.Description}}{{else}}No description available.{{end}}</p>
|
</a>
|
||||||
<div class="card-meta">
|
<div class="card-body">
|
||||||
<span>{{humanSize .Size}}</span>
|
<a href="/browse/{{urlenc .RelativePath}}" class="card-name">{{.Name}}</a>
|
||||||
<span>{{.ModTime.Format "2006-01-02"}}</span>
|
<p class="card-desc">{{if .Description}}{{.Description}}{{else}}ISO Disk Image{{end}}</p>
|
||||||
</div>
|
<div style="margin-top:1rem; display:flex; gap:0.5rem;">
|
||||||
<div class="card-actions">
|
<a href="/raw/{{urlenc .RelativePath}}" class="dl-btn" download>Download ISO</a>
|
||||||
<a href="/browse/{{urlenc .Name}}" class="dl">Browse Files</a>
|
</div>
|
||||||
<a href="/raw/{{urlenc .Name}}" class="dl secondary" download>Download ISO</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
|
||||||
<div class="empty"><p>No .iso files found in the served directory.</p></div>
|
|
||||||
{{end}}
|
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "browse"}}<!DOCTYPE html>
|
{{define "browse"}}
|
||||||
<html lang="en">
|
<!DOCTYPE html>
|
||||||
<head>
|
<html>
|
||||||
<meta charset="UTF-8">
|
<head><title>{{.Title}}</title>{{template "css" .}}</head>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>{{.Title}}</title>
|
|
||||||
{{template "css" .}}
|
|
||||||
</head>
|
|
||||||
<body>
|
<body>
|
||||||
<header><a class="logo" href="/">💿 ISOSilo</a></header>
|
<header><a class="logo" href="/">💿 ISOSilo</a></header>
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
<nav class="bc">
|
<nav class="bc">
|
||||||
{{$crumbs := .Breadcrumbs}}{{$last := len $crumbs}}
|
{{range $i, $c := .Breadcrumbs}}<a href="{{$c.URL}}">{{$c.Name}}</a> {{if lt (add1 $i) (len $.Breadcrumbs)}}/{{end}} {{end}}
|
||||||
{{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>
|
</nav>
|
||||||
|
|
||||||
<h1>
|
|
||||||
{{if .InternalPath}}📁 {{base .InternalPath}}{{else}}💿 {{.ISOName}}{{end}}
|
|
||||||
<span class="badge">{{len .Entries}} items</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{{if .Entries}}
|
|
||||||
<table class="tbl">
|
<table class="tbl">
|
||||||
<thead>
|
<thead><tr><th>Name</th><th>Size</th><th>Action</th></tr></thead>
|
||||||
<tr>
|
|
||||||
<th style="width:55%">Name</th>
|
|
||||||
<th>Size</th>
|
|
||||||
<th>Modified</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Entries}}
|
{{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>
|
<tr>
|
||||||
<td><div class="nc"><span>{{fileIcon .Name}}</span><a href="/file/{{urlenc $.ISOName}}/{{urlenc .Path}}" target="_blank">{{.Name}}</a></div></td>
|
<td>
|
||||||
<td class="sz">{{humanSize .Size}}</td>
|
{{if .IsDir}}📁 <a href="/browse/{{urlenc $.ISOName}}/{{urlenc .Path}}" style="color:var(--text); text-decoration:none;">{{.Name}}</a>
|
||||||
<td class="dt">{{.ModTime.Format "2006-01-02 15:04"}}</td>
|
{{else}}{{fileIcon .Name}} <a href="/file/{{urlenc $.ISOName}}/{{urlenc .Path}}" target="_blank" style="color:var(--text); text-decoration:none;">{{.Name}}</a>{{end}}
|
||||||
<td class="ac"><a class="dl" href="/file/{{urlenc $.ISOName}}/{{urlenc .Path}}" download>↓ Download</a></td>
|
</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>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{else}}
|
|
||||||
<div class="empty"><p>This directory is empty.</p></div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
24
main.go
24
main.go
|
|
@ -11,33 +11,23 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
dir := flag.String("dir", ".", "Directory containing ISO files to serve")
|
dir := flag.String("dir", ".", "Directory to serve")
|
||||||
addr := flag.String("addr", ":8080", "Address to listen on")
|
addr := flag.String("addr", ":8080", "Address")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
info, err := os.Stat(*dir)
|
if info, err := os.Stat(*dir); err != nil || !info.IsDir() {
|
||||||
if err != nil {
|
log.Fatalf("Invalid directory: %s", *dir)
|
||||||
log.Fatalf("Cannot access directory %q: %v", *dir, err)
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
log.Fatalf("%q is not a directory", *dir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h := handlers.New(*dir)
|
h := handlers.New(*dir)
|
||||||
|
|
||||||
// Routes:
|
// 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...} → stream/view a file from inside ISO
|
|
||||||
// GET /raw/{filename} → serve raw disk files (ISOs, covers, descriptions)
|
|
||||||
mux.HandleFunc("/", h.ListISOs)
|
|
||||||
mux.HandleFunc("/browse/", h.BrowseISO)
|
mux.HandleFunc("/browse/", h.BrowseISO)
|
||||||
mux.HandleFunc("/file/", h.DownloadFile)
|
mux.HandleFunc("/file/", h.DownloadFile)
|
||||||
mux.HandleFunc("/raw/", h.RawFile)
|
mux.HandleFunc("/raw/", h.RawFile)
|
||||||
|
mux.HandleFunc("/", h.ListISOs) // Catch-all for directory navigation
|
||||||
|
|
||||||
fmt.Printf("ISOSilo listening on %s\n", *addr)
|
fmt.Printf("ISOSilo running at %s\n", *addr)
|
||||||
fmt.Printf("Serving ISOs from: %s\n", *dir)
|
|
||||||
log.Fatal(http.ListenAndServe(*addr, mux))
|
log.Fatal(http.ListenAndServe(*addr, mux))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue