From 66a1b1f39a1e1d5499b548b36d18c8daa872d7da Mon Sep 17 00:00:00 2001 From: tjpcc Date: Sat, 28 Jan 2023 14:52:35 -0700 Subject: [PATCH] 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 | 103 ++++++------- contrib/cgi/cgi_test.go | 4 +- contrib/cgi/gemini.go | 47 ++++++ contrib/cgi/gopher.go | 45 ++++++ contrib/fs/dir.go | 227 +++++++++++++---------------- 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 +- examples/cgi/main.go | 4 +- examples/cowsay/main.go | 2 +- examples/fileserver/main.go | 8 +- examples/gopher_fileserver/main.go | 33 +++++ examples/inspectls/main.go | 2 +- gemini/response.go | 33 ++--- gemini/roundtrip_test.go | 4 +- gemini/serve.go | 158 +++++--------------- gopher/client.go | 55 +++++++ gopher/gophermap/parse.go | 61 ++++++++ gopher/gophermap/parse_test.go | 96 ++++++++++++ gopher/request.go | 72 +++++++++ gopher/request_test.go | 43 ++++++ gopher/response.go | 162 ++++++++++++++++++++ gopher/serve.go | 72 +++++++++ internal/server.go | 126 ++++++++++++++++ logging/middleware.go | 4 +- response.go | 7 + server.go | 6 + 30 files changed, 1367 insertions(+), 365 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 create mode 100644 examples/gopher_fileserver/main.go create mode 100644 gopher/client.go create mode 100644 gopher/gophermap/parse.go create mode 100644 gopher/gophermap/parse_test.go create mode 100644 gopher/request.go create mode 100644 gopher/request_test.go create mode 100644 gopher/response.go create mode 100644 gopher/serve.go create mode 100644 internal/server.go 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, "/") +// 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, "/"), "/") - return func(ctx context.Context, req *gus.Request) *gus.Response { - if !strings.HasPrefix(req.Path, pathRoot) { - return nil + 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() - - entries, err := dirFile.ReadDir(0) - if err != nil { - return gemini.Failure(err) - } - - for _, fileName := range fileNames { - for _, entry := range entries { - if entry.Name() == fileName { - file, err := fileSystem.Open(path + "/" + fileName) - if err != nil { - return gemini.Failure(err) - } - - return gemini.Success(mediaType(fileName), file) - } - } - } - - return nil +// 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 = "." } + + file, err := fileSystem.Open(path) + 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 + } + + 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() +// 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{} - if template == nil { - template = defaultDirListTemplate - } - - buf := &bytes.Buffer{} - - environ, err := dirlistNamespace(path, dirFile) - if err != nil { - return gemini.Failure(err) - } - - if err := template.Execute(buf, environ); err != nil { - gemini.Failure(err) - } - - return gemini.Success("text/gemini", buf) + environ, err := dirlistNamespace(path, dir, server) + if err != nil { + return nil, err } + + if err := template.Execute(buf, environ); err != nil { + return nil, err + } + + return buf, nil } -var defaultDirListTemplate = template.Must(template.New("directory_listing").Parse(` -# {{ .DirName }} -{{ range .Entries }} -=> {{ .Name }}{{ if .IsDir }}/{{ end -}} -{{ end }} -=> ../ -`[1:])) - -func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, error) { +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) diff --git a/examples/cgi/main.go b/examples/cgi/main.go index 6036454..5c1b9a2 100644 --- a/examples/cgi/main.go +++ b/examples/cgi/main.go @@ -23,7 +23,7 @@ func main() { } // make use of a CGI request handler - cgiHandler := cgi.CGIDirectory("/cgi-bin", "./cgi-bin") + cgiHandler := cgi.GeminiCGIDirectory("/cgi-bin", "./cgi-bin") _, infoLog, _, errLog := logging.DefaultLoggers() @@ -35,7 +35,7 @@ func main() { defer stop() // run the server - server, err := gemini.NewServer(ctx, errLog, tlsconf, "tcp4", ":1965", handler) + server, err := gemini.NewServer(ctx, "localhost", "tcp4", ":1965", handler, errLog, tlsconf) if err != nil { log.Fatal(err) } diff --git a/examples/cowsay/main.go b/examples/cowsay/main.go index b239019..4a3f980 100644 --- a/examples/cowsay/main.go +++ b/examples/cowsay/main.go @@ -29,7 +29,7 @@ func main() { handler := logging.LogRequests(infoLog)(cowsayHandler) // run the server - server, err := gemini.NewServer(context.Background(), errLog, tlsconf, "tcp4", ":1965", handler) + server, err := gemini.NewServer(context.Background(), "localhost", "tcp4", ":1965", handler, errLog, tlsconf) if err != nil { log.Fatal(err) } diff --git a/examples/fileserver/main.go b/examples/fileserver/main.go index e70974f..be427a1 100644 --- a/examples/fileserver/main.go +++ b/examples/fileserver/main.go @@ -26,11 +26,11 @@ func main() { // Fallthrough tries each handler in succession until it gets something other than "51 Not Found" handler := gus.FallthroughHandler( // first see if they're fetching a directory and we have /index.gmi - fs.DirectoryDefault(fileSystem, "index.gmi"), + fs.GeminiDirectoryDefault(fileSystem, "index.gmi"), // next (still if they requested a directory) build a directory listing response - fs.DirectoryListing(fileSystem, nil), + fs.GeminiDirectoryListing(fileSystem, nil), // finally, try to find a file at the request path and respond with that - fs.FileHandler(fileSystem), + fs.GeminiFileHandler(fileSystem), ) _, infoLog, _, errLog := logging.DefaultLoggers() @@ -39,7 +39,7 @@ func main() { handler = logging.LogRequests(infoLog)(handler) // run the server - server, err := gemini.NewServer(context.Background(), errLog, tlsconf, "tcp4", ":1965", handler) + server, err := gemini.NewServer(context.Background(), "localhost", "tcp4", ":1965", handler, errLog, tlsconf) if err != nil { log.Fatal(err) } diff --git a/examples/gopher_fileserver/main.go b/examples/gopher_fileserver/main.go new file mode 100644 index 0000000..172ca87 --- /dev/null +++ b/examples/gopher_fileserver/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "log" + "os" + + "tildegit.org/tjp/gus" + "tildegit.org/tjp/gus/contrib/cgi" + "tildegit.org/tjp/gus/contrib/fs" + "tildegit.org/tjp/gus/gopher" + "tildegit.org/tjp/gus/logging" +) + +func main() { + fileSystem := os.DirFS(".") + + handler := gus.FallthroughHandler( + fs.GopherDirectoryDefault(fileSystem, "index.gophermap"), + fs.GopherDirectoryListing(fileSystem, nil), + cgi.GopherCGIDirectory("/cgi-bin", "./cgi-bin"), + fs.GopherFileHandler(fileSystem), + ) + + _, infoLog, _, errLog := logging.DefaultLoggers() + handler = logging.LogRequests(infoLog)(handler) + + server, err := gopher.NewServer(context.Background(), "localhost", "tcp4", ":70", handler, errLog) + if err != nil { + log.Fatal(err) + } + server.Serve() +} diff --git a/examples/inspectls/main.go b/examples/inspectls/main.go index 5022888..ce82f43 100644 --- a/examples/inspectls/main.go +++ b/examples/inspectls/main.go @@ -33,7 +33,7 @@ func main() { handler := logging.LogRequests(infoLog)(inspectHandler) // run the server - server, err := gemini.NewServer(context.Background(), errLog, tlsconf, "tcp4", ":1965", handler) + server, err := gemini.NewServer(context.Background(), "localhost", "tcp4", ":1965", handler, errLog, tlsconf) if err != nil { log.Fatal(err) } diff --git a/gemini/response.go b/gemini/response.go index 0452462..b8797da 100644 --- a/gemini/response.go +++ b/gemini/response.go @@ -6,6 +6,7 @@ import ( "errors" "io" "strconv" + "sync" "tildegit.org/tjp/gus" ) @@ -284,19 +285,17 @@ func ParseResponse(rdr io.Reader) (*gus.Response, error) { }, nil } -type ResponseReader interface { - io.Reader - io.WriterTo - io.Closer -} - -func NewResponseReader(response *gus.Response) ResponseReader { - return &responseReader{ Response: response } +func NewResponseReader(response *gus.Response) gus.ResponseReader { + return &responseReader{ + Response: response, + once: &sync.Once{}, + } } type responseReader struct { *gus.Response reader io.Reader + once *sync.Once } func (rdr *responseReader) Read(b []byte) (int, error) { @@ -310,16 +309,14 @@ func (rdr *responseReader) WriteTo(dst io.Writer) (int64, error) { } func (rdr *responseReader) ensureReader() { - if rdr.reader != nil { - return - } - - hdr := bytes.NewBuffer(rdr.headerLine()) - if rdr.Body != nil { - rdr.reader = io.MultiReader(hdr, rdr.Body) - } else { - rdr.reader = hdr - } + rdr.once.Do(func() { + hdr := bytes.NewBuffer(rdr.headerLine()) + if rdr.Body != nil { + rdr.reader = io.MultiReader(hdr, rdr.Body) + } else { + rdr.reader = hdr + } + }) } func (rdr responseReader) headerLine() []byte { diff --git a/gemini/roundtrip_test.go b/gemini/roundtrip_test.go index ab7baa4..a9d9b59 100644 --- a/gemini/roundtrip_test.go +++ b/gemini/roundtrip_test.go @@ -24,7 +24,7 @@ func TestRoundTrip(t *testing.T) { return gemini.Success("text/gemini", bytes.NewBufferString("you've found my page")) } - server, err := gemini.NewServer(context.Background(), nil, tlsConf, "tcp", "127.0.0.1:0", handler) + server, err := gemini.NewServer(context.Background(), "localhost", "tcp", "127.0.0.1:0", handler, nil, tlsConf) require.Nil(t, err) go func() { @@ -69,7 +69,7 @@ func TestTitanRequest(t *testing.T) { return gemini.Success("", nil) } - server, err := gemini.NewServer(context.Background(), nil, tlsConf, "tcp", "127.0.0.1:0", handler) + server, err := gemini.NewServer(context.Background(), "localhost", "tcp", "127.0.0.1:0", handler, nil, tlsConf) require.Nil(t, err) go func() { diff --git a/gemini/serve.go b/gemini/serve.go index abed257..55998d6 100644 --- a/gemini/serve.go +++ b/gemini/serve.go @@ -5,14 +5,13 @@ import ( "context" "crypto/tls" "errors" - "fmt" "io" "net" "strconv" "strings" - "sync" "tildegit.org/tjp/gus" + "tildegit.org/tjp/gus/internal" "tildegit.org/tjp/gus/logging" ) @@ -25,127 +24,59 @@ type titanRequestBodyKey struct{} var TitanRequestBody = titanRequestBodyKey{} type server struct { - ctx context.Context - errorLog logging.Logger - network string - address string - cancel context.CancelFunc - wg *sync.WaitGroup - listener net.Listener - handler gus.Handler + internal.Server + + handler gus.Handler } +func (s server) Protocol() string { return "GEMINI" } + // NewServer builds a gemini server. func NewServer( ctx context.Context, - errorLog logging.Logger, - tlsConfig *tls.Config, + hostname string, network string, address string, handler gus.Handler, + errorLog logging.Logger, + tlsConfig *tls.Config, ) (gus.Server, error) { - listener, err := net.Listen(network, address) + s := &server{handler: handler} + + if strings.IndexByte(hostname, ':') < 0 { + hostname = net.JoinHostPort(hostname, "1965") + } + + internalServer, err := internal.NewServer(ctx, hostname, network, address, errorLog, s.handleConn) if err != nil { return nil, err } + s.Server = internalServer - addr := listener.Addr() - - s := &server{ - ctx: ctx, - errorLog: errorLog, - network: addr.Network(), - address: addr.String(), - wg: &sync.WaitGroup{}, - listener: tls.NewListener(listener, tlsConfig), - handler: handler, - } + s.Listener = tls.NewListener(s.Listener, tlsConfig) return s, nil } -// Serve starts the server and blocks until it is closed. -// -// This function will allocate resources which are not cleaned up until -// Close() is called. -// -// It will respect cancellation of the context the server was created with, -// but be aware that Close() must still be called in that case to avoid -// dangling goroutines. -// -// On titan protocol requests it sets a key/value pair in the context. The -// key is TitanRequestBody, and the value is a *bufio.Reader from which the -// request body can be read. -func (s *server) Serve() error { - s.wg.Add(1) - defer s.wg.Done() - - s.ctx, s.cancel = context.WithCancel(s.ctx) - - s.wg.Add(1) - s.propagateCancel() - - for { - conn, err := s.listener.Accept() - if err != nil { - if s.Closed() { - err = nil - } else { - _ = s.errorLog.Log("msg", "accept error", "error", err) - } - - return err - } - - s.wg.Add(1) - go s.handleConn(conn) - } -} - -func (s *server) Close() { - s.cancel() - s.wg.Wait() -} - -func (s *server) Network() string { - return s.network -} - -func (s *server) Address() string { - return s.address -} - -func (s *server) Hostname() string { - host, _, _ := net.SplitHostPort(s.address) - return host -} - -func (s *server) Port() string { - _, portStr, _ := net.SplitHostPort(s.address) - return portStr -} - func (s *server) handleConn(conn net.Conn) { - defer s.wg.Done() - defer conn.Close() - buf := bufio.NewReader(conn) var response *gus.Response - req, err := ParseRequest(buf) + request, err := ParseRequest(buf) if err != nil { response = BadRequest(err.Error()) } else { - req.Server = s - req.RemoteAddr = conn.RemoteAddr() + request.Server = s + request.RemoteAddr = conn.RemoteAddr() + if tlsconn, ok := conn.(*tls.Conn); ok { state := tlsconn.ConnectionState() - req.TLSState = &state + request.TLSState = &state } - ctx := s.ctx - if req.Scheme == "titan" { - len, err := sizeParam(req.Path) + ctx := s.Ctx + if request.Scheme == "titan" { + len, err := sizeParam(request.Path) if err == nil { ctx = context.WithValue( ctx, @@ -155,15 +86,16 @@ func (s *server) handleConn(conn net.Conn) { } } - defer func() { - if r := recover(); r != nil { - err := fmt.Errorf("%s", r) - _ = s.errorLog.Log("msg", "panic in handler", "err", err) - _, _ = io.Copy(conn, NewResponseReader(Failure(err))) - } - }() - - response = s.handler(ctx, req) + /* + defer func() { + if r := recover(); r != nil { + err := fmt.Errorf("%s", r) + _ = s.LogError("msg", "panic in handler", "err", err) + _, _ = io.Copy(conn, NewResponseReader(Failure(err))) + } + }() + */ + response = s.handler(ctx, request) if response == nil { response = NotFound("Resource does not exist.") } @@ -173,24 +105,6 @@ func (s *server) handleConn(conn net.Conn) { _, _ = io.Copy(conn, NewResponseReader(response)) } -func (s *server) propagateCancel() { - go func() { - defer s.wg.Done() - - <-s.ctx.Done() - _ = s.listener.Close() - }() -} - -func (s *server) Closed() bool { - select { - case <-s.ctx.Done(): - return true - default: - return false - } -} - func sizeParam(path string) (int, error) { _, rest, found := strings.Cut(path, ";") if !found { diff --git a/gopher/client.go b/gopher/client.go new file mode 100644 index 0000000..8f5ca81 --- /dev/null +++ b/gopher/client.go @@ -0,0 +1,55 @@ +package gopher + +import ( + "bytes" + "errors" + "io" + "net" + + "tildegit.org/tjp/gus" +) + +// Client is used for sending gopher requests and producing the responses. +// +// It carries no state and is reusable simultaneously by multiple goroutines. +// +// The zero value is immediately usable. +type Client struct{} + +// RoundTrip sends a single gopher request and returns its response. +func (c Client) RoundTrip(request *gus.Request) (*gus.Response, error) { + if request.Scheme != "gopher" && request.Scheme != "" { + return nil, errors.New("non-gopher protocols not supported") + } + + host := request.Host + if _, port, _ := net.SplitHostPort(host); port == "" { + host = net.JoinHostPort(host, "70") + } + + conn, err := net.Dial("tcp", host) + if err != nil { + return nil, err + } + defer conn.Close() + + request.RemoteAddr = conn.RemoteAddr() + request.TLSState = nil + + requestBody := request.Path + if request.RawQuery != "" { + requestBody += "\t" + request.UnescapedQuery() + } + requestBody += "\r\n" + + if _, err := conn.Write([]byte(requestBody)); err != nil { + return nil, err + } + + response, err := io.ReadAll(conn) + if err != nil { + return nil, err + } + + return &gus.Response{Body: bytes.NewBuffer(response)}, nil +} diff --git a/gopher/gophermap/parse.go b/gopher/gophermap/parse.go new file mode 100644 index 0000000..302aef0 --- /dev/null +++ b/gopher/gophermap/parse.go @@ -0,0 +1,61 @@ +package gophermap + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + + "tildegit.org/tjp/gus" + "tildegit.org/tjp/gus/gopher" +) + +// Parse reads a gophermap document from a reader. +func Parse(input io.Reader) (gopher.MapDocument, error) { + rdr := bufio.NewReader(input) + doc := gopher.MapDocument{} + + num := 0 + for { + num += 1 + line, err := rdr.ReadBytes('\n') + isEOF := errors.Is(err, io.EOF) + if err != nil && !isEOF { + return nil, err + } + + if len(line) > 2 && !bytes.Equal(line, []byte(".\r\n")) { + if line[len(line)-2] != '\r' || line[len(line)-1] != '\n' { + return nil, InvalidLine(num) + } + + item := gopher.MapItem{Type: gus.Status(line[0])} + + spl := bytes.Split(line[1:len(line)-2], []byte{'\t'}) + if len(spl) != 4 { + return nil, InvalidLine(num) + } + item.Display = string(spl[0]) + item.Selector = string(spl[1]) + item.Hostname = string(spl[2]) + item.Port = string(spl[3]) + + doc = append(doc, item) + } + + if isEOF { + break + } + } + + return doc, nil +} + +// InvalidLine is returned from Parse when the reader contains a line which is invalid gophermap. +type InvalidLine int + +// Error implements the error interface. +func (il InvalidLine) Error() string { + return fmt.Sprintf("Invalid gophermap on line %d.", il) +} diff --git a/gopher/gophermap/parse_test.go b/gopher/gophermap/parse_test.go new file mode 100644 index 0000000..0e5c09e --- /dev/null +++ b/gopher/gophermap/parse_test.go @@ -0,0 +1,96 @@ +package gophermap_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tildegit.org/tjp/gus/gopher" + "tildegit.org/tjp/gus/gopher/gophermap" +) + +func TestParse(t *testing.T) { + tests := []struct { + doc string + lines gopher.MapDocument + }{ + { + doc: ` +iI am informational text localhost 70 +icontinued on this line localhost 70 +i localhost 70 +0this is my text file /file.txt localhost 70 +i localhost 70 +1here's a sub-menu /sub/ localhost 70 +. +`[1:], + lines: gopher.MapDocument{ + gopher.MapItem{ + Type: gopher.InfoMessageType, + Display: "I am informational text", + Selector: "", + Hostname: "localhost", + Port: "70", + }, + gopher.MapItem{ + Type: gopher.InfoMessageType, + Display: "continued on this line", + Selector: "", + Hostname: "localhost", + Port: "70", + }, + gopher.MapItem{ + Type: gopher.InfoMessageType, + Display: "", + Selector: "", + Hostname: "localhost", + Port: "70", + }, + gopher.MapItem{ + Type: gopher.TextFileType, + Display: "this is my text file", + Selector: "/file.txt", + Hostname: "localhost", + Port: "70", + }, + gopher.MapItem{ + Type: gopher.InfoMessageType, + Display: "", + Selector: "", + Hostname: "localhost", + Port: "70", + }, + gopher.MapItem{ + Type: gopher.MenuType, + Display: "here's a sub-menu", + Selector: "/sub/", + Hostname: "localhost", + Port: "70", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.lines[0].Display, func(t *testing.T) { + text := strings.ReplaceAll(test.doc, "\n", "\r\n") + doc, err := gophermap.Parse(bytes.NewBufferString(text)) + require.Nil(t, err) + + if assert.Equal(t, len(test.lines), len(doc)) { + for i, line := range doc { + expect := test.lines[i] + + assert.Equal(t, expect.Type, line.Type) + assert.Equal(t, expect.Display, line.Display) + assert.Equal(t, expect.Selector, line.Selector) + assert.Equal(t, expect.Hostname, line.Hostname) + assert.Equal(t, expect.Port, line.Port) + } + } + }) + } +} diff --git a/gopher/request.go b/gopher/request.go new file mode 100644 index 0000000..6c708c0 --- /dev/null +++ b/gopher/request.go @@ -0,0 +1,72 @@ +package gopher + +import ( + "bytes" + "errors" + "io" + "net/url" + "path" + "strings" + + "tildegit.org/tjp/gus" +) + +// ParseRequest parses a gopher protocol request into a gus.Request object. +func ParseRequest(rdr io.Reader) (*gus.Request, error) { + selector, search, err := readFullRequest(rdr) + if err != nil { + return nil, err + } + + if !strings.HasPrefix(selector, "/") { + selector = "/" + selector + } + + return &gus.Request{ + URL: &url.URL{ + Scheme: "gopher", + Path: path.Clean(strings.TrimRight(selector, "\r\n")), + OmitHost: true, //nolint:typecheck + // (for some reason typecheck on drone-ci doesn't realize OmitHost is a field in url.URL) + RawQuery: url.QueryEscape(strings.TrimRight(search, "\r\n")), + }, + }, nil +} + +func readFullRequest(rdr io.Reader) (string, string, error) { + // The vast majority of requests will fit in this size: + // the specified 255 byte max for selector, then CRLF. + buf := make([]byte, 257) + + n, err := rdr.Read(buf) + if err != nil && !errors.Is(err, io.EOF) { + return "", "", err + } + buf = buf[:n] + + // Full-text search transactions are the exception, they + // may be longer because there is an additional search string + if n == 257 && buf[256] != '\n' { + intake := buf[n:cap(buf)] + total := n + for { + intake = append(intake, 0) + intake = intake[:cap(intake)] + + n, err = rdr.Read(intake) + if err != nil && err != io.EOF { + return "", "", err + } + total += n + + if n < cap(intake) || intake[cap(intake)-1] == '\n' { + break + } + intake = intake[n:] + } + buf = buf[:total] + } + + selector, search, _ := bytes.Cut(buf, []byte{'\t'}) + return string(selector), string(search), nil +} diff --git a/gopher/request_test.go b/gopher/request_test.go new file mode 100644 index 0000000..1ab7801 --- /dev/null +++ b/gopher/request_test.go @@ -0,0 +1,43 @@ +package gopher_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tildegit.org/tjp/gus/gopher" +) + +func TestParseRequest(t *testing.T) { + tests := []struct { + requestLine string + path string + query string + }{ + { + requestLine: "\r\n", + path: "/", + }, + { + requestLine: "foo/bar\r\n", + path: "/foo/bar", + }, + { + requestLine: "search\tthis AND that\r\n", + path: "/search", + query: "this+AND+that", + }, + } + + for _, test := range tests { + t.Run(test.requestLine, func(t *testing.T) { + request, err := gopher.ParseRequest(bytes.NewBufferString(test.requestLine)) + require.Nil(t, err) + + assert.Equal(t, test.path, request.Path) + assert.Equal(t, test.query, request.RawQuery) + }) + } +} diff --git a/gopher/response.go b/gopher/response.go new file mode 100644 index 0000000..c600b10 --- /dev/null +++ b/gopher/response.go @@ -0,0 +1,162 @@ +package gopher + +import ( + "bytes" + "fmt" + "io" + "sync" + + "tildegit.org/tjp/gus" +) + +// The Canonical gopher item types. +const ( + TextFileType gus.Status = '0' + MenuType gus.Status = '1' + CSOPhoneBookType gus.Status = '2' + ErrorType gus.Status = '3' + MacBinHexType gus.Status = '4' + DosBinType gus.Status = '5' + UuencodedType gus.Status = '6' + SearchType gus.Status = '7' + TelnetSessionType gus.Status = '8' + BinaryFileType gus.Status = '9' + MirrorServerType gus.Status = '+' + GifFileType gus.Status = 'g' + ImageFileType gus.Status = 'I' + Telnet3270Type gus.Status = 'T' +) + +// The gopher+ types. +const ( + BitmapType gus.Status = ':' + MovieFileType gus.Status = ';' + SoundFileType gus.Status = '<' +) + +// The various non-canonical gopher types. +const ( + DocumentType gus.Status = 'd' + HTMLType gus.Status = 'h' + InfoMessageType gus.Status = 'i' + PngImageFileType gus.Status = 'p' + RtfDocumentType gus.Status = 'r' + WavSoundFileType gus.Status = 's' + PdfDocumentType gus.Status = 'P' + XmlDocumentType gus.Status = 'X' +) + +// MapItem is a single item in a gophermap. +type MapItem struct { + Type gus.Status + Display string + Selector string + Hostname string + Port string +} + +// String serializes the item into a gophermap CRLF-terminated text line. +func (mi MapItem) String() string { + return fmt.Sprintf( + "%s%s\t%s\t%s\t%s\r\n", + []byte{byte(mi.Type)}, + mi.Display, + mi.Selector, + mi.Hostname, + mi.Port, + ) +} + +// Response builds a response which contains just this single MapItem. +// +// Meta in the response will be a pointer to the MapItem. +func (mi *MapItem) Response() *gus.Response { + return &gus.Response{ + Status: mi.Type, + Meta: &mi, + Body: bytes.NewBufferString(mi.String() + ".\r\n"), + } +} + +// MapDocument is a list of map items which can print out a full gophermap document. +type MapDocument []MapItem + +// String serializes the document into gophermap format. +func (md MapDocument) String() string { + return md.serialize().String() +} + +// Response builds a gopher response containing the gophermap. +// +// Meta will be the MapDocument itself. +func (md MapDocument) Response() *gus.Response { + return &gus.Response{ + Status: DocumentType, + Meta: md, + Body: md.serialize(), + } +} + +func (md MapDocument) serialize() *bytes.Buffer { + buf := &bytes.Buffer{} + for _, mi := range md { + _, _ = buf.WriteString(mi.String()) + } + _, _ = buf.WriteString(".\r\n") + return buf +} + +// Error builds an error message MapItem. +func Error(err error) *MapItem { + return &MapItem{ + Type: ErrorType, + Display: err.Error(), + Hostname: "none", + Port: "0", + } +} + +// File builds a minimal response delivering a file's contents. +// +// Meta is nil and Status is 0 in this response. +func File(status gus.Status, contents io.Reader) *gus.Response { + return &gus.Response{Status: status, Body: contents} +} + +// NewResponseReader produces a reader which supports reading gopher protocol responses. +func NewResponseReader(response *gus.Response) gus.ResponseReader { + return &responseReader{ + Response: response, + once: &sync.Once{}, + } +} + +type responseReader struct { + *gus.Response + reader io.Reader + once *sync.Once +} + +func (rdr *responseReader) Read(b []byte) (int, error) { + rdr.ensureReader() + return rdr.reader.Read(b) +} + +func (rdr *responseReader) WriteTo(dst io.Writer) (int64, error) { + rdr.ensureReader() + return rdr.reader.(io.WriterTo).WriteTo(dst) +} + +func (rdr *responseReader) ensureReader() { + rdr.once.Do(func() { + if _, ok := rdr.Body.(io.WriterTo); ok { + rdr.reader = rdr.Body + return + } + + // rdr.reader needs to implement WriterTo, so in this case + // we borrow an implementation in terms of io.Reader from + // io.MultiReader. + rdr.reader = io.MultiReader(rdr.Body) + }) +} diff --git a/gopher/serve.go b/gopher/serve.go new file mode 100644 index 0000000..84745d7 --- /dev/null +++ b/gopher/serve.go @@ -0,0 +1,72 @@ +package gopher + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strings" + + "tildegit.org/tjp/gus" + "tildegit.org/tjp/gus/internal" + "tildegit.org/tjp/gus/logging" +) + +type gopherServer struct { + internal.Server + handler gus.Handler +} + +func (gs gopherServer) Protocol() string { return "GOPHER" } + +// NewServer builds a gopher server. +func NewServer( + ctx context.Context, + hostname string, + network string, + address string, + handler gus.Handler, + errLog logging.Logger, +) (gus.Server, error) { + gs := &gopherServer{handler: handler} + + if strings.IndexByte(hostname, ':') < 0 { + hostname = net.JoinHostPort(hostname, "70") + } + + var err error + gs.Server, err = internal.NewServer(ctx, hostname, network, address, errLog, gs.handleConn) + if err != nil { + return nil, err + } + + return gs, nil +} + +func (gs *gopherServer) handleConn(conn net.Conn) { + var response *gus.Response + request, err := ParseRequest(conn) + if err != nil { + response = Error(errors.New("Malformed request.")).Response() + } else { + request.Server = gs + request.RemoteAddr = conn.RemoteAddr() + + defer func() { + if r := recover(); r != nil { + err := fmt.Errorf("%s", r) + _ = gs.LogError("msg", "panic in handler", "err", err) + rdr := NewResponseReader(Error(errors.New("Server error.")).Response()) + _, _ = io.Copy(conn, rdr) + } + }() + response = gs.handler(gs.Ctx, request) + if response == nil { + response = Error(errors.New("Resource does not exist.")).Response() + } + } + + defer response.Close() + _, _ = io.Copy(conn, NewResponseReader(response)) +} diff --git a/internal/server.go b/internal/server.go new file mode 100644 index 0000000..38e478c --- /dev/null +++ b/internal/server.go @@ -0,0 +1,126 @@ +package internal + +import ( + "context" + "net" + "sync" + + "tildegit.org/tjp/gus/logging" +) + +type Server struct { + Ctx context.Context + Cancel context.CancelFunc + Wg *sync.WaitGroup + Listener net.Listener + HandleConn connHandler + ErrorLog logging.Logger + Host string + NetworkAddr net.Addr +} + +type connHandler func(net.Conn) + +func NewServer( + ctx context.Context, + hostname string, + network string, + address string, + errorLog logging.Logger, + handleConn connHandler, +) (Server, error) { + listener, err := net.Listen(network, address) + if err != nil { + return Server{}, err + } + + networkAddr := listener.Addr() + ctx, cancel := context.WithCancel(ctx) + + return Server{ + Ctx: ctx, + Cancel: cancel, + Wg: &sync.WaitGroup{}, + Listener: listener, + HandleConn: handleConn, + ErrorLog: errorLog, + Host: hostname, + NetworkAddr: networkAddr, + }, nil +} + +func (s *Server) Serve() error { + s.Wg.Add(1) + defer s.Wg.Done() + + s.propagateClose() + + for { + conn, err := s.Listener.Accept() + if err != nil { + if s.Closed() { + err = nil + } else { + _ = s.ErrorLog.Log("msg", "accept error", "error", err) + } + + return err + } + + s.Wg.Add(1) + go func() { + defer s.Wg.Done() + defer func() { + _ = conn.Close() + }() + + s.HandleConn(conn) + }() + } +} + +func (s *Server) Hostname() string { + host, _, _ := net.SplitHostPort(s.Host) + return host +} + +func (s *Server) Port() string { + _, port, _ := net.SplitHostPort(s.Host) + return port +} + +func (s *Server) Network() string { + return s.NetworkAddr.Network() +} + +func (s *Server) Address() string { + return s.NetworkAddr.String() +} + +func (s *Server) Close() { + s.Cancel() + s.Wg.Wait() +} + +func (s *Server) LogError(keyvals ...any) error { + return s.ErrorLog.Log(keyvals...) +} + +func (s *Server) Closed() bool { + select { + case <-s.Ctx.Done(): + return true + default: + return false + } +} + +func (s *Server) propagateClose() { + s.Wg.Add(1) + go func() { + defer s.Wg.Done() + + <-s.Ctx.Done() + _ = s.Listener.Close() + }() +} diff --git a/logging/middleware.go b/logging/middleware.go index 5527ce7..5442203 100644 --- a/logging/middleware.go +++ b/logging/middleware.go @@ -3,7 +3,6 @@ package logging import ( "context" "errors" - "fmt" "io" "time" @@ -38,7 +37,7 @@ func (lr *loggedResponseBody) log() { end := time.Now() _ = lr.logger.Log( "msg", "request", - "ts", end, + "ts", end.UTC(), "dur", end.Sub(lr.start), "url", lr.request.URL, "status", lr.response.Status, @@ -74,7 +73,6 @@ type loggedWriteToResponseBody struct { } func (lwtr loggedWriteToResponseBody) WriteTo(dst io.Writer) (int64, error) { - fmt.Println("lwtrb.WriteTo()") n, err := lwtr.body.(io.WriterTo).WriteTo(dst) if err == nil { lwtr.written += int(n) diff --git a/response.go b/response.go index 5943552..369c5d1 100644 --- a/response.go +++ b/response.go @@ -26,3 +26,10 @@ func (response *Response) Close() error { } return nil } + +// ResponseReader is an object which can serialize a response to a protocol. +type ResponseReader interface { + io.Reader + io.WriterTo + io.Closer +} diff --git a/server.go b/server.go index 96b6433..686e92e 100644 --- a/server.go +++ b/server.go @@ -19,6 +19,9 @@ type Server interface { // hasn't yet completed. Closed() bool + // Protocol returns the protocol being served by the server. + Protocol() string + // Network returns the network type on which the server is running. Network() string @@ -33,4 +36,7 @@ type Server interface { // It will return the empty string if the network type does not // have ports (unix sockets, for example). Port() string + + // LogError sends a log message to the server's error log. + LogError(keyvals ...any) error }