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

View File

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

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 (
"bytes"
"context"
"io"
"io/fs"
"sort"
"strings"
"text/template"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/gemini"
)
// DirectoryDefault handles directory path requests by looking for specific filenames.
// ResolveDirectory opens the directory corresponding to a request path.
//
// If any of the supported filenames are found, the contents of the file is returned
// as the gemini response.
//
// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
//
// When it encounters a directory path which doesn't end in a trailing slash (/) it
// redirects to a URL with the trailing slash appended. This is necessary for relative
// links into the directory's contents to function.
//
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
// it will also produce "51 Not Found" responses for directory paths.
func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gus.Handler {
return func(ctx context.Context, req *gus.Request) *gus.Response {
path, dirFile, resp := handleDir(req, fileSystem)
if dirFile == nil {
return resp
}
defer dirFile.Close()
entries, err := dirFile.ReadDir(0)
if err != nil {
return gemini.Failure(err)
}
for _, fileName := range fileNames {
for _, entry := range entries {
if entry.Name() == fileName {
file, err := fileSystem.Open(path + "/" + fileName)
if err != nil {
return gemini.Failure(err)
}
return gemini.Success(mediaType(fileName), file)
}
}
}
return nil
// The string is the full path to the directory. If the returned ReadDirFile
// is not nil, it will be open and must be closed by the caller.
func ResolveDirectory(
request *gus.Request,
fileSystem fs.FS,
) (string, fs.ReadDirFile, error) {
path := strings.Trim(request.Path, "/")
if path == "" {
path = "."
}
file, err := fileSystem.Open(path)
if isNotFound(err) {
return "", nil, nil
}
if err != nil {
return "", nil, err
}
isDir, err := fileIsDir(file)
if err != nil {
_ = file.Close()
return "", nil, err
}
if !isDir {
_ = file.Close()
return "", nil, nil
}
dirFile, ok := file.(fs.ReadDirFile)
if !ok {
_ = file.Close()
return "", nil, nil
}
return path, dirFile, nil
}
// DirectoryListing produces a gemtext listing of the contents of any requested directories.
// ResolveDirectoryDefault finds any of the provided filenames within a directory.
//
// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
//
// When it encounters a directory path which doesn't end in a trailing slash (/) it
// redirects to a URL with the trailing slash appended. This is necessary for relative
// links into the directory's contents to function.
//
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
// it will also produce "51 Not Found" responses for directory paths.
// If it does not find any of the filenames it returns "", nil, nil.
func ResolveDirectoryDefault(
fileSystem fs.FS,
dirPath string,
dir fs.ReadDirFile,
filenames []string,
) (string, fs.File, error) {
entries, err := dir.ReadDir(0)
if err != nil {
return "", nil, err
}
sort.Slice(entries, func(a, b int) bool {
return entries[a].Name() < entries[b].Name()
})
for _, filename := range filenames {
idx := sort.Search(len(entries), func(i int) bool {
return entries[i].Name() <= filename
})
if idx < len(entries) && entries[idx].Name() == filename {
path := dirPath + "/" + filename
file, err := fileSystem.Open(path)
return path, file, err
}
}
return "", nil, nil
}
// RenderDirectoryListing provides an io.Reader with the output of a directory listing template.
//
// The template is provided the following namespace:
// - .FullPath: the complete path to the listed directory
// - .DirName: the name of the directory itself
// - .Entries: the []fs.DirEntry of the directory contents
// - .Hostname: the hostname of the server hosting the file
// - .Port: the port on which the server is listening
//
// The template argument may be nil, in which case a simple default template is used.
func DirectoryListing(fileSystem fs.FS, template *template.Template) gus.Handler {
return func(ctx context.Context, req *gus.Request) *gus.Response {
path, dirFile, resp := handleDir(req, fileSystem)
if dirFile == nil {
return resp
}
defer dirFile.Close()
// Each entry in .Entries has the following fields:
// - .Name the string name of the item within the directory
// - .IsDir is a boolean
// - .Type is the FileMode bits
// - .Info is a method returning (fs.FileInfo, error)
func RenderDirectoryListing(
path string,
dir fs.ReadDirFile,
template *template.Template,
server gus.Server,
) (io.Reader, error) {
buf := &bytes.Buffer{}
if template == nil {
template = defaultDirListTemplate
}
buf := &bytes.Buffer{}
environ, err := dirlistNamespace(path, dirFile)
if err != nil {
return gemini.Failure(err)
}
if err := template.Execute(buf, environ); err != nil {
gemini.Failure(err)
}
return gemini.Success("text/gemini", buf)
environ, err := dirlistNamespace(path, dir, server)
if err != nil {
return nil, err
}
if err := template.Execute(buf, environ); err != nil {
return nil, err
}
return buf, nil
}
var defaultDirListTemplate = template.Must(template.New("directory_listing").Parse(`
# {{ .DirName }}
{{ range .Entries }}
=> {{ .Name }}{{ if .IsDir }}/{{ end -}}
{{ end }}
=> ../
`[1:]))
func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, error) {
func dirlistNamespace(path string, dirFile fs.ReadDirFile, server gus.Server) (map[string]any, error) {
entries, err := dirFile.ReadDir(0)
if err != nil {
return nil, err
@ -124,52 +135,20 @@ func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, erro
dirname = path[strings.LastIndex(path, "/")+1:]
}
hostname := "none"
port := "0"
if server != nil {
hostname = server.Hostname()
port = server.Port()
}
m := map[string]any{
"FullPath": path,
"DirName": dirname,
"Entries": entries,
"Hostname": hostname,
"Port": port,
}
return m, nil
}
func handleDir(req *gus.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gus.Response) {
path := strings.Trim(req.Path, "/")
if path == "" {
path = "."
}
file, err := fileSystem.Open(path)
if isNotFound(err) {
return "", nil, nil
}
if err != nil {
return "", nil, gemini.Failure(err)
}
isDir, err := fileIsDir(file)
if err != nil {
file.Close()
return "", nil, gemini.Failure(err)
}
if !isDir {
file.Close()
return "", nil, nil
}
if !strings.HasSuffix(req.Path, "/") {
file.Close()
url := *req.URL
url.Path += "/"
return "", nil, gemini.Redirect(url.String())
}
dirFile, ok := file.(fs.ReadDirFile)
if !ok {
file.Close()
return "", nil, nil
}
return path, dirFile, nil
}

View File

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

View File

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

View File

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

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(
context.Background(),
nil,
serverTLS,
"localhost",
"tcp",
"127.0.0.1:0",
handler,
nil,
serverTLS,
)
require.Nil(t, err)

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -5,14 +5,13 @@ import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"tildegit.org/tjp/gus"
"tildegit.org/tjp/gus/internal"
"tildegit.org/tjp/gus/logging"
)
@ -25,127 +24,59 @@ type titanRequestBodyKey struct{}
var TitanRequestBody = titanRequestBodyKey{}
type server struct {
ctx context.Context
errorLog logging.Logger
network string
address string
cancel context.CancelFunc
wg *sync.WaitGroup
listener net.Listener
handler gus.Handler
internal.Server
handler gus.Handler
}
func (s server) Protocol() string { return "GEMINI" }
// NewServer builds a gemini server.
func NewServer(
ctx context.Context,
errorLog logging.Logger,
tlsConfig *tls.Config,
hostname string,
network string,
address string,
handler gus.Handler,
errorLog logging.Logger,
tlsConfig *tls.Config,
) (gus.Server, error) {
listener, err := net.Listen(network, address)
s := &server{handler: handler}
if strings.IndexByte(hostname, ':') < 0 {
hostname = net.JoinHostPort(hostname, "1965")
}
internalServer, err := internal.NewServer(ctx, hostname, network, address, errorLog, s.handleConn)
if err != nil {
return nil, err
}
s.Server = internalServer
addr := listener.Addr()
s := &server{
ctx: ctx,
errorLog: errorLog,
network: addr.Network(),
address: addr.String(),
wg: &sync.WaitGroup{},
listener: tls.NewListener(listener, tlsConfig),
handler: handler,
}
s.Listener = tls.NewListener(s.Listener, tlsConfig)
return s, nil
}
// Serve starts the server and blocks until it is closed.
//
// This function will allocate resources which are not cleaned up until
// Close() is called.
//
// It will respect cancellation of the context the server was created with,
// but be aware that Close() must still be called in that case to avoid
// dangling goroutines.
//
// On titan protocol requests it sets a key/value pair in the context. The
// key is TitanRequestBody, and the value is a *bufio.Reader from which the
// request body can be read.
func (s *server) Serve() error {
s.wg.Add(1)
defer s.wg.Done()
s.ctx, s.cancel = context.WithCancel(s.ctx)
s.wg.Add(1)
s.propagateCancel()
for {
conn, err := s.listener.Accept()
if err != nil {
if s.Closed() {
err = nil
} else {
_ = s.errorLog.Log("msg", "accept error", "error", err)
}
return err
}
s.wg.Add(1)
go s.handleConn(conn)
}
}
func (s *server) Close() {
s.cancel()
s.wg.Wait()
}
func (s *server) Network() string {
return s.network
}
func (s *server) Address() string {
return s.address
}
func (s *server) Hostname() string {
host, _, _ := net.SplitHostPort(s.address)
return host
}
func (s *server) Port() string {
_, portStr, _ := net.SplitHostPort(s.address)
return portStr
}
func (s *server) handleConn(conn net.Conn) {
defer s.wg.Done()
defer conn.Close()
buf := bufio.NewReader(conn)
var response *gus.Response
req, err := ParseRequest(buf)
request, err := ParseRequest(buf)
if err != nil {
response = BadRequest(err.Error())
} else {
req.Server = s
req.RemoteAddr = conn.RemoteAddr()
request.Server = s
request.RemoteAddr = conn.RemoteAddr()
if tlsconn, ok := conn.(*tls.Conn); ok {
state := tlsconn.ConnectionState()
req.TLSState = &state
request.TLSState = &state
}
ctx := s.ctx
if req.Scheme == "titan" {
len, err := sizeParam(req.Path)
ctx := s.Ctx
if request.Scheme == "titan" {
len, err := sizeParam(request.Path)
if err == nil {
ctx = context.WithValue(
ctx,
@ -155,15 +86,16 @@ func (s *server) handleConn(conn net.Conn) {
}
}
defer func() {
if r := recover(); r != nil {
err := fmt.Errorf("%s", r)
_ = s.errorLog.Log("msg", "panic in handler", "err", err)
_, _ = io.Copy(conn, NewResponseReader(Failure(err)))
}
}()
response = s.handler(ctx, req)
/*
defer func() {
if r := recover(); r != nil {
err := fmt.Errorf("%s", r)
_ = s.LogError("msg", "panic in handler", "err", err)
_, _ = io.Copy(conn, NewResponseReader(Failure(err)))
}
}()
*/
response = s.handler(ctx, request)
if response == nil {
response = NotFound("Resource does not exist.")
}
@ -173,24 +105,6 @@ func (s *server) handleConn(conn net.Conn) {
_, _ = io.Copy(conn, NewResponseReader(response))
}
func (s *server) propagateCancel() {
go func() {
defer s.wg.Done()
<-s.ctx.Done()
_ = s.listener.Close()
}()
}
func (s *server) Closed() bool {
select {
case <-s.ctx.Done():
return true
default:
return false
}
}
func sizeParam(path string) (int, error) {
_, rest, found := strings.Cut(path, ";")
if !found {

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 (
"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)

View File

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

View File

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