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/cgi/cgi.go | 105 +++++++++----------- contrib/cgi/cgi_test.go | 4 +- contrib/cgi/gemini.go | 47 +++++++++ contrib/cgi/gopher.go | 45 +++++++++ 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 ++++++++++++++++++++++++++++++++ contrib/tlsauth/auth_test.go | 5 +- 11 files changed, 570 insertions(+), 210 deletions(-) create mode 100644 contrib/cgi/gemini.go create mode 100644 contrib/cgi/gopher.go create mode 100644 contrib/fs/gemini.go create mode 100644 contrib/fs/gopher.go (limited to 'contrib') diff --git a/contrib/cgi/cgi.go b/contrib/cgi/cgi.go index 71743a0..e57f2d0 100644 --- a/contrib/cgi/cgi.go +++ b/contrib/cgi/cgi.go @@ -6,7 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" - "fmt" + "io" "io/fs" "net" "os" @@ -14,52 +14,45 @@ import ( "strings" "tildegit.org/tjp/gus" - "tildegit.org/tjp/gus/gemini" ) -// CGIDirectory runs any executable files relative to a root directory on the file system. +// ResolveCGI finds a CGI program corresponding to a request path. // -// It will also find and run any executables _part way_ through the path, so for example -// a request for /foo/bar/baz can also run an executable found at /foo or /foo/bar. In -// such a case the PATH_INFO environment variable will include the remaining portion of -// the URI path. -func CGIDirectory(pathRoot, fsRoot string) gus.Handler { - fsRoot = strings.TrimRight(fsRoot, "/") - - return func(ctx context.Context, req *gus.Request) *gus.Response { - if !strings.HasPrefix(req.Path, pathRoot) { - return nil +// It returns the path to the executable file and the PATH_INFO that should be passed, +// or an error. +// +// It will find executables which are just part way through the path, so for example +// a request for /foo/bar/baz can run an executable found at /foo or /foo/bar. In such +// a case the PATH_INFO would include the remaining portion of the URI path. +func ResolveCGI(requestPath, fsRoot string) (string, string, error) { + segments := strings.Split(strings.TrimLeft(requestPath, "/"), "/") + + for i := range append(segments, "") { + filepath := strings.Join(append([]string{fsRoot}, segments[:i]...), "/") + filepath = strings.TrimRight(filepath, "/") + isDir, isExecutable, err := executableFile(filepath) + if err != nil { + return "", "", err } - path := req.Path[len(pathRoot):] - segments := strings.Split(strings.TrimLeft(path, "/"), "/") - for i := range append(segments, "") { - path := strings.Join(append([]string{fsRoot}, segments[:i]...), "/") - path = strings.TrimRight(path, "/") - isDir, isExecutable, err := executableFile(path) - if err != nil { - return gemini.Failure(err) - } - - if isExecutable { - pathInfo := "/" - if len(segments) > i+1 { - pathInfo = strings.Join(segments[i:], "/") - } - return RunCGI(ctx, req, path, pathInfo) - } - - if !isDir { - break + if isExecutable { + pathinfo := "/" + if len(segments) > i+1 { + pathinfo = strings.Join(segments[i:], "/") } + return filepath, pathinfo, nil } - return nil + if !isDir { + break + } } + + return "", "", nil } -func executableFile(path string) (bool, bool, error) { - file, err := os.Open(path) +func executableFile(filepath string) (bool, bool, error) { + file, err := os.Open(filepath) if isNotExistError(err) { return false, false, nil } @@ -98,10 +91,10 @@ func isNotExistError(err error) bool { // RunCGI runs a specific program as a CGI script. func RunCGI( ctx context.Context, - req *gus.Request, + request *gus.Request, executable string, pathInfo string, -) *gus.Response { +) (io.Reader, int, error) { pathSegments := strings.Split(executable, "/") dirPath := "." @@ -115,40 +108,34 @@ func RunCGI( infoLen -= 1 } - scriptName := req.Path[:len(req.Path)-infoLen] + scriptName := request.Path[:len(request.Path)-infoLen] scriptName = strings.TrimSuffix(scriptName, "/") cmd := exec.CommandContext(ctx, "./"+basename) - cmd.Env = prepareCGIEnv(ctx, req, scriptName, pathInfo) + cmd.Env = prepareCGIEnv(ctx, request, scriptName, pathInfo) cmd.Dir = dirPath responseBuffer := &bytes.Buffer{} cmd.Stdout = responseBuffer - if err := cmd.Run(); err != nil { + err := cmd.Run() + if err != nil { var exErr *exec.ExitError if errors.As(err, &exErr) { - errMsg := fmt.Sprintf("CGI returned exit code %d", exErr.ExitCode()) - return gemini.CGIError(errMsg) + return responseBuffer, exErr.ExitCode(), nil } - return gemini.Failure(err) - } - - response, err := gemini.ParseResponse(responseBuffer) - if err != nil { - return gemini.Failure(err) } - return response + return responseBuffer, cmd.ProcessState.ExitCode(), err } func prepareCGIEnv( ctx context.Context, - req *gus.Request, + request *gus.Request, scriptName string, pathInfo string, ) []string { var authType string - if len(req.TLSState.PeerCertificates) > 0 { + if request.TLSState != nil && len(request.TLSState.PeerCertificates) > 0 { authType = "Certificate" } environ := []string{ @@ -158,10 +145,10 @@ func prepareCGIEnv( "GATEWAY_INTERFACE=CGI/1.1", "PATH_INFO=" + pathInfo, "PATH_TRANSLATED=", - "QUERY_STRING=" + req.RawQuery, + "QUERY_STRING=" + request.RawQuery, } - host, _, _ := net.SplitHostPort(req.RemoteAddr.String()) + host, _, _ := net.SplitHostPort(request.RemoteAddr.String()) environ = append(environ, "REMOTE_ADDR="+host) environ = append( @@ -169,14 +156,14 @@ func prepareCGIEnv( "REMOTE_HOST=", "REMOTE_IDENT=", "SCRIPT_NAME="+scriptName, - "SERVER_NAME="+req.Server.Hostname(), - "SERVER_PORT="+req.Server.Port(), - "SERVER_PROTOCOL=GEMINI", + "SERVER_NAME="+request.Server.Hostname(), + "SERVER_PORT="+request.Server.Port(), + "SERVER_PROTOCOL="+request.Server.Protocol(), "SERVER_SOFTWARE=GUS", ) - if len(req.TLSState.PeerCertificates) > 0 { - cert := req.TLSState.PeerCertificates[0] + if request.TLSState != nil && len(request.TLSState.PeerCertificates) > 0 { + cert := request.TLSState.PeerCertificates[0] environ = append( environ, "TLS_CLIENT_HASH="+fingerprint(cert.Raw), diff --git a/contrib/cgi/cgi_test.go b/contrib/cgi/cgi_test.go index c265050..5c1ca33 100644 --- a/contrib/cgi/cgi_test.go +++ b/contrib/cgi/cgi_test.go @@ -21,8 +21,8 @@ func TestCGIDirectory(t *testing.T) { tlsconf, err := gemini.FileTLS("testdata/server.crt", "testdata/server.key") require.Nil(t, err) - handler := cgi.CGIDirectory("/cgi-bin", "./testdata") - server, err := gemini.NewServer(context.Background(), nil, tlsconf, "tcp", "127.0.0.1:0", handler) + handler := cgi.GeminiCGIDirectory("/cgi-bin", "./testdata") + server, err := gemini.NewServer(context.Background(), "localhost", "tcp", "127.0.0.1:0", handler, nil, tlsconf) require.Nil(t, err) go func() { assert.Nil(t, server.Serve()) }() diff --git a/contrib/cgi/gemini.go b/contrib/cgi/gemini.go new file mode 100644 index 0000000..8302e7e --- /dev/null +++ b/contrib/cgi/gemini.go @@ -0,0 +1,47 @@ +package cgi + +import ( + "context" + "fmt" + "strings" + + "tildegit.org/tjp/gus" + "tildegit.org/tjp/gus/gemini" +) + +// GeminiCGIDirectory runs any executable files relative to a root directory on the file system. +// +// It will also find and run any executables _part way_ through the path, so for example +// a request for /foo/bar/baz can also run an executable found at /foo or /foo/bar. In +// such a case the PATH_INFO environment variable will include the remaining portion of +// the URI path. +func GeminiCGIDirectory(pathRoot, fsRoot string) gus.Handler { + fsRoot = strings.TrimRight(fsRoot, "/") + return func(ctx context.Context, request *gus.Request) *gus.Response { + if !strings.HasPrefix(request.Path, pathRoot) { + return nil + } + + filepath, pathinfo, err := ResolveCGI(request.Path[len(pathRoot):], fsRoot) + if err != nil { + return gemini.Failure(err) + } + if filepath == "" { + return nil + } + + stdout, exitCode, err := RunCGI(ctx, request, filepath, pathinfo) + if err != nil { + return gemini.Failure(err) + } + if exitCode != 0 { + return gemini.CGIError(fmt.Sprintf("CGI process exited with status %d", exitCode)) + } + + response, err := gemini.ParseResponse(stdout) + if err != nil { + return gemini.Failure(err) + } + return response + } +} diff --git a/contrib/cgi/gopher.go b/contrib/cgi/gopher.go new file mode 100644 index 0000000..29bfdba --- /dev/null +++ b/contrib/cgi/gopher.go @@ -0,0 +1,45 @@ +package cgi + +import ( + "context" + "fmt" + "strings" + + "tildegit.org/tjp/gus" + "tildegit.org/tjp/gus/gopher" +) + +// GopherCGIDirectory runs any executable files relative to a root directory on the file system. +// +// It will also find and run any executables part way through the path, so for example +// a request for /foo/bar/baz can also run an executable found at /foo or /foo/bar. In +// such a case the PATH_INFO environment variable will include the remaining portion of +// the URI path. +func GopherCGIDirectory(pathRoot, fsRoot string) gus.Handler { + fsRoot = strings.TrimRight(fsRoot, "/") + return func(ctx context.Context, request *gus.Request) *gus.Response { + if !strings.HasPrefix(request.Path, pathRoot) { + return nil + } + + filepath, pathinfo, err := ResolveCGI(request.Path[len(pathRoot):], fsRoot) + if err != nil { + return gopher.Error(err).Response() + } + if filepath == "" { + return nil + } + + stdout, exitCode, err := RunCGI(ctx, request, filepath, pathinfo) + if err != nil { + return gopher.Error(err).Response() + } + if exitCode != 0 { + return gopher.Error( + fmt.Errorf("CGI process exited with status %d", exitCode), + ).Response() + } + + return gopher.File(0, stdout) + } +} 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 +} diff --git a/contrib/tlsauth/auth_test.go b/contrib/tlsauth/auth_test.go index fc39359..30b63f5 100644 --- a/contrib/tlsauth/auth_test.go +++ b/contrib/tlsauth/auth_test.go @@ -143,11 +143,12 @@ func setup( server, err := gemini.NewServer( context.Background(), - nil, - serverTLS, + "localhost", "tcp", "127.0.0.1:0", handler, + nil, + serverTLS, ) require.Nil(t, err) -- cgit v1.2.3