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
+109 -0 pkg/apps/pgs/gen_dir_listing.go #
......@@ -0,0 +1,109 @@
1+package pgs
2+
3+import (
4+ "bytes"
5+ "embed"
6+ "fmt"
7+ "html/template"
8+ "os"
9+
10+ sst "github.com/picosh/pico/pkg/pobj/storage"
11+)
12+
13+//go:embed html/*
14+var dirListingFS embed.FS
15+
16+var dirListingTmpl = template.Must(
17+ template.New("base").ParseFS(
18+ dirListingFS,
19+ "html/base.layout.tmpl",
20+ "html/marketing-footer.partial.tmpl",
21+ "html/directory_listing.page.tmpl",
22+ ),
23+)
24+
25+type dirEntryDisplay struct {
26+ Href string
27+ Display string
28+ Size string
29+ ModTime string
30+}
31+
32+type DirectoryListingData struct {
33+ Path string
34+ ShowParent bool
35+ Entries []dirEntryDisplay
36+}
37+
38+func formatFileSize(size int64) string {
39+ const (
40+ KB = 1024
41+ MB = KB * 1024
42+ GB = MB * 1024
43+ )
44+
45+ switch {
46+ case size >= GB:
47+ return fmt.Sprintf("%.1f GB", float64(size)/float64(GB))
48+ case size >= MB:
49+ return fmt.Sprintf("%.1f MB", float64(size)/float64(MB))
50+ case size >= KB:
51+ return fmt.Sprintf("%.1f KB", float64(size)/float64(KB))
52+ default:
53+ return fmt.Sprintf("%d B", size)
54+ }
55+}
56+
57+func toDisplayEntries(entries []os.FileInfo) []dirEntryDisplay {
58+ displayEntries := make([]dirEntryDisplay, 0, len(entries))
59+
60+ for _, entry := range entries {
61+ display := dirEntryDisplay{
62+ Href: entry.Name(),
63+ Display: entry.Name(),
64+ Size: formatFileSize(entry.Size()),
65+ ModTime: entry.ModTime().Format("2006-01-02 15:04"),
66+ }
67+
68+ if entry.IsDir() {
69+ display.Href += "/"
70+ display.Display += "/"
71+ display.Size = "-"
72+ }
73+
74+ displayEntries = append(displayEntries, display)
75+ }
76+
77+ return displayEntries
78+}
79+
80+func shouldGenerateListing(st sst.ObjectStorage, bucket sst.Bucket, projectDir string, path string) bool {
81+ dirPath := projectDir + path
82+ if path == "/" {
83+ dirPath = projectDir + "/"
84+ }
85+
86+ entries, err := st.ListObjects(bucket, dirPath, false)
87+ if err != nil || len(entries) == 0 {
88+ return false
89+ }
90+
91+ indexPath := dirPath + "index.html"
92+ _, _, err = st.GetObject(bucket, indexPath)
93+ return err != nil
94+}
95+
96+func generateDirectoryHTML(path string, entries []os.FileInfo) string {
97+ data := DirectoryListingData{
98+ Path: path,
99+ ShowParent: path != "/",
100+ Entries: toDisplayEntries(entries),
101+ }
102+
103+ var buf bytes.Buffer
104+ if err := dirListingTmpl.Execute(&buf, data); err != nil {
105+ return fmt.Sprintf("Error rendering directory listing: %s", err)
106+ }
107+
108+ return buf.String()
109+}
+181 -0 pkg/apps/pgs/gen_dir_listing_test.go #
......@@ -0,0 +1,181 @@
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 TestShouldGenerateListing(t *testing.T) {
98+ fixtures := []struct {
99+ Name string
100+ Path string
101+ Storage map[string]map[string]string
102+ Expected bool
103+ }{
104+ {
105+ Name: "directory-with-index-html",
106+ Path: "/docs/",
107+ Storage: map[string]map[string]string{
108+ "testbucket": {
109+ "/project/docs/index.html": "<html>hello</html>",
110+ },
111+ },
112+ Expected: false,
113+ },
114+ {
115+ Name: "directory-without-index-html",
116+ Path: "/docs/",
117+ Storage: map[string]map[string]string{
118+ "testbucket": {
119+ "/project/docs/readme.md": "# Readme",
120+ "/project/docs/guide.md": "# Guide",
121+ },
122+ },
123+ Expected: true,
124+ },
125+ {
126+ Name: "empty-directory",
127+ Path: "/empty/",
128+ Storage: map[string]map[string]string{
129+ "testbucket": {
130+ "/project/other/file.txt": "content",
131+ },
132+ },
133+ Expected: false,
134+ },
135+ {
136+ Name: "root-directory-without-index",
137+ Path: "/",
138+ Storage: map[string]map[string]string{
139+ "testbucket": {
140+ "/project/style.css": "body {}",
141+ "/project/app.js": "console.log('hi')",
142+ },
143+ },
144+ Expected: true,
145+ },
146+ {
147+ Name: "root-directory-with-index",
148+ Path: "/",
149+ Storage: map[string]map[string]string{
150+ "testbucket": {
151+ "/project/index.html": "<html>home</html>",
152+ },
153+ },
154+ Expected: false,
155+ },
156+ {
157+ Name: "nested-directory-without-index",
158+ Path: "/assets/images/",
159+ Storage: map[string]map[string]string{
160+ "testbucket": {
161+ "/project/assets/images/logo.png": "png data",
162+ "/project/assets/images/banner.jpg": "jpg data",
163+ },
164+ },
165+ Expected: true,
166+ },
167+ }
168+
169+ for _, fixture := range fixtures {
170+ t.Run(fixture.Name, func(t *testing.T) {
171+ st, _ := sst.NewStorageMemory(fixture.Storage)
172+ bucket := sst.Bucket{Name: "testbucket", Path: "testbucket"}
173+
174+ result := shouldGenerateListing(st, bucket, "project", fixture.Path)
175+
176+ if result != fixture.Expected {
177+ t.Errorf("shouldGenerateListing(%q) = %v, want %v", fixture.Path, result, fixture.Expected)
178+ }
179+ })
180+ }
181+}
+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