summaryrefslogtreecommitdiff
path: root/contrib/fs
diff options
context:
space:
mode:
authortjpcc <tjp@ctrl-c.club>2023-01-28 14:52:35 -0700
committertjpcc <tjp@ctrl-c.club>2023-01-28 15:01:41 -0700
commit66a1b1f39a1e1d5499b548b36d18c8daa872d7da (patch)
tree96471dbd5486ede1a908790ac23e0c55b226dfad /contrib/fs
parenta27b879accb191b6a6c6e76a6251ed751967f73a (diff)
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.
Diffstat (limited to 'contrib/fs')
-rw-r--r--contrib/fs/dir.go221
-rw-r--r--contrib/fs/dir_test.go4
-rw-r--r--contrib/fs/file.go49
-rw-r--r--contrib/fs/file_test.go2
-rw-r--r--contrib/fs/gemini.go130
-rw-r--r--contrib/fs/gopher.go168
6 files changed, 427 insertions, 147 deletions
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
+}