summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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
-rw-r--r--examples/cgi/main.go4
-rw-r--r--examples/cowsay/main.go2
-rw-r--r--examples/fileserver/main.go8
-rw-r--r--examples/gopher_fileserver/main.go33
-rw-r--r--examples/inspectls/main.go2
-rw-r--r--gemini/response.go33
-rw-r--r--gemini/roundtrip_test.go4
-rw-r--r--gemini/serve.go158
-rw-r--r--gopher/client.go55
-rw-r--r--gopher/gophermap/parse.go61
-rw-r--r--gopher/gophermap/parse_test.go96
-rw-r--r--gopher/request.go72
-rw-r--r--gopher/request_test.go43
-rw-r--r--gopher/response.go162
-rw-r--r--gopher/serve.go72
-rw-r--r--internal/server.go126
-rw-r--r--logging/middleware.go4
-rw-r--r--response.go7
-rw-r--r--server.go6
30 files changed, 1365 insertions, 363 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)
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 <dir>/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)
- if err != nil {
- return nil, err
- }
+ s := &server{handler: handler}
- 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,
+ if strings.IndexByte(hostname, ':') < 0 {
+ hostname = net.JoinHostPort(hostname, "1965")
}
- 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)
+ internalServer, err := internal.NewServer(ctx, hostname, network, address, errorLog, s.handleConn)
+ if err != nil {
+ return nil, err
}
-}
-
-func (s *server) Close() {
- s.cancel()
- s.wg.Wait()
-}
-
-func (s *server) Network() string {
- return s.network
-}
+ s.Server = internalServer
-func (s *server) Address() string {
- return s.address
-}
+ s.Listener = tls.NewListener(s.Listener, tlsConfig)
-func (s *server) Hostname() string {
- host, _, _ := net.SplitHostPort(s.address)
- return host
-}
-
-func (s *server) Port() string {
- _, portStr, _ := net.SplitHostPort(s.address)
- return portStr
+ return s, nil
}
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
}