repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
fbdea17
parent
31cd1e1
author
Eric Bower
date
2025-12-15 20:35:09 -0500 EST
feat(pgs): show dir listing when no index.html present
5 files changed,  +488, -0
A pkg/apps/pgs/gen_dir_listing.go
+120, -0
  1@@ -0,0 +1,120 @@
  2+package pgs
  3+
  4+import (
  5+	"bytes"
  6+	"embed"
  7+	"fmt"
  8+	"html/template"
  9+	"os"
 10+	"sort"
 11+
 12+	sst "github.com/picosh/pico/pkg/pobj/storage"
 13+)
 14+
 15+//go:embed html/*
 16+var dirListingFS embed.FS
 17+
 18+var dirListingTmpl = template.Must(
 19+	template.New("base").ParseFS(
 20+		dirListingFS,
 21+		"html/base.layout.tmpl",
 22+		"html/marketing-footer.partial.tmpl",
 23+		"html/directory_listing.page.tmpl",
 24+	),
 25+)
 26+
 27+type dirEntryDisplay struct {
 28+	Href    string
 29+	Display string
 30+	Size    string
 31+	ModTime string
 32+}
 33+
 34+type DirectoryListingData struct {
 35+	Path       string
 36+	ShowParent bool
 37+	Entries    []dirEntryDisplay
 38+}
 39+
 40+func formatFileSize(size int64) string {
 41+	const (
 42+		KB = 1024
 43+		MB = KB * 1024
 44+		GB = MB * 1024
 45+	)
 46+
 47+	switch {
 48+	case size >= GB:
 49+		return fmt.Sprintf("%.1f GB", float64(size)/float64(GB))
 50+	case size >= MB:
 51+		return fmt.Sprintf("%.1f MB", float64(size)/float64(MB))
 52+	case size >= KB:
 53+		return fmt.Sprintf("%.1f KB", float64(size)/float64(KB))
 54+	default:
 55+		return fmt.Sprintf("%d B", size)
 56+	}
 57+}
 58+
 59+func sortEntries(entries []os.FileInfo) {
 60+	sort.Slice(entries, func(i, j int) bool {
 61+		if entries[i].IsDir() != entries[j].IsDir() {
 62+			return entries[i].IsDir()
 63+		}
 64+		return entries[i].Name() < entries[j].Name()
 65+	})
 66+}
 67+
 68+func toDisplayEntries(entries []os.FileInfo) []dirEntryDisplay {
 69+	sortEntries(entries)
 70+	displayEntries := make([]dirEntryDisplay, 0, len(entries))
 71+
 72+	for _, entry := range entries {
 73+		display := dirEntryDisplay{
 74+			Href:    entry.Name(),
 75+			Display: entry.Name(),
 76+			Size:    formatFileSize(entry.Size()),
 77+			ModTime: entry.ModTime().Format("2006-01-02 15:04"),
 78+		}
 79+
 80+		if entry.IsDir() {
 81+			display.Href += "/"
 82+			display.Display += "/"
 83+			display.Size = "-"
 84+		}
 85+
 86+		displayEntries = append(displayEntries, display)
 87+	}
 88+
 89+	return displayEntries
 90+}
 91+
 92+func shouldGenerateListing(st sst.ObjectStorage, bucket sst.Bucket, projectDir string, path string) bool {
 93+	dirPath := projectDir + path
 94+	if path == "/" {
 95+		dirPath = projectDir + "/"
 96+	}
 97+
 98+	entries, err := st.ListObjects(bucket, dirPath, false)
 99+	if err != nil || len(entries) == 0 {
100+		return false
101+	}
102+
103+	indexPath := dirPath + "index.html"
104+	_, _, err = st.GetObject(bucket, indexPath)
105+	return err != nil
106+}
107+
108+func generateDirectoryHTML(path string, entries []os.FileInfo) string {
109+	data := DirectoryListingData{
110+		Path:       path,
111+		ShowParent: path != "/",
112+		Entries:    toDisplayEntries(entries),
113+	}
114+
115+	var buf bytes.Buffer
116+	if err := dirListingTmpl.Execute(&buf, data); err != nil {
117+		return fmt.Sprintf("Error rendering directory listing: %s", err)
118+	}
119+
120+	return buf.String()
121+}
A pkg/apps/pgs/gen_dir_listing_test.go
+200, -0
  1@@ -0,0 +1,200 @@
  2+package pgs
  3+
  4+import (
  5+	"os"
  6+	"strings"
  7+	"testing"
  8+	"time"
  9+
 10+	sst "github.com/picosh/pico/pkg/pobj/storage"
 11+	"github.com/picosh/pico/pkg/send/utils"
 12+)
 13+
 14+func TestGenerateDirectoryHTML(t *testing.T) {
 15+	fixtures := []struct {
 16+		Name     string
 17+		Path     string
 18+		Entries  []os.FileInfo
 19+		Contains []string
 20+	}{
 21+		{
 22+			Name:    "empty-directory",
 23+			Path:    "/",
 24+			Entries: []os.FileInfo{},
 25+			Contains: []string{
 26+				"<title>Index of /</title>",
 27+				"Index of /",
 28+			},
 29+		},
 30+		{
 31+			Name: "single-file",
 32+			Path: "/",
 33+			Entries: []os.FileInfo{
 34+				&utils.VirtualFile{FName: "hello.txt", FSize: 1024, FIsDir: false, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
 35+			},
 36+			Contains: []string{
 37+				"<title>Index of /</title>",
 38+				`href="hello.txt"`,
 39+				"hello.txt",
 40+				"1.0 KB",
 41+			},
 42+		},
 43+		{
 44+			Name: "single-folder",
 45+			Path: "/",
 46+			Entries: []os.FileInfo{
 47+				&utils.VirtualFile{FName: "docs", FSize: 0, FIsDir: true, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
 48+			},
 49+			Contains: []string{
 50+				`href="docs/"`,
 51+				"docs/",
 52+			},
 53+		},
 54+		{
 55+			Name: "mixed-entries",
 56+			Path: "/assets/",
 57+			Entries: []os.FileInfo{
 58+				&utils.VirtualFile{FName: "images", FSize: 0, FIsDir: true, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
 59+				&utils.VirtualFile{FName: "style.css", FSize: 2048, FIsDir: false, FModTime: time.Date(2025, 1, 14, 8, 0, 0, 0, time.UTC)},
 60+				&utils.VirtualFile{FName: "app.js", FSize: 512, FIsDir: false, FModTime: time.Date(2025, 1, 13, 12, 0, 0, 0, time.UTC)},
 61+			},
 62+			Contains: []string{
 63+				"<title>Index of /assets/</title>",
 64+				`href="images/"`,
 65+				`href="style.css"`,
 66+				`href="app.js"`,
 67+				"images/",
 68+				"2.0 KB",
 69+			},
 70+		},
 71+		{
 72+			Name: "subdirectory-with-parent-link",
 73+			Path: "/docs/api/",
 74+			Entries: []os.FileInfo{
 75+				&utils.VirtualFile{FName: "readme.md", FSize: 256, FIsDir: false, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
 76+			},
 77+			Contains: []string{
 78+				"<title>Index of /docs/api/</title>",
 79+				`href="../"`,
 80+				"../",
 81+			},
 82+		},
 83+	}
 84+
 85+	for _, fixture := range fixtures {
 86+		t.Run(fixture.Name, func(t *testing.T) {
 87+			html := generateDirectoryHTML(fixture.Path, fixture.Entries)
 88+
 89+			for _, expected := range fixture.Contains {
 90+				if !strings.Contains(html, expected) {
 91+					t.Errorf("expected HTML to contain %q, got:\n%s", expected, html)
 92+				}
 93+			}
 94+		})
 95+	}
 96+}
 97+
 98+func TestSortEntries(t *testing.T) {
 99+	entries := []os.FileInfo{
100+		&utils.VirtualFile{FName: "zebra.txt", FIsDir: false},
101+		&utils.VirtualFile{FName: "alpha", FIsDir: true},
102+		&utils.VirtualFile{FName: "beta.md", FIsDir: false},
103+		&utils.VirtualFile{FName: "zulu", FIsDir: true},
104+		&utils.VirtualFile{FName: "apple.js", FIsDir: false},
105+	}
106+
107+	sortEntries(entries)
108+
109+	expected := []string{"alpha", "zulu", "apple.js", "beta.md", "zebra.txt"}
110+	for i, entry := range entries {
111+		if entry.Name() != expected[i] {
112+			t.Errorf("position %d: expected %q, got %q", i, expected[i], entry.Name())
113+		}
114+	}
115+}
116+
117+func TestShouldGenerateListing(t *testing.T) {
118+	fixtures := []struct {
119+		Name     string
120+		Path     string
121+		Storage  map[string]map[string]string
122+		Expected bool
123+	}{
124+		{
125+			Name: "directory-with-index-html",
126+			Path: "/docs/",
127+			Storage: map[string]map[string]string{
128+				"testbucket": {
129+					"/project/docs/index.html": "<html>hello</html>",
130+				},
131+			},
132+			Expected: false,
133+		},
134+		{
135+			Name: "directory-without-index-html",
136+			Path: "/docs/",
137+			Storage: map[string]map[string]string{
138+				"testbucket": {
139+					"/project/docs/readme.md": "# Readme",
140+					"/project/docs/guide.md":  "# Guide",
141+				},
142+			},
143+			Expected: true,
144+		},
145+		{
146+			Name: "empty-directory",
147+			Path: "/empty/",
148+			Storage: map[string]map[string]string{
149+				"testbucket": {
150+					"/project/other/file.txt": "content",
151+				},
152+			},
153+			Expected: false,
154+		},
155+		{
156+			Name: "root-directory-without-index",
157+			Path: "/",
158+			Storage: map[string]map[string]string{
159+				"testbucket": {
160+					"/project/style.css": "body {}",
161+					"/project/app.js":    "console.log('hi')",
162+				},
163+			},
164+			Expected: true,
165+		},
166+		{
167+			Name: "root-directory-with-index",
168+			Path: "/",
169+			Storage: map[string]map[string]string{
170+				"testbucket": {
171+					"/project/index.html": "<html>home</html>",
172+				},
173+			},
174+			Expected: false,
175+		},
176+		{
177+			Name: "nested-directory-without-index",
178+			Path: "/assets/images/",
179+			Storage: map[string]map[string]string{
180+				"testbucket": {
181+					"/project/assets/images/logo.png":   "png data",
182+					"/project/assets/images/banner.jpg": "jpg data",
183+				},
184+			},
185+			Expected: true,
186+		},
187+	}
188+
189+	for _, fixture := range fixtures {
190+		t.Run(fixture.Name, func(t *testing.T) {
191+			st, _ := sst.NewStorageMemory(fixture.Storage)
192+			bucket := sst.Bucket{Name: "testbucket", Path: "testbucket"}
193+
194+			result := shouldGenerateListing(st, bucket, "project", fixture.Path)
195+
196+			if result != fixture.Expected {
197+				t.Errorf("shouldGenerateListing(%q) = %v, want %v", fixture.Path, result, fixture.Expected)
198+			}
199+		})
200+	}
201+}
A pkg/apps/pgs/html/directory_listing.page.tmpl
+30, -0
 1@@ -0,0 +1,30 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}Index of {{.Path}}{{end}}
 5+
 6+{{define "meta"}}{{end}}
 7+
 8+{{define "attrs"}}class="container"{{end}}
 9+
10+{{define "body"}}
11+<header>
12+  <h1 class="text-2xl">Index of {{.Path}}</h1>
13+  <hr />
14+</header>
15+<main>
16+  <table>
17+    <thead>
18+      <tr><th>Name</th><th>Size</th><th>Modified</th></tr>
19+    </thead>
20+    <tbody>
21+{{- if .ShowParent}}
22+      <tr><td><a href="../">../</a></td><td>-</td><td>-</td></tr>
23+{{- end}}
24+{{- range .Entries}}
25+      <tr><td><a href="{{.Href}}">{{.Display}}</a></td><td>{{.Size}}</td><td>{{.ModTime}}</td></tr>
26+{{- end}}
27+    </tbody>
28+  </table>
29+</main>
30+{{template "marketing-footer" .}}
31+{{end}}
M pkg/apps/pgs/web_asset_handler.go
+21, -0
 1@@ -175,6 +175,27 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 2 	}
 3 
 4 	if assetFilepath == "" {
 5+		if shouldGenerateListing(h.Cfg.Storage, h.Bucket, h.ProjectDir, "/"+fpath) {
 6+			logger.Info(
 7+				"generating directory listing",
 8+				"path", fpath,
 9+			)
10+			dirPath := h.ProjectDir + "/" + fpath
11+			entries, err := h.Cfg.Storage.ListObjects(h.Bucket, dirPath, false)
12+			if err == nil {
13+				requestPath := "/" + fpath
14+				if !strings.HasSuffix(requestPath, "/") {
15+					requestPath += "/"
16+				}
17+
18+				html := generateDirectoryHTML(requestPath, entries)
19+				w.Header().Set("content-type", "text/html")
20+				w.WriteHeader(http.StatusOK)
21+				_, _ = w.Write([]byte(html))
22+				return
23+			}
24+		}
25+
26 		logger.Info(
27 			"asset not found in bucket",
28 			"routes", strings.Join(attempts, ", "),
M pkg/apps/pgs/web_test.go
+117, -0
  1@@ -358,6 +358,123 @@ func TestApiBasic(t *testing.T) {
  2 	}
  3 }
  4 
  5+func TestDirectoryListing(t *testing.T) {
  6+	logger := slog.Default()
  7+	dbpool := NewPgsDb(logger)
  8+	bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
  9+
 10+	tt := []struct {
 11+		name        string
 12+		path        string
 13+		status      int
 14+		contentType string
 15+		contains    []string
 16+		notContains []string
 17+		storage     map[string]map[string]string
 18+	}{
 19+		{
 20+			name:        "directory-without-index-shows-listing",
 21+			path:        "/docs/",
 22+			status:      http.StatusOK,
 23+			contentType: "text/html",
 24+			contains: []string{
 25+				"Index of /docs/",
 26+				"readme.md",
 27+				"guide.md",
 28+			},
 29+			storage: map[string]map[string]string{
 30+				bucketName: {
 31+					"/test/docs/readme.md": "# Readme",
 32+					"/test/docs/guide.md":  "# Guide",
 33+				},
 34+			},
 35+		},
 36+		{
 37+			name:        "directory-with-index-serves-index",
 38+			path:        "/docs/",
 39+			status:      http.StatusOK,
 40+			contentType: "text/html",
 41+			contains:    []string{"hello world!"},
 42+			notContains: []string{"Index of"},
 43+			storage: map[string]map[string]string{
 44+				bucketName: {
 45+					"/test/docs/index.html": "hello world!",
 46+					"/test/docs/readme.md":  "# Readme",
 47+				},
 48+			},
 49+		},
 50+		{
 51+			name:        "root-directory-without-index-shows-listing",
 52+			path:        "/",
 53+			status:      http.StatusOK,
 54+			contentType: "text/html",
 55+			contains: []string{
 56+				"Index of /",
 57+				"style.css",
 58+			},
 59+			storage: map[string]map[string]string{
 60+				bucketName: {
 61+					"/test/style.css": "body {}",
 62+				},
 63+			},
 64+		},
 65+		{
 66+			name:        "nested-directory-shows-parent-link",
 67+			path:        "/assets/images/",
 68+			status:      http.StatusOK,
 69+			contentType: "text/html",
 70+			contains: []string{
 71+				"Index of /assets/images/",
 72+				`href="../"`,
 73+				"logo.png",
 74+			},
 75+			storage: map[string]map[string]string{
 76+				bucketName: {
 77+					"/test/assets/images/logo.png": "png data",
 78+				},
 79+			},
 80+		},
 81+	}
 82+
 83+	for _, tc := range tt {
 84+		t.Run(tc.name, func(t *testing.T) {
 85+			request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
 86+			responseRecorder := httptest.NewRecorder()
 87+
 88+			st, _ := storage.NewStorageMemory(tc.storage)
 89+			pubsub := NewPubsubChan()
 90+			defer func() {
 91+				_ = pubsub.Close()
 92+			}()
 93+			cfg := NewPgsConfig(logger, dbpool, st, pubsub)
 94+			cfg.Domain = "pgs.test"
 95+			router := NewWebRouter(cfg)
 96+			router.ServeHTTP(responseRecorder, request)
 97+
 98+			if responseRecorder.Code != tc.status {
 99+				t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
100+			}
101+
102+			ct := responseRecorder.Header().Get("content-type")
103+			if ct != tc.contentType {
104+				t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
105+			}
106+
107+			body := responseRecorder.Body.String()
108+			for _, want := range tc.contains {
109+				if !strings.Contains(body, want) {
110+					t.Errorf("Want body to contain '%s', got '%s'", want, body)
111+				}
112+			}
113+			for _, notWant := range tc.notContains {
114+				if strings.Contains(body, notWant) {
115+					t.Errorf("Want body to NOT contain '%s', got '%s'", notWant, body)
116+				}
117+			}
118+		})
119+	}
120+}
121+
122 type ImageStorageMemory struct {
123 	*storage.StorageMemory
124 	Opts  *storage.ImgProcessOpts