diff options
Diffstat (limited to 'contrib/fs/dir.go')
-rw-r--r-- | contrib/fs/dir.go | 174 |
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 +} |