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