pico

created pr with 91.1 on 2025-12-16T01:38:45Z · by c8ef7d19
added 91.2 on 2025-12-16T01:48:41Z · by c8ef7d19
1: a0c3196 ! 1: fbdea17 feat(pgs): show dir listing when no index.html present
cmds
checkout latest patchset:
ssh pr.pico.sh print 91 | git am -3
checkout any patchset in a patch request:
ssh pr.pico.sh print 91.[rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 91
+120 -0 pkg/apps/pgs/gen_dir_listing.go #
......@@ -0,0 +1,120 @@
1+package pgs
2+
3+import (
4+ "bytes"
5+ "embed"
6+ "fmt"
7+ "html/template"
8+ "os"
9+ "sort"
10+
11+ sst "github.com/picosh/pico/pkg/pobj/storage"
12+)
13+
14+//go:embed html/*
15+var dirListingFS embed.FS
16+
17+var dirListingTmpl = template.Must(
18+ template.New("base").ParseFS(
19+ dirListingFS,
20+ "html/base.layout.tmpl",
21+ "html/marketing-footer.partial.tmpl",
22+ "html/directory_listing.page.tmpl",
23+ ),
24+)
25+
26+type dirEntryDisplay struct {
27+ Href string
28+ Display string
29+ Size string
30+ ModTime string
31+}
32+
33+type DirectoryListingData struct {
34+ Path string
35+ ShowParent bool
36+ Entries []dirEntryDisplay
37+}
38+
39+func formatFileSize(size int64) string {
40+ const (
41+ KB = 1024
42+ MB = KB * 1024
43+ GB = MB * 1024
44+ )
45+
46+ switch {
47+ case size >= GB:
48+ return fmt.Sprintf("%.1f GB", float64(size)/float64(GB))
49+ case size >= MB:
50+ return fmt.Sprintf("%.1f MB", float64(size)/float64(MB))
51+ case size >= KB:
52+ return fmt.Sprintf("%.1f KB", float64(size)/float64(KB))
53+ default:
54+ return fmt.Sprintf("%d B", size)
55+ }
56+}
57+
58+func sortEntries(entries []os.FileInfo) {
59+ sort.Slice(entries, func(i, j int) bool {
60+ if entries[i].IsDir() != entries[j].IsDir() {
61+ return entries[i].IsDir()
62+ }
63+ return entries[i].Name() < entries[j].Name()
64+ })
65+}
66+
67+func toDisplayEntries(entries []os.FileInfo) []dirEntryDisplay {
68+ sortEntries(entries)
69+ displayEntries := make([]dirEntryDisplay, 0, len(entries))
70+
71+ for _, entry := range entries {
72+ display := dirEntryDisplay{
73+ Href: entry.Name(),
74+ Display: entry.Name(),
75+ Size: formatFileSize(entry.Size()),
76+ ModTime: entry.ModTime().Format("2006-01-02 15:04"),
77+ }
78+
79+ if entry.IsDir() {
80+ display.Href += "/"
81+ display.Display += "/"
82+ display.Size = "-"
83+ }
84+
85+ displayEntries = append(displayEntries, display)
86+ }
87+
88+ return displayEntries
89+}
90+
91+func shouldGenerateListing(st sst.ObjectStorage, bucket sst.Bucket, projectDir string, path string) bool {
92+ dirPath := projectDir + path
93+ if path == "/" {
94+ dirPath = projectDir + "/"
95+ }
96+
97+ entries, err := st.ListObjects(bucket, dirPath, false)
98+ if err != nil || len(entries) == 0 {
99+ return false
100+ }
101+
102+ indexPath := dirPath + "index.html"
103+ _, _, err = st.GetObject(bucket, indexPath)
104+ return err != nil
105+}
106+
107+func generateDirectoryHTML(path string, entries []os.FileInfo) string {
108+ data := DirectoryListingData{
109+ Path: path,
110+ ShowParent: path != "/",
111+ Entries: toDisplayEntries(entries),
112+ }
113+
114+ var buf bytes.Buffer
115+ if err := dirListingTmpl.Execute(&buf, data); err != nil {
116+ return fmt.Sprintf("Error rendering directory listing: %s", err)
117+ }
118+
119+ return buf.String()
120+}
+200 -0 pkg/apps/pgs/gen_dir_listing_test.go #
......@@ -0,0 +1,200 @@
1+package pgs
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ sst "github.com/picosh/pico/pkg/pobj/storage"
10+ "github.com/picosh/pico/pkg/send/utils"
11+)
12+
13+func TestGenerateDirectoryHTML(t *testing.T) {
14+ fixtures := []struct {
15+ Name string
16+ Path string
17+ Entries []os.FileInfo
18+ Contains []string
19+ }{
20+ {
21+ Name: "empty-directory",
22+ Path: "/",
23+ Entries: []os.FileInfo{},
24+ Contains: []string{
25+ "<title>Index of /</title>",
26+ "Index of /",
27+ },
28+ },
29+ {
30+ Name: "single-file",
31+ Path: "/",
32+ Entries: []os.FileInfo{
33+ &utils.VirtualFile{FName: "hello.txt", FSize: 1024, FIsDir: false, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
34+ },
35+ Contains: []string{
36+ "<title>Index of /</title>",
37+ `href="hello.txt"`,
38+ "hello.txt",
39+ "1.0 KB",
40+ },
41+ },
42+ {
43+ Name: "single-folder",
44+ Path: "/",
45+ Entries: []os.FileInfo{
46+ &utils.VirtualFile{FName: "docs", FSize: 0, FIsDir: true, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
47+ },
48+ Contains: []string{
49+ `href="docs/"`,
50+ "docs/",
51+ },
52+ },
53+ {
54+ Name: "mixed-entries",
55+ Path: "/assets/",
56+ Entries: []os.FileInfo{
57+ &utils.VirtualFile{FName: "images", FSize: 0, FIsDir: true, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
58+ &utils.VirtualFile{FName: "style.css", FSize: 2048, FIsDir: false, FModTime: time.Date(2025, 1, 14, 8, 0, 0, 0, time.UTC)},
59+ &utils.VirtualFile{FName: "app.js", FSize: 512, FIsDir: false, FModTime: time.Date(2025, 1, 13, 12, 0, 0, 0, time.UTC)},
60+ },
61+ Contains: []string{
62+ "<title>Index of /assets/</title>",
63+ `href="images/"`,
64+ `href="style.css"`,
65+ `href="app.js"`,
66+ "images/",
67+ "2.0 KB",
68+ },
69+ },
70+ {
71+ Name: "subdirectory-with-parent-link",
72+ Path: "/docs/api/",
73+ Entries: []os.FileInfo{
74+ &utils.VirtualFile{FName: "readme.md", FSize: 256, FIsDir: false, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
75+ },
76+ Contains: []string{
77+ "<title>Index of /docs/api/</title>",
78+ `href="../"`,
79+ "../",
80+ },
81+ },
82+ }
83+
84+ for _, fixture := range fixtures {
85+ t.Run(fixture.Name, func(t *testing.T) {
86+ html := generateDirectoryHTML(fixture.Path, fixture.Entries)
87+
88+ for _, expected := range fixture.Contains {
89+ if !strings.Contains(html, expected) {
90+ t.Errorf("expected HTML to contain %q, got:\n%s", expected, html)
91+ }
92+ }
93+ })
94+ }
95+}
96+
97+func TestSortEntries(t *testing.T) {
98+ entries := []os.FileInfo{
99+ &utils.VirtualFile{FName: "zebra.txt", FIsDir: false},
100+ &utils.VirtualFile{FName: "alpha", FIsDir: true},
101+ &utils.VirtualFile{FName: "beta.md", FIsDir: false},
102+ &utils.VirtualFile{FName: "zulu", FIsDir: true},
103+ &utils.VirtualFile{FName: "apple.js", FIsDir: false},
104+ }
105+
106+ sortEntries(entries)
107+
108+ expected := []string{"alpha", "zulu", "apple.js", "beta.md", "zebra.txt"}
109+ for i, entry := range entries {
110+ if entry.Name() != expected[i] {
111+ t.Errorf("position %d: expected %q, got %q", i, expected[i], entry.Name())
112+ }
113+ }
114+}
115+
116+func TestShouldGenerateListing(t *testing.T) {
117+ fixtures := []struct {
118+ Name string
119+ Path string
120+ Storage map[string]map[string]string
121+ Expected bool
122+ }{
123+ {
124+ Name: "directory-with-index-html",
125+ Path: "/docs/",
126+ Storage: map[string]map[string]string{
127+ "testbucket": {
128+ "/project/docs/index.html": "<html>hello</html>",
129+ },
130+ },
131+ Expected: false,
132+ },
133+ {
134+ Name: "directory-without-index-html",
135+ Path: "/docs/",
136+ Storage: map[string]map[string]string{
137+ "testbucket": {
138+ "/project/docs/readme.md": "# Readme",
139+ "/project/docs/guide.md": "# Guide",
140+ },
141+ },
142+ Expected: true,
143+ },
144+ {
145+ Name: "empty-directory",
146+ Path: "/empty/",
147+ Storage: map[string]map[string]string{
148+ "testbucket": {
149+ "/project/other/file.txt": "content",
150+ },
151+ },
152+ Expected: false,
153+ },
154+ {
155+ Name: "root-directory-without-index",
156+ Path: "/",
157+ Storage: map[string]map[string]string{
158+ "testbucket": {
159+ "/project/style.css": "body {}",
160+ "/project/app.js": "console.log('hi')",
161+ },
162+ },
163+ Expected: true,
164+ },
165+ {
166+ Name: "root-directory-with-index",
167+ Path: "/",
168+ Storage: map[string]map[string]string{
169+ "testbucket": {
170+ "/project/index.html": "<html>home</html>",
171+ },
172+ },
173+ Expected: false,
174+ },
175+ {
176+ Name: "nested-directory-without-index",
177+ Path: "/assets/images/",
178+ Storage: map[string]map[string]string{
179+ "testbucket": {
180+ "/project/assets/images/logo.png": "png data",
181+ "/project/assets/images/banner.jpg": "jpg data",
182+ },
183+ },
184+ Expected: true,
185+ },
186+ }
187+
188+ for _, fixture := range fixtures {
189+ t.Run(fixture.Name, func(t *testing.T) {
190+ st, _ := sst.NewStorageMemory(fixture.Storage)
191+ bucket := sst.Bucket{Name: "testbucket", Path: "testbucket"}
192+
193+ result := shouldGenerateListing(st, bucket, "project", fixture.Path)
194+
195+ if result != fixture.Expected {
196+ t.Errorf("shouldGenerateListing(%q) = %v, want %v", fixture.Path, result, fixture.Expected)
197+ }
198+ })
199+ }
200+}
+30 -0 pkg/apps/pgs/html/directory_listing.page.tmpl #
......@@ -0,0 +1,30 @@
1+{{template "base" .}}
2+
3+{{define "title"}}Index of {{.Path}}{{end}}
4+
5+{{define "meta"}}{{end}}
6+
7+{{define "attrs"}}class="container"{{end}}
8+
9+{{define "body"}}
10+<header>
11+ <h1 class="text-2xl">Index of {{.Path}}</h1>
12+ <hr />
13+</header>
14+<main>
15+ <table>
16+ <thead>
17+ <tr><th>Name</th><th>Size</th><th>Modified</th></tr>
18+ </thead>
19+ <tbody>
20+{{- if .ShowParent}}
21+ <tr><td><a href="../">../</a></td><td>-</td><td>-</td></tr>
22+{{- end}}
23+{{- range .Entries}}
24+ <tr><td><a href="{{.Href}}">{{.Display}}</a></td><td>{{.Size}}</td><td>{{.ModTime}}</td></tr>
25+{{- end}}
26+ </tbody>
27+ </table>
28+</main>
29+{{template "marketing-footer" .}}
30+{{end}}
+21 -0 pkg/apps/pgs/web_asset_handler.go #
......@@ -175,6 +175,27 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
175175 }
176176
177177 if assetFilepath == "" {
178+ if shouldGenerateListing(h.Cfg.Storage, h.Bucket, h.ProjectDir, "/"+fpath) {
179+ logger.Info(
180+ "generating directory listing",
181+ "path", fpath,
182+ )
183+ dirPath := h.ProjectDir + "/" + fpath
184+ entries, err := h.Cfg.Storage.ListObjects(h.Bucket, dirPath, false)
185+ if err == nil {
186+ requestPath := "/" + fpath
187+ if !strings.HasSuffix(requestPath, "/") {
188+ requestPath += "/"
189+ }
190+
191+ html := generateDirectoryHTML(requestPath, entries)
192+ w.Header().Set("content-type", "text/html")
193+ w.WriteHeader(http.StatusOK)
194+ _, _ = w.Write([]byte(html))
195+ return
196+ }
197+ }
198+
178199 logger.Info(
179200 "asset not found in bucket",
180201 "routes", strings.Join(attempts, ", "),
+117 -0 pkg/apps/pgs/web_test.go #
......@@ -358,6 +358,123 @@ func TestApiBasic(t *testing.T) {
358358 }
359359 }
360360
361+func TestDirectoryListing(t *testing.T) {
362+ logger := slog.Default()
363+ dbpool := NewPgsDb(logger)
364+ bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
365+
366+ tt := []struct {
367+ name string
368+ path string
369+ status int
370+ contentType string
371+ contains []string
372+ notContains []string
373+ storage map[string]map[string]string
374+ }{
375+ {
376+ name: "directory-without-index-shows-listing",
377+ path: "/docs/",
378+ status: http.StatusOK,
379+ contentType: "text/html",
380+ contains: []string{
381+ "Index of /docs/",
382+ "readme.md",
383+ "guide.md",
384+ },
385+ storage: map[string]map[string]string{
386+ bucketName: {
387+ "/test/docs/readme.md": "# Readme",
388+ "/test/docs/guide.md": "# Guide",
389+ },
390+ },
391+ },
392+ {
393+ name: "directory-with-index-serves-index",
394+ path: "/docs/",
395+ status: http.StatusOK,
396+ contentType: "text/html",
397+ contains: []string{"hello world!"},
398+ notContains: []string{"Index of"},
399+ storage: map[string]map[string]string{
400+ bucketName: {
401+ "/test/docs/index.html": "hello world!",
402+ "/test/docs/readme.md": "# Readme",
403+ },
404+ },
405+ },
406+ {
407+ name: "root-directory-without-index-shows-listing",
408+ path: "/",
409+ status: http.StatusOK,
410+ contentType: "text/html",
411+ contains: []string{
412+ "Index of /",
413+ "style.css",
414+ },
415+ storage: map[string]map[string]string{
416+ bucketName: {
417+ "/test/style.css": "body {}",
418+ },
419+ },
420+ },
421+ {
422+ name: "nested-directory-shows-parent-link",
423+ path: "/assets/images/",
424+ status: http.StatusOK,
425+ contentType: "text/html",
426+ contains: []string{
427+ "Index of /assets/images/",
428+ `href="../"`,
429+ "logo.png",
430+ },
431+ storage: map[string]map[string]string{
432+ bucketName: {
433+ "/test/assets/images/logo.png": "png data",
434+ },
435+ },
436+ },
437+ }
438+
439+ for _, tc := range tt {
440+ t.Run(tc.name, func(t *testing.T) {
441+ request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
442+ responseRecorder := httptest.NewRecorder()
443+
444+ st, _ := storage.NewStorageMemory(tc.storage)
445+ pubsub := NewPubsubChan()
446+ defer func() {
447+ _ = pubsub.Close()
448+ }()
449+ cfg := NewPgsConfig(logger, dbpool, st, pubsub)
450+ cfg.Domain = "pgs.test"
451+ router := NewWebRouter(cfg)
452+ router.ServeHTTP(responseRecorder, request)
453+
454+ if responseRecorder.Code != tc.status {
455+ t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
456+ }
457+
458+ ct := responseRecorder.Header().Get("content-type")
459+ if ct != tc.contentType {
460+ t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
461+ }
462+
463+ body := responseRecorder.Body.String()
464+ for _, want := range tc.contains {
465+ if !strings.Contains(body, want) {
466+ t.Errorf("Want body to contain '%s', got '%s'", want, body)
467+ }
468+ }
469+ for _, notWant := range tc.notContains {
470+ if strings.Contains(body, notWant) {
471+ t.Errorf("Want body to NOT contain '%s', got '%s'", notWant, body)
472+ }
473+ }
474+ })
475+ }
476+}
477+
361478 type ImageStorageMemory struct {
362479 *storage.StorageMemory
363480 Opts *storage.ImgProcessOpts
Back to top