- 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
+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+}
+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+}
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}}
+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, ", "),
+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