summaryrefslogtreecommitdiff
path: root/contrib
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
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')
-rw-r--r--contrib/cgi/cgi.go105
-rw-r--r--contrib/cgi/cgi_test.go4
-rw-r--r--contrib/cgi/gemini.go47
-rw-r--r--contrib/cgi/gopher.go45
-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
-rw-r--r--contrib/tlsauth/auth_test.go5
11 files changed, 570 insertions, 210 deletions
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)