summaryrefslogtreecommitdiff
path: root/contrib/fs/dir.go
diff options
context:
space:
mode:
authortjpcc <tjp@ctrl-c.club>2023-01-09 16:40:24 -0700
committertjpcc <tjp@ctrl-c.club>2023-01-09 16:40:24 -0700
commitff05d62013906f3086b452bfeda3e0d5b9b7a541 (patch)
tree3be29de0b1bc7c273041c6d89b71ca447c940556 /contrib/fs/dir.go
Initial commit.
some basics: - minimal README - some TODOs - server and request handler framework - contribs: file serving, request logging - server examples - CI setup
Diffstat (limited to 'contrib/fs/dir.go')
-rw-r--r--contrib/fs/dir.go174
1 files changed, 174 insertions, 0 deletions
diff --git a/contrib/fs/dir.go b/contrib/fs/dir.go
new file mode 100644
index 0000000..b219e22
--- /dev/null
+++ b/contrib/fs/dir.go
@@ -0,0 +1,174 @@
+package fs
+
+import (
+ "bytes"
+ "context"
+ "io/fs"
+ "sort"
+ "strings"
+ "text/template"
+
+ "tildegit.org/tjp/gus/gemini"
+)
+
+// DirectoryDefault handles directory path requests by looking for specific filenames.
+//
+// If any of the supported filenames are found, the contents of the file is returned
+// as the gemini response.
+//
+// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
+//
+// When it encounters a directory path which doesn't end in a trailing slash (/) it
+// redirects to a URL with the trailing slash appended. This is necessary for relative
+// links into the directory's contents to function.
+//
+// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
+// it will also produce "51 Not Found" responses for directory paths.
+func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gemini.Handler {
+ return func(ctx context.Context, req *gemini.Request) *gemini.Response {
+ path, dirFile, resp := handleDir(req, fileSystem)
+ if resp != nil {
+ return resp
+ }
+ defer dirFile.Close()
+
+ entries, err := dirFile.ReadDir(0)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ for _, fileName := range fileNames {
+ for _, entry := range entries {
+ if entry.Name() == fileName {
+ file, err := fileSystem.Open(path + "/" + fileName)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ return gemini.Success(mediaType(fileName), file)
+ }
+ }
+ }
+
+ return gemini.NotFound("Resource does not exist.")
+ }
+}
+
+// DirectoryListing produces a gemtext listing of the contents of any requested directories.
+//
+// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
+//
+// When it encounters a directory path which doesn't end in a trailing slash (/) it
+// redirects to a URL with the trailing slash appended. This is necessary for relative
+// links into the directory's contents to function.
+//
+// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
+// it will also produce "51 Not Found" responses for directory paths.
+//
+// The template is provided the following namespace:
+// - .FullPath: the complete path to the listed directory
+// - .DirName: the name of the directory itself
+// - .Entries: the []fs.DirEntry of the directory contents
+//
+// The template argument may be nil, in which case a simple default template is used.
+func DirectoryListing(fileSystem fs.FS, template *template.Template) gemini.Handler {
+ return func(ctx context.Context, req *gemini.Request) *gemini.Response {
+ path, dirFile, resp := handleDir(req, fileSystem)
+ if resp != nil {
+ return resp
+ }
+ defer dirFile.Close()
+
+ if template == nil {
+ template = defaultDirListTemplate
+ }
+
+ buf := &bytes.Buffer{}
+
+ environ, err := dirlistNamespace(path, dirFile)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ if err := template.Execute(buf, environ); err != nil {
+ gemini.Failure(err)
+ }
+
+ return gemini.Success("text/gemini", buf)
+ }
+}
+
+var defaultDirListTemplate = template.Must(template.New("directory_listing").Parse(`
+# {{ .DirName }}
+{{ range .Entries }}
+=> {{ .Name }}{{ if .IsDir }}/{{ end -}}
+{{ end }}
+=> ../
+`[1:]))
+
+func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, error) {
+ entries, err := dirFile.ReadDir(0)
+ if err != nil {
+ return nil, err
+ }
+
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].Name() < entries[j].Name()
+ })
+
+ var dirname string
+ if path == "." {
+ dirname = "(root)"
+ } else {
+ dirname = path[strings.LastIndex(path, "/")+1:]
+ }
+
+ m := map[string]any{
+ "FullPath": path,
+ "DirName": dirname,
+ "Entries": entries,
+ }
+
+ return m, nil
+}
+
+func handleDir(req *gemini.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gemini.Response) {
+ path := strings.Trim(req.Path, "/")
+ if path == "" {
+ path = "."
+ }
+
+ file, err := fileSystem.Open(path)
+ if isNotFound(err) {
+ return "", nil, gemini.NotFound("Resource does not exist.")
+ }
+ if err != nil {
+ return "", nil, gemini.Failure(err)
+ }
+
+ isDir, err := fileIsDir(file)
+ if err != nil {
+ file.Close()
+ return "", nil, gemini.Failure(err)
+ }
+
+ if !isDir {
+ file.Close()
+ return "", nil, gemini.NotFound("Resource does not exist.")
+ }
+
+ if !strings.HasSuffix(req.Path, "/") {
+ file.Close()
+ url := *req.URL
+ url.Path += "/"
+ return "", nil, gemini.Redirect(url.String())
+ }
+
+ dirFile, ok := file.(fs.ReadDirFile)
+ if !ok {
+ file.Close()
+ return "", nil, gemini.NotFound("Resource does not exist.")
+ }
+
+ return path, dirFile, nil
+}