From 66a1b1f39a1e1d5499b548b36d18c8daa872d7da Mon Sep 17 00:00:00 2001 From: tjpcc Date: Sat, 28 Jan 2023 14:52:35 -0700 Subject: gopher support. Some of the contrib packages were originally built gemini-specific and had to be refactored into generic core functionality and thin protocol-specific wrappers for each of gemini and gopher. --- contrib/fs/dir.go | 221 ++++++++++++++++++++++-------------------------- contrib/fs/dir_test.go | 4 +- contrib/fs/file.go | 49 ++++++----- contrib/fs/file_test.go | 2 +- contrib/fs/gemini.go | 130 ++++++++++++++++++++++++++++ contrib/fs/gopher.go | 168 ++++++++++++++++++++++++++++++++++++ 6 files changed, 427 insertions(+), 147 deletions(-) create mode 100644 contrib/fs/gemini.go create mode 100644 contrib/fs/gopher.go (limited to 'contrib/fs') diff --git a/contrib/fs/dir.go b/contrib/fs/dir.go index 4328c8f..5659804 100644 --- a/contrib/fs/dir.go +++ b/contrib/fs/dir.go @@ -2,112 +2,123 @@ package fs import ( "bytes" - "context" + "io" "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. +// ResolveDirectory opens the directory corresponding to a request path. // -// 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() +// The string is the full path to the directory. If the returned ReadDirFile +// is not nil, it will be open and must be closed by the caller. +func ResolveDirectory( + request *gus.Request, + fileSystem fs.FS, +) (string, fs.ReadDirFile, error) { + path := strings.Trim(request.Path, "/") + if path == "" { + path = "." + } - entries, err := dirFile.ReadDir(0) - if err != nil { - return gemini.Failure(err) - } + file, err := fileSystem.Open(path) + if isNotFound(err) { + return "", nil, nil + } + if err != nil { + return "", nil, 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) - } - } - } + isDir, err := fileIsDir(file) + if err != nil { + _ = file.Close() + return "", nil, err + } + + if !isDir { + _ = file.Close() + return "", nil, nil + } - return nil + dirFile, ok := file.(fs.ReadDirFile) + if !ok { + _ = file.Close() + return "", nil, nil } + + return path, dirFile, nil } -// DirectoryListing produces a gemtext listing of the contents of any requested directories. +// ResolveDirectoryDefault finds any of the provided filenames within a directory. // -// 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. +// If it does not find any of the filenames it returns "", nil, nil. +func ResolveDirectoryDefault( + fileSystem fs.FS, + dirPath string, + dir fs.ReadDirFile, + filenames []string, +) (string, fs.File, error) { + entries, err := dir.ReadDir(0) + if err != nil { + return "", nil, err + } + sort.Slice(entries, func(a, b int) bool { + return entries[a].Name() < entries[b].Name() + }) + + for _, filename := range filenames { + idx := sort.Search(len(entries), func(i int) bool { + return entries[i].Name() <= filename + }) + + if idx < len(entries) && entries[idx].Name() == filename { + path := dirPath + "/" + filename + file, err := fileSystem.Open(path) + return path, file, err + } + } + + return "", nil, nil +} + +// RenderDirectoryListing provides an io.Reader with the output of a directory listing template. // // 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 +// - .Hostname: the hostname of the server hosting the file +// - .Port: the port on which the server is listening // -// 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) - } +// Each entry in .Entries has the following fields: +// - .Name the string name of the item within the directory +// - .IsDir is a boolean +// - .Type is the FileMode bits +// - .Info is a method returning (fs.FileInfo, error) +func RenderDirectoryListing( + path string, + dir fs.ReadDirFile, + template *template.Template, + server gus.Server, +) (io.Reader, error) { + buf := &bytes.Buffer{} + + environ, err := dirlistNamespace(path, dir, server) + if err != nil { + return nil, err + } - return gemini.Success("text/gemini", buf) + if err := template.Execute(buf, environ); err != nil { + return nil, err } -} -var defaultDirListTemplate = template.Must(template.New("directory_listing").Parse(` -# {{ .DirName }} -{{ range .Entries }} -=> {{ .Name }}{{ if .IsDir }}/{{ end -}} -{{ end }} -=> ../ -`[1:])) + return buf, nil +} -func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, error) { +func dirlistNamespace(path string, dirFile fs.ReadDirFile, server gus.Server) (map[string]any, error) { entries, err := dirFile.ReadDir(0) if err != nil { return nil, err @@ -124,52 +135,20 @@ func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, erro dirname = path[strings.LastIndex(path, "/")+1:] } + hostname := "none" + port := "0" + if server != nil { + hostname = server.Hostname() + port = server.Port() + } + m := map[string]any{ "FullPath": path, "DirName": dirname, "Entries": entries, + "Hostname": hostname, + "Port": port, } 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 -} diff --git a/contrib/fs/dir_test.go b/contrib/fs/dir_test.go index c7492ff..7d824e3 100644 --- a/contrib/fs/dir_test.go +++ b/contrib/fs/dir_test.go @@ -16,7 +16,7 @@ import ( ) func TestDirectoryDefault(t *testing.T) { - handler := fs.DirectoryDefault(os.DirFS("testdata"), "index.gmi") + handler := fs.GeminiDirectoryDefault(os.DirFS("testdata"), "index.gmi") tests := []struct { url string @@ -69,7 +69,7 @@ func TestDirectoryDefault(t *testing.T) { } func TestDirectoryListing(t *testing.T) { - handler := fs.DirectoryListing(os.DirFS("testdata"), nil) + handler := fs.GeminiDirectoryListing(os.DirFS("testdata"), nil) tests := []struct { url string diff --git a/contrib/fs/file.go b/contrib/fs/file.go index 71428ed..a1293af 100644 --- a/contrib/fs/file.go +++ b/contrib/fs/file.go @@ -1,37 +1,40 @@ package fs import ( - "context" "io/fs" "mime" "strings" "tildegit.org/tjp/gus" - "tildegit.org/tjp/gus/gemini" ) -// FileHandler builds a handler function which serves up a file system. -func FileHandler(fileSystem fs.FS) gus.Handler { - return func(ctx context.Context, req *gus.Request) *gus.Response { - file, err := fileSystem.Open(strings.TrimPrefix(req.Path, "/")) - if isNotFound(err) { - return nil - } - if err != nil { - return gemini.Failure(err) - } - - isDir, err := fileIsDir(file) - if err != nil { - return gemini.Failure(err) - } - - if isDir { - return nil - } - - return gemini.Success(mediaType(req.Path), file) +// ResolveFile finds a file from a filesystem based on a request path. +// +// It only returns a non-nil file if a file is found - not a directory. +// If there is any other sort of filesystem access error, it will be +// returned. +func ResolveFile(request *gus.Request, fileSystem fs.FS) (string, fs.File, error) { + filepath := strings.TrimPrefix(request.Path, "/") + file, err := fileSystem.Open(filepath) + if isNotFound(err) { + return "", nil, nil } + if err != nil { + return "", nil, err + } + + isDir, err := fileIsDir(file) + if err != nil { + _ = file.Close() + return "", nil, err + } + + if isDir { + _ = file.Close() + return "", nil, nil + } + + return filepath, file, nil } func mediaType(filePath string) string { diff --git a/contrib/fs/file_test.go b/contrib/fs/file_test.go index 4f371c7..f97b66b 100644 --- a/contrib/fs/file_test.go +++ b/contrib/fs/file_test.go @@ -16,7 +16,7 @@ import ( ) func TestFileHandler(t *testing.T) { - handler := fs.FileHandler(os.DirFS("testdata")) + handler := fs.GeminiFileHandler(os.DirFS("testdata")) tests := []struct { url string diff --git a/contrib/fs/gemini.go b/contrib/fs/gemini.go new file mode 100644 index 0000000..b41cb75 --- /dev/null +++ b/contrib/fs/gemini.go @@ -0,0 +1,130 @@ +package fs + +import ( + "context" + "io/fs" + "strings" + "text/template" + + "tildegit.org/tjp/gus" + "tildegit.org/tjp/gus/gemini" +) + +// GeminiFileHandler builds a handler which serves up files from a file system. +// +// It only serves responses for paths which do not correspond to directories on disk. +func GeminiFileHandler(fileSystem fs.FS) gus.Handler { + return func(ctx context.Context, request *gus.Request) *gus.Response { + filepath, file, err := ResolveFile(request, fileSystem) + if err != nil { + return gemini.Failure(err) + } + + if file == nil { + return nil + } + + return gemini.Success(mediaType(filepath), file) + } +} + +// GeminiDirectoryDefault serves up default files for directory path requests. +// +// If any of the supported filenames are found, the contents of the file is returned +// as the gemini response. +// +// It returns nil for any paths which don't correspond to a 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 inot the directory's contents to function properly. +// +// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they +// don't, it will produce nil responses for any directory paths. +func GeminiDirectoryDefault(fileSystem fs.FS, filenames ...string) gus.Handler { + return func(ctx context.Context, request *gus.Request) *gus.Response { + dirpath, dir, response := handleDirGemini(request, fileSystem) + if response != nil { + return response + } + if dir == nil { + return nil + } + defer func() { _ = dir.Close() }() + + filepath, file, err := ResolveDirectoryDefault(fileSystem, dirpath, dir, filenames) + if err != nil { + return gemini.Failure(err) + } + if file == nil { + return nil + } + + return gemini.Success(mediaType(filepath), file) + } +} + +// GeminiDirectoryListing produces a 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 inot the directory's contents to function properly. +// +// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they +// don't, it will produce "51 Not Found" responses for any directory paths. +// +// The template may be nil, in which case DefaultGeminiDirectoryList is used instead. The +// template is then processed with RenderDirectoryListing. +func GeminiDirectoryListing(fileSystem fs.FS, template *template.Template) gus.Handler { + return func(ctx context.Context, request *gus.Request) *gus.Response { + dirpath, dir, response := handleDirGemini(request, fileSystem) + if response != nil { + return response + } + if dir == nil { + return nil + } + defer func() { _ = dir.Close() }() + + if template == nil { + template = DefaultGeminiDirectoryList + } + body, err := RenderDirectoryListing(dirpath, dir, template, request.Server) + if err != nil { + return gemini.Failure(err) + } + + return gemini.Success("text/gemini", body) + } +} + +// DefaultGeminiDirectoryList is a template which renders a reasonable gemtext dir list. +var DefaultGeminiDirectoryList = template.Must(template.New("gemini_dirlist").Parse(` +# {{ .DirName }} +{{ range .Entries }} +=> {{ .Name }}{{ if .IsDir }}/{{ end -}} +{{ end }} +=> ../ +`[1:])) + +func handleDirGemini(request *gus.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gus.Response) { + path, dir, err := ResolveDirectory(request, fileSystem) + if err != nil { + return "", nil, gemini.Failure(err) + } + + if dir == nil { + return "", nil, nil + } + + if !strings.HasSuffix(request.Path, "/") { + _ = dir.Close() + url := *request.URL + url.Path += "/" + return "", nil, gemini.Redirect(url.String()) + } + + return path, dir, nil +} diff --git a/contrib/fs/gopher.go b/contrib/fs/gopher.go new file mode 100644 index 0000000..7b0d8bd --- /dev/null +++ b/contrib/fs/gopher.go @@ -0,0 +1,168 @@ +package fs + +import ( + "context" + "io/fs" + "mime" + "path" + "strings" + "text/template" + + "tildegit.org/tjp/gus" + "tildegit.org/tjp/gus/gopher" +) + +// GopherFileHandler builds a handler which serves up files from a file system. +// +// It only serves responses for paths which correspond to files, not directories. +func GopherFileHandler(fileSystem fs.FS) gus.Handler { + return func(ctx context.Context, request *gus.Request) *gus.Response { + filepath, file, err := ResolveFile(request, fileSystem) + if err != nil { + return gopher.Error(err).Response() + } + + if file == nil { + return nil + } + + return gopher.File(GuessGopherItemType(filepath), file) + } +} + +// GopherDirectoryDefault serves up default files for directory path requests. +// +// If any of the supported filenames are found in the requested directory, the +// contents of that file is returned as the gopher response. +// +// It returns nil for any paths which don't correspond to a directory. +// +// It requires that files from the provided fs.FS implement fs.ReadDirFile. If +// they don't, it will produce nil responses for all directory paths. +func GopherDirectoryDefault(fileSystem fs.FS, filenames ...string) gus.Handler { + return func(ctx context.Context, request *gus.Request) *gus.Response { + dirpath, dir, err := ResolveDirectory(request, fileSystem) + if err != nil { + return gopher.Error(err).Response() + } + if dir == nil { + return nil + } + defer func() { _ = dir.Close() }() + + _, file, err := ResolveDirectoryDefault(fileSystem, dirpath, dir, filenames) + if err != nil { + return gopher.Error(err).Response() + } + if file == nil { + return nil + } + + return gopher.File(gopher.MenuType, file) + } +} + +// GopherDirectoryListing produces a listing of the contents of any requested directories. +// +// It returns nil for any paths which don't correspond to a filesystem directory. +// +// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they +// don't, it will produce nil responses for any directory paths. +// +// A template may be nil, in which case DefaultGopherDirectoryList is used instead. The +// template is then processed with RenderDirectoryListing. +func GopherDirectoryListing(fileSystem fs.FS, tpl *template.Template) gus.Handler { + return func(ctx context.Context, request *gus.Request) *gus.Response { + dirpath, dir, err := ResolveDirectory(request, fileSystem) + if err != nil { + return gopher.Error(err).Response() + } + if dir == nil { + return nil + } + defer func() { _ = dir.Close() }() + + if tpl == nil { + tpl = DefaultGopherDirectoryList + } + body, err := RenderDirectoryListing(dirpath, dir, tpl, request.Server) + if err != nil { + return gopher.Error(err).Response() + } + + return gopher.File(gopher.MenuType, body) + } +} + +// GopherTemplateFunctions is a map for templates providing useful functions for gophermaps. +// +// - GuessItemType: return a gopher item type for a file based on its path/name. +var GopherTemplateFunctions = template.FuncMap{ + "GuessItemType": func(filepath string) string { + return string([]byte{byte(GuessGopherItemType(filepath))}) + }, +} + +// DefaultGopherDirectoryList is a template which renders a directory listing as gophermap. +var DefaultGopherDirectoryList = template.Must( + template.New("gopher_dirlist").Funcs(GopherTemplateFunctions).Parse( + strings.ReplaceAll( + ` +{{ $root := .FullPath -}} +{{ if eq .FullPath "." }}{{ $root = "" }}{{ end -}} +{{ $hostname := .Hostname -}} +{{ $port := .Port -}} +i{{ .DirName }} {{ $hostname }} {{ $port }} +i {{ $hostname }} {{ $port }} +{{ range .Entries -}} +{{ if .IsDir -}} +1{{ .Name }} {{ $root }}/{{ .Name }} {{ $hostname }} {{ $port }} +{{- else -}} +{{ GuessItemType .Name }}{{ .Name }} {{ $root }}/{{ .Name }} {{ $hostname }} {{ $port }} +{{- end }} +{{ end -}} +. +`[1:], + "\n", + "\r\n", + ), + ), +) + +// GuessGopherItemType attempts to find the best gopher item type for a file based on its name. +func GuessGopherItemType(filepath string) gus.Status { + ext := path.Ext(filepath) + switch ext { + case "txt", "gmi": + return gopher.TextFileType + case "gif", "png", "jpg", "jpeg": + return gopher.ImageFileType + case "mp4", "mov": + return gopher.MovieFileType + case "mp3", "aiff", "aif", "aac", "ogg", "flac", "alac", "wma": + return gopher.SoundFileType + case "bmp": + return gopher.BitmapType + case "doc", "docx", "odt": + return gopher.DocumentType + case "html", "htm": + return gopher.HTMLType + case "rtf": + return gopher.RtfDocumentType + case "wav": + return gopher.WavSoundFileType + case "pdf": + return gopher.PdfDocumentType + case "xml": + return gopher.XmlDocumentType + case "": + return gopher.BinaryFileType + } + + mtype := mime.TypeByExtension(ext) + if strings.HasPrefix(mtype, "text/") { + return gopher.TextFileType + } + + return gopher.BinaryFileType +} -- cgit v1.2.3