package fs import ( "bytes" "context" "io/fs" "sort" "strings" "text/template" "tildegit.org/tjp/gus" "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) gus.Handler { return func(ctx context.Context, req *gus.Request) *gus.Response { path, dirFile, resp := handleDir(req, fileSystem) if dirFile == 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 nil } } // 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) gus.Handler { return func(ctx context.Context, req *gus.Request) *gus.Response { path, dirFile, resp := handleDir(req, fileSystem) if dirFile == 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 *gus.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gus.Response) { path := strings.Trim(req.Path, "/") if path == "" { path = "." } file, err := fileSystem.Open(path) if isNotFound(err) { return "", nil, nil } 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, nil } 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, nil } return path, dirFile, nil }