diff options
author | tjpcc <tjp@ctrl-c.club> | 2023-01-09 16:40:24 -0700 |
---|---|---|
committer | tjpcc <tjp@ctrl-c.club> | 2023-01-09 16:40:24 -0700 |
commit | ff05d62013906f3086b452bfeda3e0d5b9b7a541 (patch) | |
tree | 3be29de0b1bc7c273041c6d89b71ca447c940556 /contrib/fs |
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')
-rw-r--r-- | contrib/fs/dir.go | 174 | ||||
-rw-r--r-- | contrib/fs/file.go | 55 | ||||
-rw-r--r-- | contrib/fs/stat.go | 28 |
3 files changed, 257 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 +} diff --git a/contrib/fs/file.go b/contrib/fs/file.go new file mode 100644 index 0000000..cdcd1a9 --- /dev/null +++ b/contrib/fs/file.go @@ -0,0 +1,55 @@ +package fs + +import ( + "context" + "io/fs" + "mime" + "strings" + + "tildegit.org/tjp/gus/gemini" +) + +// FileHandler builds a handler function which serves up a file system. +func FileHandler(fileSystem fs.FS) gemini.Handler { + return func(ctx context.Context, req *gemini.Request) *gemini.Response { + file, err := fileSystem.Open(strings.TrimPrefix(req.Path, "/")) + if isNotFound(err) { + return gemini.NotFound("Resource does not exist.") + } + if err != nil { + return gemini.Failure(err) + } + + isDir, err := fileIsDir(file) + if err != nil { + return gemini.Failure(err) + } + + if isDir { + return gemini.NotFound("Resource does not exist.") + } + + return gemini.Success(mediaType(req.Path), file) + } +} + +func mediaType(filePath string) string { + if strings.HasSuffix(filePath, ".gmi") { + // This may not be present in the listings searched by mime.TypeByExtension, + // so provide a dedicated fast path for it here. + return "text/gemini" + } + + slashIdx := strings.LastIndex(filePath, "/") + dotIdx := strings.LastIndex(filePath[slashIdx+1:], ".") + if dotIdx == -1 { + return "application/octet-stream" + } + ext := filePath[slashIdx+dotIdx:] + + mtype := mime.TypeByExtension(ext) + if mtype == "" { + return "application/octet-stream" + } + return mtype +} diff --git a/contrib/fs/stat.go b/contrib/fs/stat.go new file mode 100644 index 0000000..4dd65d8 --- /dev/null +++ b/contrib/fs/stat.go @@ -0,0 +1,28 @@ +package fs + +import ( + "errors" + "io/fs" +) + +func isNotFound(err error) bool { + if err == nil { + return false + } + + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + e := pathErr.Err + return errors.Is(e, fs.ErrInvalid) || errors.Is(e, fs.ErrNotExist) + } + + return false +} + +func fileIsDir(file fs.File) (bool, error) { + info, err := file.Stat() + if err != nil { + return false, err + } + return info.IsDir(), nil +} |