gopher support.
continuous-integration/drone/push Build is passing Details

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.
This commit is contained in:
tjpcc 2023-01-28 14:52:35 -07:00
parent a27b879acc
commit 66a1b1f39a
30 changed files with 1367 additions and 365 deletions

View File

@ -6,7 +6,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "io"
"io/fs" "io/fs"
"net" "net"
"os" "os"
@ -14,52 +14,45 @@ import (
"strings" "strings"
"tildegit.org/tjp/gus" "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 // It returns the path to the executable file and the PATH_INFO that should be passed,
// a request for /foo/bar/baz can also run an executable found at /foo or /foo/bar. In // or an error.
// such a case the PATH_INFO environment variable will include the remaining portion of //
// the URI path. // It will find executables which are just part way through the path, so for example
func CGIDirectory(pathRoot, fsRoot string) gus.Handler { // a request for /foo/bar/baz can run an executable found at /foo or /foo/bar. In such
fsRoot = strings.TrimRight(fsRoot, "/") // 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 { for i := range append(segments, "") {
if !strings.HasPrefix(req.Path, pathRoot) { filepath := strings.Join(append([]string{fsRoot}, segments[:i]...), "/")
return nil filepath = strings.TrimRight(filepath, "/")
isDir, isExecutable, err := executableFile(filepath)
if err != nil {
return "", "", err
} }
path := req.Path[len(pathRoot):] if isExecutable {
segments := strings.Split(strings.TrimLeft(path, "/"), "/") pathinfo := "/"
for i := range append(segments, "") { if len(segments) > i+1 {
path := strings.Join(append([]string{fsRoot}, segments[:i]...), "/") pathinfo = strings.Join(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
} }
return filepath, pathinfo, nil
} }
return nil if !isDir {
break
}
} }
return "", "", nil
} }
func executableFile(path string) (bool, bool, error) { func executableFile(filepath string) (bool, bool, error) {
file, err := os.Open(path) file, err := os.Open(filepath)
if isNotExistError(err) { if isNotExistError(err) {
return false, false, nil return false, false, nil
} }
@ -98,10 +91,10 @@ func isNotExistError(err error) bool {
// RunCGI runs a specific program as a CGI script. // RunCGI runs a specific program as a CGI script.
func RunCGI( func RunCGI(
ctx context.Context, ctx context.Context,
req *gus.Request, request *gus.Request,
executable string, executable string,
pathInfo string, pathInfo string,
) *gus.Response { ) (io.Reader, int, error) {
pathSegments := strings.Split(executable, "/") pathSegments := strings.Split(executable, "/")
dirPath := "." dirPath := "."
@ -115,40 +108,34 @@ func RunCGI(
infoLen -= 1 infoLen -= 1
} }
scriptName := req.Path[:len(req.Path)-infoLen] scriptName := request.Path[:len(request.Path)-infoLen]
scriptName = strings.TrimSuffix(scriptName, "/") scriptName = strings.TrimSuffix(scriptName, "/")
cmd := exec.CommandContext(ctx, "./"+basename) cmd := exec.CommandContext(ctx, "./"+basename)
cmd.Env = prepareCGIEnv(ctx, req, scriptName, pathInfo) cmd.Env = prepareCGIEnv(ctx, request, scriptName, pathInfo)
cmd.Dir = dirPath cmd.Dir = dirPath
responseBuffer := &bytes.Buffer{} responseBuffer := &bytes.Buffer{}
cmd.Stdout = responseBuffer cmd.Stdout = responseBuffer
if err := cmd.Run(); err != nil { err := cmd.Run()
if err != nil {
var exErr *exec.ExitError var exErr *exec.ExitError
if errors.As(err, &exErr) { if errors.As(err, &exErr) {
errMsg := fmt.Sprintf("CGI returned exit code %d", exErr.ExitCode()) return responseBuffer, exErr.ExitCode(), nil
return gemini.CGIError(errMsg)
} }
return gemini.Failure(err)
} }
return responseBuffer, cmd.ProcessState.ExitCode(), err
response, err := gemini.ParseResponse(responseBuffer)
if err != nil {
return gemini.Failure(err)
}
return response
} }
func prepareCGIEnv( func prepareCGIEnv(
ctx context.Context, ctx context.Context,
req *gus.Request, request *gus.Request,
scriptName string, scriptName string,
pathInfo string, pathInfo string,
) []string { ) []string {
var authType string var authType string
if len(req.TLSState.PeerCertificates) > 0 { if request.TLSState != nil && len(request.TLSState.PeerCertificates) > 0 {
authType = "Certificate" authType = "Certificate"
} }
environ := []string{ environ := []string{
@ -158,10 +145,10 @@ func prepareCGIEnv(
"GATEWAY_INTERFACE=CGI/1.1", "GATEWAY_INTERFACE=CGI/1.1",
"PATH_INFO=" + pathInfo, "PATH_INFO=" + pathInfo,
"PATH_TRANSLATED=", "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(environ, "REMOTE_ADDR="+host)
environ = append( environ = append(
@ -169,14 +156,14 @@ func prepareCGIEnv(
"REMOTE_HOST=", "REMOTE_HOST=",
"REMOTE_IDENT=", "REMOTE_IDENT=",
"SCRIPT_NAME="+scriptName, "SCRIPT_NAME="+scriptName,
"SERVER_NAME="+req.Server.Hostname(), "SERVER_NAME="+request.Server.Hostname(),
"SERVER_PORT="+req.Server.Port(), "SERVER_PORT="+request.Server.Port(),
"SERVER_PROTOCOL=GEMINI", "SERVER_PROTOCOL="+request.Server.Protocol(),
"SERVER_SOFTWARE=GUS", "SERVER_SOFTWARE=GUS",
) )
if len(req.TLSState.PeerCertificates) > 0 { if request.TLSState != nil && len(request.TLSState.PeerCertificates) > 0 {
cert := req.TLSState.PeerCertificates[0] cert := request.TLSState.PeerCertificates[0]
environ = append( environ = append(
environ, environ,
"TLS_CLIENT_HASH="+fingerprint(cert.Raw), "TLS_CLIENT_HASH="+fingerprint(cert.Raw),

View File

@ -21,8 +21,8 @@ func TestCGIDirectory(t *testing.T) {
tlsconf, err := gemini.FileTLS("testdata/server.crt", "testdata/server.key") tlsconf, err := gemini.FileTLS("testdata/server.crt", "testdata/server.key")
require.Nil(t, err) require.Nil(t, err)
handler := cgi.CGIDirectory("/cgi-bin", "./testdata") handler := cgi.GeminiCGIDirectory("/cgi-bin", "./testdata")
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) require.Nil(t, err)
go func() { assert.Nil(t, server.Serve()) }() go func() { assert.Nil(t, server.Serve()) }()

47
contrib/cgi/gemini.go Normal file
View File

@ -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
}
}

45
contrib/cgi/gopher.go Normal file
View File

@ -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)
}
}

View File

@ -2,112 +2,123 @@ package fs
import ( import (
"bytes" "bytes"
"context" "io"
"io/fs" "io/fs"
"sort" "sort"
"strings" "strings"
"text/template" "text/template"
"tildegit.org/tjp/gus" "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 // The string is the full path to the directory. If the returned ReadDirFile
// as the gemini response. // is not nil, it will be open and must be closed by the caller.
// func ResolveDirectory(
// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory. request *gus.Request,
// fileSystem fs.FS,
// When it encounters a directory path which doesn't end in a trailing slash (/) it ) (string, fs.ReadDirFile, error) {
// redirects to a URL with the trailing slash appended. This is necessary for relative path := strings.Trim(request.Path, "/")
// links into the directory's contents to function. if path == "" {
// path = "."
// 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
} }
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. // If it does not find any of the filenames it returns "", nil, nil.
// func ResolveDirectoryDefault(
// When it encounters a directory path which doesn't end in a trailing slash (/) it fileSystem fs.FS,
// redirects to a URL with the trailing slash appended. This is necessary for relative dirPath string,
// links into the directory's contents to function. dir fs.ReadDirFile,
// filenames []string,
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't, ) (string, fs.File, error) {
// it will also produce "51 Not Found" responses for directory paths. 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: // The template is provided the following namespace:
// - .FullPath: the complete path to the listed directory // - .FullPath: the complete path to the listed directory
// - .DirName: the name of the directory itself // - .DirName: the name of the directory itself
// - .Entries: the []fs.DirEntry of the directory contents // - .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. // Each entry in .Entries has the following fields:
func DirectoryListing(fileSystem fs.FS, template *template.Template) gus.Handler { // - .Name the string name of the item within the directory
return func(ctx context.Context, req *gus.Request) *gus.Response { // - .IsDir is a boolean
path, dirFile, resp := handleDir(req, fileSystem) // - .Type is the FileMode bits
if dirFile == nil { // - .Info is a method returning (fs.FileInfo, error)
return resp func RenderDirectoryListing(
} path string,
defer dirFile.Close() dir fs.ReadDirFile,
template *template.Template,
server gus.Server,
) (io.Reader, error) {
buf := &bytes.Buffer{}
if template == nil { environ, err := dirlistNamespace(path, dir, server)
template = defaultDirListTemplate if err != nil {
} return nil, err
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)
} }
if err := template.Execute(buf, environ); err != nil {
return nil, err
}
return buf, nil
} }
var defaultDirListTemplate = template.Must(template.New("directory_listing").Parse(` func dirlistNamespace(path string, dirFile fs.ReadDirFile, server gus.Server) (map[string]any, error) {
# {{ .DirName }}
{{ range .Entries }}
=> {{ .Name }}{{ if .IsDir }}/{{ end -}}
{{ end }}
=> ../
`[1:]))
func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, error) {
entries, err := dirFile.ReadDir(0) entries, err := dirFile.ReadDir(0)
if err != nil { if err != nil {
return nil, err return nil, err
@ -124,52 +135,20 @@ func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, erro
dirname = path[strings.LastIndex(path, "/")+1:] dirname = path[strings.LastIndex(path, "/")+1:]
} }
hostname := "none"
port := "0"
if server != nil {
hostname = server.Hostname()
port = server.Port()
}
m := map[string]any{ m := map[string]any{
"FullPath": path, "FullPath": path,
"DirName": dirname, "DirName": dirname,
"Entries": entries, "Entries": entries,
"Hostname": hostname,
"Port": port,
} }
return m, nil 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
}

View File

@ -16,7 +16,7 @@ import (
) )
func TestDirectoryDefault(t *testing.T) { func TestDirectoryDefault(t *testing.T) {
handler := fs.DirectoryDefault(os.DirFS("testdata"), "index.gmi") handler := fs.GeminiDirectoryDefault(os.DirFS("testdata"), "index.gmi")
tests := []struct { tests := []struct {
url string url string
@ -69,7 +69,7 @@ func TestDirectoryDefault(t *testing.T) {
} }
func TestDirectoryListing(t *testing.T) { func TestDirectoryListing(t *testing.T) {
handler := fs.DirectoryListing(os.DirFS("testdata"), nil) handler := fs.GeminiDirectoryListing(os.DirFS("testdata"), nil)
tests := []struct { tests := []struct {
url string url string

View File

@ -1,37 +1,40 @@
package fs package fs
import ( import (
"context"
"io/fs" "io/fs"
"mime" "mime"
"strings" "strings"
"tildegit.org/tjp/gus" "tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
) )
// FileHandler builds a handler function which serves up a file system. // ResolveFile finds a file from a filesystem based on a request path.
func FileHandler(fileSystem fs.FS) gus.Handler { //
return func(ctx context.Context, req *gus.Request) *gus.Response { // It only returns a non-nil file if a file is found - not a directory.
file, err := fileSystem.Open(strings.TrimPrefix(req.Path, "/")) // If there is any other sort of filesystem access error, it will be
if isNotFound(err) { // returned.
return nil func ResolveFile(request *gus.Request, fileSystem fs.FS) (string, fs.File, error) {
} filepath := strings.TrimPrefix(request.Path, "/")
if err != nil { file, err := fileSystem.Open(filepath)
return gemini.Failure(err) if isNotFound(err) {
} return "", nil, nil
isDir, err := fileIsDir(file)
if err != nil {
return gemini.Failure(err)
}
if isDir {
return nil
}
return gemini.Success(mediaType(req.Path), file)
} }
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 { func mediaType(filePath string) string {

View File

@ -16,7 +16,7 @@ import (
) )
func TestFileHandler(t *testing.T) { func TestFileHandler(t *testing.T) {
handler := fs.FileHandler(os.DirFS("testdata")) handler := fs.GeminiFileHandler(os.DirFS("testdata"))
tests := []struct { tests := []struct {
url string url string

130
contrib/fs/gemini.go Normal file
View File

@ -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
}

168
contrib/fs/gopher.go Normal file
View File

@ -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
}

View File

@ -143,11 +143,12 @@ func setup(
server, err := gemini.NewServer( server, err := gemini.NewServer(
context.Background(), context.Background(),
nil, "localhost",
serverTLS,
"tcp", "tcp",
"127.0.0.1:0", "127.0.0.1:0",
handler, handler,
nil,
serverTLS,
) )
require.Nil(t, err) require.Nil(t, err)

View File

@ -23,7 +23,7 @@ func main() {
} }
// make use of a CGI request handler // 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() _, infoLog, _, errLog := logging.DefaultLoggers()
@ -35,7 +35,7 @@ func main() {
defer stop() defer stop()
// run the server // 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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -29,7 +29,7 @@ func main() {
handler := logging.LogRequests(infoLog)(cowsayHandler) handler := logging.LogRequests(infoLog)(cowsayHandler)
// run the server // 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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -26,11 +26,11 @@ func main() {
// Fallthrough tries each handler in succession until it gets something other than "51 Not Found" // Fallthrough tries each handler in succession until it gets something other than "51 Not Found"
handler := gus.FallthroughHandler( handler := gus.FallthroughHandler(
// first see if they're fetching a directory and we have <dir>/index.gmi // 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 // 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 // finally, try to find a file at the request path and respond with that
fs.FileHandler(fileSystem), fs.GeminiFileHandler(fileSystem),
) )
_, infoLog, _, errLog := logging.DefaultLoggers() _, infoLog, _, errLog := logging.DefaultLoggers()
@ -39,7 +39,7 @@ func main() {
handler = logging.LogRequests(infoLog)(handler) handler = logging.LogRequests(infoLog)(handler)
// run the server // 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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -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()
}

View File

@ -33,7 +33,7 @@ func main() {
handler := logging.LogRequests(infoLog)(inspectHandler) handler := logging.LogRequests(infoLog)(inspectHandler)
// run the server // 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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -6,6 +6,7 @@ import (
"errors" "errors"
"io" "io"
"strconv" "strconv"
"sync"
"tildegit.org/tjp/gus" "tildegit.org/tjp/gus"
) )
@ -284,19 +285,17 @@ func ParseResponse(rdr io.Reader) (*gus.Response, error) {
}, nil }, nil
} }
type ResponseReader interface { func NewResponseReader(response *gus.Response) gus.ResponseReader {
io.Reader return &responseReader{
io.WriterTo Response: response,
io.Closer once: &sync.Once{},
} }
func NewResponseReader(response *gus.Response) ResponseReader {
return &responseReader{ Response: response }
} }
type responseReader struct { type responseReader struct {
*gus.Response *gus.Response
reader io.Reader reader io.Reader
once *sync.Once
} }
func (rdr *responseReader) Read(b []byte) (int, error) { 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() { func (rdr *responseReader) ensureReader() {
if rdr.reader != nil { rdr.once.Do(func() {
return hdr := bytes.NewBuffer(rdr.headerLine())
} if rdr.Body != nil {
rdr.reader = io.MultiReader(hdr, rdr.Body)
hdr := bytes.NewBuffer(rdr.headerLine()) } else {
if rdr.Body != nil { rdr.reader = hdr
rdr.reader = io.MultiReader(hdr, rdr.Body) }
} else { })
rdr.reader = hdr
}
} }
func (rdr responseReader) headerLine() []byte { func (rdr responseReader) headerLine() []byte {

View File

@ -24,7 +24,7 @@ func TestRoundTrip(t *testing.T) {
return gemini.Success("text/gemini", bytes.NewBufferString("you've found my page")) 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) require.Nil(t, err)
go func() { go func() {
@ -69,7 +69,7 @@ func TestTitanRequest(t *testing.T) {
return gemini.Success("", nil) 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) require.Nil(t, err)
go func() { go func() {

View File

@ -5,14 +5,13 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt"
"io" "io"
"net" "net"
"strconv" "strconv"
"strings" "strings"
"sync"
"tildegit.org/tjp/gus" "tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/internal"
"tildegit.org/tjp/gus/logging" "tildegit.org/tjp/gus/logging"
) )
@ -25,127 +24,59 @@ type titanRequestBodyKey struct{}
var TitanRequestBody = titanRequestBodyKey{} var TitanRequestBody = titanRequestBodyKey{}
type server struct { type server struct {
ctx context.Context internal.Server
errorLog logging.Logger
network string handler gus.Handler
address string
cancel context.CancelFunc
wg *sync.WaitGroup
listener net.Listener
handler gus.Handler
} }
func (s server) Protocol() string { return "GEMINI" }
// NewServer builds a gemini server. // NewServer builds a gemini server.
func NewServer( func NewServer(
ctx context.Context, ctx context.Context,
errorLog logging.Logger, hostname string,
tlsConfig *tls.Config,
network string, network string,
address string, address string,
handler gus.Handler, handler gus.Handler,
errorLog logging.Logger,
tlsConfig *tls.Config,
) (gus.Server, error) { ) (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 { if err != nil {
return nil, err return nil, err
} }
s.Server = internalServer
addr := listener.Addr() s.Listener = tls.NewListener(s.Listener, tlsConfig)
s := &server{
ctx: ctx,
errorLog: errorLog,
network: addr.Network(),
address: addr.String(),
wg: &sync.WaitGroup{},
listener: tls.NewListener(listener, tlsConfig),
handler: handler,
}
return s, nil 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) { func (s *server) handleConn(conn net.Conn) {
defer s.wg.Done()
defer conn.Close()
buf := bufio.NewReader(conn) buf := bufio.NewReader(conn)
var response *gus.Response var response *gus.Response
req, err := ParseRequest(buf) request, err := ParseRequest(buf)
if err != nil { if err != nil {
response = BadRequest(err.Error()) response = BadRequest(err.Error())
} else { } else {
req.Server = s request.Server = s
req.RemoteAddr = conn.RemoteAddr() request.RemoteAddr = conn.RemoteAddr()
if tlsconn, ok := conn.(*tls.Conn); ok { if tlsconn, ok := conn.(*tls.Conn); ok {
state := tlsconn.ConnectionState() state := tlsconn.ConnectionState()
req.TLSState = &state request.TLSState = &state
} }
ctx := s.ctx ctx := s.Ctx
if req.Scheme == "titan" { if request.Scheme == "titan" {
len, err := sizeParam(req.Path) len, err := sizeParam(request.Path)
if err == nil { if err == nil {
ctx = context.WithValue( ctx = context.WithValue(
ctx, ctx,
@ -155,15 +86,16 @@ func (s *server) handleConn(conn net.Conn) {
} }
} }
defer func() { /*
if r := recover(); r != nil { defer func() {
err := fmt.Errorf("%s", r) if r := recover(); r != nil {
_ = s.errorLog.Log("msg", "panic in handler", "err", err) err := fmt.Errorf("%s", r)
_, _ = io.Copy(conn, NewResponseReader(Failure(err))) _ = s.LogError("msg", "panic in handler", "err", err)
} _, _ = io.Copy(conn, NewResponseReader(Failure(err)))
}() }
}()
response = s.handler(ctx, req) */
response = s.handler(ctx, request)
if response == nil { if response == nil {
response = NotFound("Resource does not exist.") response = NotFound("Resource does not exist.")
} }
@ -173,24 +105,6 @@ func (s *server) handleConn(conn net.Conn) {
_, _ = io.Copy(conn, NewResponseReader(response)) _, _ = 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) { func sizeParam(path string) (int, error) {
_, rest, found := strings.Cut(path, ";") _, rest, found := strings.Cut(path, ";")
if !found { if !found {

55
gopher/client.go Normal file
View File

@ -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
}

61
gopher/gophermap/parse.go Normal file
View File

@ -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)
}

View File

@ -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)
}
}
})
}
}

72
gopher/request.go Normal file
View File

@ -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
}

43
gopher/request_test.go Normal file
View File

@ -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)
})
}
}

162
gopher/response.go Normal file
View File

@ -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)
})
}

72
gopher/serve.go Normal file
View File

@ -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))
}

126
internal/server.go Normal file
View File

@ -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()
}()
}

View File

@ -3,7 +3,6 @@ package logging
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"time" "time"
@ -38,7 +37,7 @@ func (lr *loggedResponseBody) log() {
end := time.Now() end := time.Now()
_ = lr.logger.Log( _ = lr.logger.Log(
"msg", "request", "msg", "request",
"ts", end, "ts", end.UTC(),
"dur", end.Sub(lr.start), "dur", end.Sub(lr.start),
"url", lr.request.URL, "url", lr.request.URL,
"status", lr.response.Status, "status", lr.response.Status,
@ -74,7 +73,6 @@ type loggedWriteToResponseBody struct {
} }
func (lwtr loggedWriteToResponseBody) WriteTo(dst io.Writer) (int64, error) { func (lwtr loggedWriteToResponseBody) WriteTo(dst io.Writer) (int64, error) {
fmt.Println("lwtrb.WriteTo()")
n, err := lwtr.body.(io.WriterTo).WriteTo(dst) n, err := lwtr.body.(io.WriterTo).WriteTo(dst)
if err == nil { if err == nil {
lwtr.written += int(n) lwtr.written += int(n)

View File

@ -26,3 +26,10 @@ func (response *Response) Close() error {
} }
return nil return nil
} }
// ResponseReader is an object which can serialize a response to a protocol.
type ResponseReader interface {
io.Reader
io.WriterTo
io.Closer
}

View File

@ -19,6 +19,9 @@ type Server interface {
// hasn't yet completed. // hasn't yet completed.
Closed() bool 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 returns the network type on which the server is running.
Network() string Network() string
@ -33,4 +36,7 @@ type Server interface {
// It will return the empty string if the network type does not // It will return the empty string if the network type does not
// have ports (unix sockets, for example). // have ports (unix sockets, for example).
Port() string Port() string
// LogError sends a log message to the server's error log.
LogError(keyvals ...any) error
} }