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"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
@ -14,52 +14,45 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"tildegit.org/tjp/gus"
|
"tildegit.org/tjp/gus"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CGIDirectory runs any executable files relative to a root directory on the file system.
|
// ResolveCGI finds a CGI program corresponding to a request path.
|
||||||
//
|
//
|
||||||
// It will also find and run any executables _part way_ through the path, so for example
|
// It returns the path to the executable file and the PATH_INFO that should be passed,
|
||||||
// a request for /foo/bar/baz can also run an executable found at /foo or /foo/bar. In
|
// or an error.
|
||||||
// such a case the PATH_INFO environment variable will include the remaining portion of
|
//
|
||||||
// the URI path.
|
// It will find executables which are just part way through the path, so for example
|
||||||
func CGIDirectory(pathRoot, fsRoot string) gus.Handler {
|
// a request for /foo/bar/baz can run an executable found at /foo or /foo/bar. In such
|
||||||
fsRoot = strings.TrimRight(fsRoot, "/")
|
// a case the PATH_INFO would include the remaining portion of the URI path.
|
||||||
|
func ResolveCGI(requestPath, fsRoot string) (string, string, error) {
|
||||||
|
segments := strings.Split(strings.TrimLeft(requestPath, "/"), "/")
|
||||||
|
|
||||||
return func(ctx context.Context, req *gus.Request) *gus.Response {
|
for i := range append(segments, "") {
|
||||||
if !strings.HasPrefix(req.Path, pathRoot) {
|
filepath := strings.Join(append([]string{fsRoot}, segments[:i]...), "/")
|
||||||
return nil
|
filepath = strings.TrimRight(filepath, "/")
|
||||||
|
isDir, isExecutable, err := executableFile(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
path := req.Path[len(pathRoot):]
|
if isExecutable {
|
||||||
segments := strings.Split(strings.TrimLeft(path, "/"), "/")
|
pathinfo := "/"
|
||||||
for i := range append(segments, "") {
|
if len(segments) > i+1 {
|
||||||
path := strings.Join(append([]string{fsRoot}, segments[:i]...), "/")
|
pathinfo = strings.Join(segments[i:], "/")
|
||||||
path = strings.TrimRight(path, "/")
|
|
||||||
isDir, isExecutable, err := executableFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return gemini.Failure(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isExecutable {
|
|
||||||
pathInfo := "/"
|
|
||||||
if len(segments) > i+1 {
|
|
||||||
pathInfo = strings.Join(segments[i:], "/")
|
|
||||||
}
|
|
||||||
return RunCGI(ctx, req, path, pathInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isDir {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
return filepath, pathinfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
if !isDir {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func executableFile(path string) (bool, bool, error) {
|
func executableFile(filepath string) (bool, bool, error) {
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(filepath)
|
||||||
if isNotExistError(err) {
|
if isNotExistError(err) {
|
||||||
return false, false, nil
|
return false, false, nil
|
||||||
}
|
}
|
||||||
|
@ -98,10 +91,10 @@ func isNotExistError(err error) bool {
|
||||||
// RunCGI runs a specific program as a CGI script.
|
// RunCGI runs a specific program as a CGI script.
|
||||||
func RunCGI(
|
func RunCGI(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *gus.Request,
|
request *gus.Request,
|
||||||
executable string,
|
executable string,
|
||||||
pathInfo string,
|
pathInfo string,
|
||||||
) *gus.Response {
|
) (io.Reader, int, error) {
|
||||||
pathSegments := strings.Split(executable, "/")
|
pathSegments := strings.Split(executable, "/")
|
||||||
|
|
||||||
dirPath := "."
|
dirPath := "."
|
||||||
|
@ -115,40 +108,34 @@ func RunCGI(
|
||||||
infoLen -= 1
|
infoLen -= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
scriptName := req.Path[:len(req.Path)-infoLen]
|
scriptName := request.Path[:len(request.Path)-infoLen]
|
||||||
scriptName = strings.TrimSuffix(scriptName, "/")
|
scriptName = strings.TrimSuffix(scriptName, "/")
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "./"+basename)
|
cmd := exec.CommandContext(ctx, "./"+basename)
|
||||||
cmd.Env = prepareCGIEnv(ctx, req, scriptName, pathInfo)
|
cmd.Env = prepareCGIEnv(ctx, request, scriptName, pathInfo)
|
||||||
cmd.Dir = dirPath
|
cmd.Dir = dirPath
|
||||||
|
|
||||||
responseBuffer := &bytes.Buffer{}
|
responseBuffer := &bytes.Buffer{}
|
||||||
cmd.Stdout = responseBuffer
|
cmd.Stdout = responseBuffer
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
var exErr *exec.ExitError
|
var exErr *exec.ExitError
|
||||||
if errors.As(err, &exErr) {
|
if errors.As(err, &exErr) {
|
||||||
errMsg := fmt.Sprintf("CGI returned exit code %d", exErr.ExitCode())
|
return responseBuffer, exErr.ExitCode(), nil
|
||||||
return gemini.CGIError(errMsg)
|
|
||||||
}
|
}
|
||||||
return gemini.Failure(err)
|
|
||||||
}
|
}
|
||||||
|
return responseBuffer, cmd.ProcessState.ExitCode(), err
|
||||||
response, err := gemini.ParseResponse(responseBuffer)
|
|
||||||
if err != nil {
|
|
||||||
return gemini.Failure(err)
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareCGIEnv(
|
func prepareCGIEnv(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *gus.Request,
|
request *gus.Request,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
pathInfo string,
|
pathInfo string,
|
||||||
) []string {
|
) []string {
|
||||||
var authType string
|
var authType string
|
||||||
if len(req.TLSState.PeerCertificates) > 0 {
|
if request.TLSState != nil && len(request.TLSState.PeerCertificates) > 0 {
|
||||||
authType = "Certificate"
|
authType = "Certificate"
|
||||||
}
|
}
|
||||||
environ := []string{
|
environ := []string{
|
||||||
|
@ -158,10 +145,10 @@ func prepareCGIEnv(
|
||||||
"GATEWAY_INTERFACE=CGI/1.1",
|
"GATEWAY_INTERFACE=CGI/1.1",
|
||||||
"PATH_INFO=" + pathInfo,
|
"PATH_INFO=" + pathInfo,
|
||||||
"PATH_TRANSLATED=",
|
"PATH_TRANSLATED=",
|
||||||
"QUERY_STRING=" + req.RawQuery,
|
"QUERY_STRING=" + request.RawQuery,
|
||||||
}
|
}
|
||||||
|
|
||||||
host, _, _ := net.SplitHostPort(req.RemoteAddr.String())
|
host, _, _ := net.SplitHostPort(request.RemoteAddr.String())
|
||||||
environ = append(environ, "REMOTE_ADDR="+host)
|
environ = append(environ, "REMOTE_ADDR="+host)
|
||||||
|
|
||||||
environ = append(
|
environ = append(
|
||||||
|
@ -169,14 +156,14 @@ func prepareCGIEnv(
|
||||||
"REMOTE_HOST=",
|
"REMOTE_HOST=",
|
||||||
"REMOTE_IDENT=",
|
"REMOTE_IDENT=",
|
||||||
"SCRIPT_NAME="+scriptName,
|
"SCRIPT_NAME="+scriptName,
|
||||||
"SERVER_NAME="+req.Server.Hostname(),
|
"SERVER_NAME="+request.Server.Hostname(),
|
||||||
"SERVER_PORT="+req.Server.Port(),
|
"SERVER_PORT="+request.Server.Port(),
|
||||||
"SERVER_PROTOCOL=GEMINI",
|
"SERVER_PROTOCOL="+request.Server.Protocol(),
|
||||||
"SERVER_SOFTWARE=GUS",
|
"SERVER_SOFTWARE=GUS",
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(req.TLSState.PeerCertificates) > 0 {
|
if request.TLSState != nil && len(request.TLSState.PeerCertificates) > 0 {
|
||||||
cert := req.TLSState.PeerCertificates[0]
|
cert := request.TLSState.PeerCertificates[0]
|
||||||
environ = append(
|
environ = append(
|
||||||
environ,
|
environ,
|
||||||
"TLS_CLIENT_HASH="+fingerprint(cert.Raw),
|
"TLS_CLIENT_HASH="+fingerprint(cert.Raw),
|
||||||
|
|
|
@ -21,8 +21,8 @@ func TestCGIDirectory(t *testing.T) {
|
||||||
tlsconf, err := gemini.FileTLS("testdata/server.crt", "testdata/server.key")
|
tlsconf, err := gemini.FileTLS("testdata/server.crt", "testdata/server.key")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
handler := cgi.CGIDirectory("/cgi-bin", "./testdata")
|
handler := cgi.GeminiCGIDirectory("/cgi-bin", "./testdata")
|
||||||
server, err := gemini.NewServer(context.Background(), nil, tlsconf, "tcp", "127.0.0.1:0", handler)
|
server, err := gemini.NewServer(context.Background(), "localhost", "tcp", "127.0.0.1:0", handler, nil, tlsconf)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
go func() { assert.Nil(t, server.Serve()) }()
|
go func() { assert.Nil(t, server.Serve()) }()
|
||||||
|
|
|
@ -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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"tildegit.org/tjp/gus"
|
"tildegit.org/tjp/gus"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DirectoryDefault handles directory path requests by looking for specific filenames.
|
// ResolveDirectory opens the directory corresponding to a request path.
|
||||||
//
|
//
|
||||||
// If any of the supported filenames are found, the contents of the file is returned
|
// The string is the full path to the directory. If the returned ReadDirFile
|
||||||
// as the gemini response.
|
// is not nil, it will be open and must be closed by the caller.
|
||||||
//
|
func ResolveDirectory(
|
||||||
// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
|
request *gus.Request,
|
||||||
//
|
fileSystem fs.FS,
|
||||||
// When it encounters a directory path which doesn't end in a trailing slash (/) it
|
) (string, fs.ReadDirFile, error) {
|
||||||
// redirects to a URL with the trailing slash appended. This is necessary for relative
|
path := strings.Trim(request.Path, "/")
|
||||||
// links into the directory's contents to function.
|
if path == "" {
|
||||||
//
|
path = "."
|
||||||
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
|
|
||||||
// it will also produce "51 Not Found" responses for directory paths.
|
|
||||||
func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gus.Handler {
|
|
||||||
return func(ctx context.Context, req *gus.Request) *gus.Response {
|
|
||||||
path, dirFile, resp := handleDir(req, fileSystem)
|
|
||||||
if dirFile == nil {
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
defer dirFile.Close()
|
|
||||||
|
|
||||||
entries, err := dirFile.ReadDir(0)
|
|
||||||
if err != nil {
|
|
||||||
return gemini.Failure(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, fileName := range fileNames {
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.Name() == fileName {
|
|
||||||
file, err := fileSystem.Open(path + "/" + fileName)
|
|
||||||
if err != nil {
|
|
||||||
return gemini.Failure(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return gemini.Success(mediaType(fileName), file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
file, err := fileSystem.Open(path)
|
||||||
|
if isNotFound(err) {
|
||||||
|
return "", nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
isDir, err := fileIsDir(file)
|
||||||
|
if err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isDir {
|
||||||
|
_ = file.Close()
|
||||||
|
return "", nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dirFile, ok := file.(fs.ReadDirFile)
|
||||||
|
if !ok {
|
||||||
|
_ = file.Close()
|
||||||
|
return "", nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, dirFile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DirectoryListing produces a gemtext listing of the contents of any requested directories.
|
// ResolveDirectoryDefault finds any of the provided filenames within a directory.
|
||||||
//
|
//
|
||||||
// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
|
// If it does not find any of the filenames it returns "", nil, nil.
|
||||||
//
|
func ResolveDirectoryDefault(
|
||||||
// When it encounters a directory path which doesn't end in a trailing slash (/) it
|
fileSystem fs.FS,
|
||||||
// redirects to a URL with the trailing slash appended. This is necessary for relative
|
dirPath string,
|
||||||
// links into the directory's contents to function.
|
dir fs.ReadDirFile,
|
||||||
//
|
filenames []string,
|
||||||
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
|
) (string, fs.File, error) {
|
||||||
// it will also produce "51 Not Found" responses for directory paths.
|
entries, err := dir.ReadDir(0)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(a, b int) bool {
|
||||||
|
return entries[a].Name() < entries[b].Name()
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, filename := range filenames {
|
||||||
|
idx := sort.Search(len(entries), func(i int) bool {
|
||||||
|
return entries[i].Name() <= filename
|
||||||
|
})
|
||||||
|
|
||||||
|
if idx < len(entries) && entries[idx].Name() == filename {
|
||||||
|
path := dirPath + "/" + filename
|
||||||
|
file, err := fileSystem.Open(path)
|
||||||
|
return path, file, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderDirectoryListing provides an io.Reader with the output of a directory listing template.
|
||||||
//
|
//
|
||||||
// The template is provided the following namespace:
|
// The template is provided the following namespace:
|
||||||
// - .FullPath: the complete path to the listed directory
|
// - .FullPath: the complete path to the listed directory
|
||||||
// - .DirName: the name of the directory itself
|
// - .DirName: the name of the directory itself
|
||||||
// - .Entries: the []fs.DirEntry of the directory contents
|
// - .Entries: the []fs.DirEntry of the directory contents
|
||||||
|
// - .Hostname: the hostname of the server hosting the file
|
||||||
|
// - .Port: the port on which the server is listening
|
||||||
//
|
//
|
||||||
// The template argument may be nil, in which case a simple default template is used.
|
// Each entry in .Entries has the following fields:
|
||||||
func DirectoryListing(fileSystem fs.FS, template *template.Template) gus.Handler {
|
// - .Name the string name of the item within the directory
|
||||||
return func(ctx context.Context, req *gus.Request) *gus.Response {
|
// - .IsDir is a boolean
|
||||||
path, dirFile, resp := handleDir(req, fileSystem)
|
// - .Type is the FileMode bits
|
||||||
if dirFile == nil {
|
// - .Info is a method returning (fs.FileInfo, error)
|
||||||
return resp
|
func RenderDirectoryListing(
|
||||||
}
|
path string,
|
||||||
defer dirFile.Close()
|
dir fs.ReadDirFile,
|
||||||
|
template *template.Template,
|
||||||
|
server gus.Server,
|
||||||
|
) (io.Reader, error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
if template == nil {
|
environ, err := dirlistNamespace(path, dir, server)
|
||||||
template = defaultDirListTemplate
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
|
|
||||||
environ, err := dirlistNamespace(path, dirFile)
|
|
||||||
if err != nil {
|
|
||||||
return gemini.Failure(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := template.Execute(buf, environ); err != nil {
|
|
||||||
gemini.Failure(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return gemini.Success("text/gemini", buf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := template.Execute(buf, environ); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultDirListTemplate = template.Must(template.New("directory_listing").Parse(`
|
func dirlistNamespace(path string, dirFile fs.ReadDirFile, server gus.Server) (map[string]any, error) {
|
||||||
# {{ .DirName }}
|
|
||||||
{{ range .Entries }}
|
|
||||||
=> {{ .Name }}{{ if .IsDir }}/{{ end -}}
|
|
||||||
{{ end }}
|
|
||||||
=> ../
|
|
||||||
`[1:]))
|
|
||||||
|
|
||||||
func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, error) {
|
|
||||||
entries, err := dirFile.ReadDir(0)
|
entries, err := dirFile.ReadDir(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -124,52 +135,20 @@ func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, erro
|
||||||
dirname = path[strings.LastIndex(path, "/")+1:]
|
dirname = path[strings.LastIndex(path, "/")+1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hostname := "none"
|
||||||
|
port := "0"
|
||||||
|
if server != nil {
|
||||||
|
hostname = server.Hostname()
|
||||||
|
port = server.Port()
|
||||||
|
}
|
||||||
|
|
||||||
m := map[string]any{
|
m := map[string]any{
|
||||||
"FullPath": path,
|
"FullPath": path,
|
||||||
"DirName": dirname,
|
"DirName": dirname,
|
||||||
"Entries": entries,
|
"Entries": entries,
|
||||||
|
"Hostname": hostname,
|
||||||
|
"Port": port,
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDir(req *gus.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gus.Response) {
|
|
||||||
path := strings.Trim(req.Path, "/")
|
|
||||||
if path == "" {
|
|
||||||
path = "."
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := fileSystem.Open(path)
|
|
||||||
if isNotFound(err) {
|
|
||||||
return "", nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, gemini.Failure(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
isDir, err := fileIsDir(file)
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
return "", nil, gemini.Failure(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isDir {
|
|
||||||
file.Close()
|
|
||||||
return "", nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasSuffix(req.Path, "/") {
|
|
||||||
file.Close()
|
|
||||||
url := *req.URL
|
|
||||||
url.Path += "/"
|
|
||||||
return "", nil, gemini.Redirect(url.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
dirFile, ok := file.(fs.ReadDirFile)
|
|
||||||
if !ok {
|
|
||||||
file.Close()
|
|
||||||
return "", nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return path, dirFile, nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDirectoryDefault(t *testing.T) {
|
func TestDirectoryDefault(t *testing.T) {
|
||||||
handler := fs.DirectoryDefault(os.DirFS("testdata"), "index.gmi")
|
handler := fs.GeminiDirectoryDefault(os.DirFS("testdata"), "index.gmi")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
url string
|
url string
|
||||||
|
@ -69,7 +69,7 @@ func TestDirectoryDefault(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDirectoryListing(t *testing.T) {
|
func TestDirectoryListing(t *testing.T) {
|
||||||
handler := fs.DirectoryListing(os.DirFS("testdata"), nil)
|
handler := fs.GeminiDirectoryListing(os.DirFS("testdata"), nil)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
url string
|
url string
|
||||||
|
|
|
@ -1,37 +1,40 @@
|
||||||
package fs
|
package fs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"mime"
|
"mime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"tildegit.org/tjp/gus"
|
"tildegit.org/tjp/gus"
|
||||||
"tildegit.org/tjp/gus/gemini"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileHandler builds a handler function which serves up a file system.
|
// ResolveFile finds a file from a filesystem based on a request path.
|
||||||
func FileHandler(fileSystem fs.FS) gus.Handler {
|
//
|
||||||
return func(ctx context.Context, req *gus.Request) *gus.Response {
|
// It only returns a non-nil file if a file is found - not a directory.
|
||||||
file, err := fileSystem.Open(strings.TrimPrefix(req.Path, "/"))
|
// If there is any other sort of filesystem access error, it will be
|
||||||
if isNotFound(err) {
|
// returned.
|
||||||
return nil
|
func ResolveFile(request *gus.Request, fileSystem fs.FS) (string, fs.File, error) {
|
||||||
}
|
filepath := strings.TrimPrefix(request.Path, "/")
|
||||||
if err != nil {
|
file, err := fileSystem.Open(filepath)
|
||||||
return gemini.Failure(err)
|
if isNotFound(err) {
|
||||||
}
|
return "", nil, nil
|
||||||
|
|
||||||
isDir, err := fileIsDir(file)
|
|
||||||
if err != nil {
|
|
||||||
return gemini.Failure(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isDir {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return gemini.Success(mediaType(req.Path), file)
|
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
isDir, err := fileIsDir(file)
|
||||||
|
if err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDir {
|
||||||
|
_ = file.Close()
|
||||||
|
return "", nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath, file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaType(filePath string) string {
|
func mediaType(filePath string) string {
|
||||||
|
|
|
@ -16,7 +16,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFileHandler(t *testing.T) {
|
func TestFileHandler(t *testing.T) {
|
||||||
handler := fs.FileHandler(os.DirFS("testdata"))
|
handler := fs.GeminiFileHandler(os.DirFS("testdata"))
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
url string
|
url string
|
||||||
|
|
|
@ -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(
|
server, err := gemini.NewServer(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
nil,
|
"localhost",
|
||||||
serverTLS,
|
|
||||||
"tcp",
|
"tcp",
|
||||||
"127.0.0.1:0",
|
"127.0.0.1:0",
|
||||||
handler,
|
handler,
|
||||||
|
nil,
|
||||||
|
serverTLS,
|
||||||
)
|
)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// make use of a CGI request handler
|
// make use of a CGI request handler
|
||||||
cgiHandler := cgi.CGIDirectory("/cgi-bin", "./cgi-bin")
|
cgiHandler := cgi.GeminiCGIDirectory("/cgi-bin", "./cgi-bin")
|
||||||
|
|
||||||
_, infoLog, _, errLog := logging.DefaultLoggers()
|
_, infoLog, _, errLog := logging.DefaultLoggers()
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ func main() {
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
// run the server
|
// run the server
|
||||||
server, err := gemini.NewServer(ctx, errLog, tlsconf, "tcp4", ":1965", handler)
|
server, err := gemini.NewServer(ctx, "localhost", "tcp4", ":1965", handler, errLog, tlsconf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,7 +29,7 @@ func main() {
|
||||||
handler := logging.LogRequests(infoLog)(cowsayHandler)
|
handler := logging.LogRequests(infoLog)(cowsayHandler)
|
||||||
|
|
||||||
// run the server
|
// run the server
|
||||||
server, err := gemini.NewServer(context.Background(), errLog, tlsconf, "tcp4", ":1965", handler)
|
server, err := gemini.NewServer(context.Background(), "localhost", "tcp4", ":1965", handler, errLog, tlsconf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,11 +26,11 @@ func main() {
|
||||||
// Fallthrough tries each handler in succession until it gets something other than "51 Not Found"
|
// Fallthrough tries each handler in succession until it gets something other than "51 Not Found"
|
||||||
handler := gus.FallthroughHandler(
|
handler := gus.FallthroughHandler(
|
||||||
// first see if they're fetching a directory and we have <dir>/index.gmi
|
// first see if they're fetching a directory and we have <dir>/index.gmi
|
||||||
fs.DirectoryDefault(fileSystem, "index.gmi"),
|
fs.GeminiDirectoryDefault(fileSystem, "index.gmi"),
|
||||||
// next (still if they requested a directory) build a directory listing response
|
// next (still if they requested a directory) build a directory listing response
|
||||||
fs.DirectoryListing(fileSystem, nil),
|
fs.GeminiDirectoryListing(fileSystem, nil),
|
||||||
// finally, try to find a file at the request path and respond with that
|
// finally, try to find a file at the request path and respond with that
|
||||||
fs.FileHandler(fileSystem),
|
fs.GeminiFileHandler(fileSystem),
|
||||||
)
|
)
|
||||||
|
|
||||||
_, infoLog, _, errLog := logging.DefaultLoggers()
|
_, infoLog, _, errLog := logging.DefaultLoggers()
|
||||||
|
@ -39,7 +39,7 @@ func main() {
|
||||||
handler = logging.LogRequests(infoLog)(handler)
|
handler = logging.LogRequests(infoLog)(handler)
|
||||||
|
|
||||||
// run the server
|
// run the server
|
||||||
server, err := gemini.NewServer(context.Background(), errLog, tlsconf, "tcp4", ":1965", handler)
|
server, err := gemini.NewServer(context.Background(), "localhost", "tcp4", ":1965", handler, errLog, tlsconf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
handler := logging.LogRequests(infoLog)(inspectHandler)
|
||||||
|
|
||||||
// run the server
|
// run the server
|
||||||
server, err := gemini.NewServer(context.Background(), errLog, tlsconf, "tcp4", ":1965", handler)
|
server, err := gemini.NewServer(context.Background(), "localhost", "tcp4", ":1965", handler, errLog, tlsconf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"tildegit.org/tjp/gus"
|
"tildegit.org/tjp/gus"
|
||||||
)
|
)
|
||||||
|
@ -284,19 +285,17 @@ func ParseResponse(rdr io.Reader) (*gus.Response, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResponseReader interface {
|
func NewResponseReader(response *gus.Response) gus.ResponseReader {
|
||||||
io.Reader
|
return &responseReader{
|
||||||
io.WriterTo
|
Response: response,
|
||||||
io.Closer
|
once: &sync.Once{},
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewResponseReader(response *gus.Response) ResponseReader {
|
|
||||||
return &responseReader{ Response: response }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type responseReader struct {
|
type responseReader struct {
|
||||||
*gus.Response
|
*gus.Response
|
||||||
reader io.Reader
|
reader io.Reader
|
||||||
|
once *sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rdr *responseReader) Read(b []byte) (int, error) {
|
func (rdr *responseReader) Read(b []byte) (int, error) {
|
||||||
|
@ -310,16 +309,14 @@ func (rdr *responseReader) WriteTo(dst io.Writer) (int64, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rdr *responseReader) ensureReader() {
|
func (rdr *responseReader) ensureReader() {
|
||||||
if rdr.reader != nil {
|
rdr.once.Do(func() {
|
||||||
return
|
hdr := bytes.NewBuffer(rdr.headerLine())
|
||||||
}
|
if rdr.Body != nil {
|
||||||
|
rdr.reader = io.MultiReader(hdr, rdr.Body)
|
||||||
hdr := bytes.NewBuffer(rdr.headerLine())
|
} else {
|
||||||
if rdr.Body != nil {
|
rdr.reader = hdr
|
||||||
rdr.reader = io.MultiReader(hdr, rdr.Body)
|
}
|
||||||
} else {
|
})
|
||||||
rdr.reader = hdr
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rdr responseReader) headerLine() []byte {
|
func (rdr responseReader) headerLine() []byte {
|
||||||
|
|
|
@ -24,7 +24,7 @@ func TestRoundTrip(t *testing.T) {
|
||||||
return gemini.Success("text/gemini", bytes.NewBufferString("you've found my page"))
|
return gemini.Success("text/gemini", bytes.NewBufferString("you've found my page"))
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := gemini.NewServer(context.Background(), nil, tlsConf, "tcp", "127.0.0.1:0", handler)
|
server, err := gemini.NewServer(context.Background(), "localhost", "tcp", "127.0.0.1:0", handler, nil, tlsConf)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
@ -69,7 +69,7 @@ func TestTitanRequest(t *testing.T) {
|
||||||
return gemini.Success("", nil)
|
return gemini.Success("", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := gemini.NewServer(context.Background(), nil, tlsConf, "tcp", "127.0.0.1:0", handler)
|
server, err := gemini.NewServer(context.Background(), "localhost", "tcp", "127.0.0.1:0", handler, nil, tlsConf)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|
158
gemini/serve.go
158
gemini/serve.go
|
@ -5,14 +5,13 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"tildegit.org/tjp/gus"
|
"tildegit.org/tjp/gus"
|
||||||
|
"tildegit.org/tjp/gus/internal"
|
||||||
"tildegit.org/tjp/gus/logging"
|
"tildegit.org/tjp/gus/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -25,127 +24,59 @@ type titanRequestBodyKey struct{}
|
||||||
var TitanRequestBody = titanRequestBodyKey{}
|
var TitanRequestBody = titanRequestBodyKey{}
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
ctx context.Context
|
internal.Server
|
||||||
errorLog logging.Logger
|
|
||||||
network string
|
handler gus.Handler
|
||||||
address string
|
|
||||||
cancel context.CancelFunc
|
|
||||||
wg *sync.WaitGroup
|
|
||||||
listener net.Listener
|
|
||||||
handler gus.Handler
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s server) Protocol() string { return "GEMINI" }
|
||||||
|
|
||||||
// NewServer builds a gemini server.
|
// NewServer builds a gemini server.
|
||||||
func NewServer(
|
func NewServer(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
errorLog logging.Logger,
|
hostname string,
|
||||||
tlsConfig *tls.Config,
|
|
||||||
network string,
|
network string,
|
||||||
address string,
|
address string,
|
||||||
handler gus.Handler,
|
handler gus.Handler,
|
||||||
|
errorLog logging.Logger,
|
||||||
|
tlsConfig *tls.Config,
|
||||||
) (gus.Server, error) {
|
) (gus.Server, error) {
|
||||||
listener, err := net.Listen(network, address)
|
s := &server{handler: handler}
|
||||||
|
|
||||||
|
if strings.IndexByte(hostname, ':') < 0 {
|
||||||
|
hostname = net.JoinHostPort(hostname, "1965")
|
||||||
|
}
|
||||||
|
|
||||||
|
internalServer, err := internal.NewServer(ctx, hostname, network, address, errorLog, s.handleConn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.Server = internalServer
|
||||||
|
|
||||||
addr := listener.Addr()
|
s.Listener = tls.NewListener(s.Listener, tlsConfig)
|
||||||
|
|
||||||
s := &server{
|
|
||||||
ctx: ctx,
|
|
||||||
errorLog: errorLog,
|
|
||||||
network: addr.Network(),
|
|
||||||
address: addr.String(),
|
|
||||||
wg: &sync.WaitGroup{},
|
|
||||||
listener: tls.NewListener(listener, tlsConfig),
|
|
||||||
handler: handler,
|
|
||||||
}
|
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve starts the server and blocks until it is closed.
|
|
||||||
//
|
|
||||||
// This function will allocate resources which are not cleaned up until
|
|
||||||
// Close() is called.
|
|
||||||
//
|
|
||||||
// It will respect cancellation of the context the server was created with,
|
|
||||||
// but be aware that Close() must still be called in that case to avoid
|
|
||||||
// dangling goroutines.
|
|
||||||
//
|
|
||||||
// On titan protocol requests it sets a key/value pair in the context. The
|
|
||||||
// key is TitanRequestBody, and the value is a *bufio.Reader from which the
|
|
||||||
// request body can be read.
|
|
||||||
func (s *server) Serve() error {
|
|
||||||
s.wg.Add(1)
|
|
||||||
defer s.wg.Done()
|
|
||||||
|
|
||||||
s.ctx, s.cancel = context.WithCancel(s.ctx)
|
|
||||||
|
|
||||||
s.wg.Add(1)
|
|
||||||
s.propagateCancel()
|
|
||||||
|
|
||||||
for {
|
|
||||||
conn, err := s.listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
if s.Closed() {
|
|
||||||
err = nil
|
|
||||||
} else {
|
|
||||||
_ = s.errorLog.Log("msg", "accept error", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.wg.Add(1)
|
|
||||||
go s.handleConn(conn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) Close() {
|
|
||||||
s.cancel()
|
|
||||||
s.wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) Network() string {
|
|
||||||
return s.network
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) Address() string {
|
|
||||||
return s.address
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) Hostname() string {
|
|
||||||
host, _, _ := net.SplitHostPort(s.address)
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) Port() string {
|
|
||||||
_, portStr, _ := net.SplitHostPort(s.address)
|
|
||||||
return portStr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleConn(conn net.Conn) {
|
func (s *server) handleConn(conn net.Conn) {
|
||||||
defer s.wg.Done()
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
buf := bufio.NewReader(conn)
|
buf := bufio.NewReader(conn)
|
||||||
|
|
||||||
var response *gus.Response
|
var response *gus.Response
|
||||||
req, err := ParseRequest(buf)
|
request, err := ParseRequest(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response = BadRequest(err.Error())
|
response = BadRequest(err.Error())
|
||||||
} else {
|
} else {
|
||||||
req.Server = s
|
request.Server = s
|
||||||
req.RemoteAddr = conn.RemoteAddr()
|
request.RemoteAddr = conn.RemoteAddr()
|
||||||
|
|
||||||
if tlsconn, ok := conn.(*tls.Conn); ok {
|
if tlsconn, ok := conn.(*tls.Conn); ok {
|
||||||
state := tlsconn.ConnectionState()
|
state := tlsconn.ConnectionState()
|
||||||
req.TLSState = &state
|
request.TLSState = &state
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := s.ctx
|
ctx := s.Ctx
|
||||||
if req.Scheme == "titan" {
|
if request.Scheme == "titan" {
|
||||||
len, err := sizeParam(req.Path)
|
len, err := sizeParam(request.Path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
ctx = context.WithValue(
|
ctx = context.WithValue(
|
||||||
ctx,
|
ctx,
|
||||||
|
@ -155,15 +86,16 @@ func (s *server) handleConn(conn net.Conn) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
/*
|
||||||
if r := recover(); r != nil {
|
defer func() {
|
||||||
err := fmt.Errorf("%s", r)
|
if r := recover(); r != nil {
|
||||||
_ = s.errorLog.Log("msg", "panic in handler", "err", err)
|
err := fmt.Errorf("%s", r)
|
||||||
_, _ = io.Copy(conn, NewResponseReader(Failure(err)))
|
_ = s.LogError("msg", "panic in handler", "err", err)
|
||||||
}
|
_, _ = io.Copy(conn, NewResponseReader(Failure(err)))
|
||||||
}()
|
}
|
||||||
|
}()
|
||||||
response = s.handler(ctx, req)
|
*/
|
||||||
|
response = s.handler(ctx, request)
|
||||||
if response == nil {
|
if response == nil {
|
||||||
response = NotFound("Resource does not exist.")
|
response = NotFound("Resource does not exist.")
|
||||||
}
|
}
|
||||||
|
@ -173,24 +105,6 @@ func (s *server) handleConn(conn net.Conn) {
|
||||||
_, _ = io.Copy(conn, NewResponseReader(response))
|
_, _ = io.Copy(conn, NewResponseReader(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) propagateCancel() {
|
|
||||||
go func() {
|
|
||||||
defer s.wg.Done()
|
|
||||||
|
|
||||||
<-s.ctx.Done()
|
|
||||||
_ = s.listener.Close()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) Closed() bool {
|
|
||||||
select {
|
|
||||||
case <-s.ctx.Done():
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sizeParam(path string) (int, error) {
|
func sizeParam(path string) (int, error) {
|
||||||
_, rest, found := strings.Cut(path, ";")
|
_, rest, found := strings.Cut(path, ";")
|
||||||
if !found {
|
if !found {
|
||||||
|
|
|
@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -38,7 +37,7 @@ func (lr *loggedResponseBody) log() {
|
||||||
end := time.Now()
|
end := time.Now()
|
||||||
_ = lr.logger.Log(
|
_ = lr.logger.Log(
|
||||||
"msg", "request",
|
"msg", "request",
|
||||||
"ts", end,
|
"ts", end.UTC(),
|
||||||
"dur", end.Sub(lr.start),
|
"dur", end.Sub(lr.start),
|
||||||
"url", lr.request.URL,
|
"url", lr.request.URL,
|
||||||
"status", lr.response.Status,
|
"status", lr.response.Status,
|
||||||
|
@ -74,7 +73,6 @@ type loggedWriteToResponseBody struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lwtr loggedWriteToResponseBody) WriteTo(dst io.Writer) (int64, error) {
|
func (lwtr loggedWriteToResponseBody) WriteTo(dst io.Writer) (int64, error) {
|
||||||
fmt.Println("lwtrb.WriteTo()")
|
|
||||||
n, err := lwtr.body.(io.WriterTo).WriteTo(dst)
|
n, err := lwtr.body.(io.WriterTo).WriteTo(dst)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
lwtr.written += int(n)
|
lwtr.written += int(n)
|
||||||
|
|
|
@ -26,3 +26,10 @@ func (response *Response) Close() error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResponseReader is an object which can serialize a response to a protocol.
|
||||||
|
type ResponseReader interface {
|
||||||
|
io.Reader
|
||||||
|
io.WriterTo
|
||||||
|
io.Closer
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,9 @@ type Server interface {
|
||||||
// hasn't yet completed.
|
// hasn't yet completed.
|
||||||
Closed() bool
|
Closed() bool
|
||||||
|
|
||||||
|
// Protocol returns the protocol being served by the server.
|
||||||
|
Protocol() string
|
||||||
|
|
||||||
// Network returns the network type on which the server is running.
|
// Network returns the network type on which the server is running.
|
||||||
Network() string
|
Network() string
|
||||||
|
|
||||||
|
@ -33,4 +36,7 @@ type Server interface {
|
||||||
// It will return the empty string if the network type does not
|
// It will return the empty string if the network type does not
|
||||||
// have ports (unix sockets, for example).
|
// have ports (unix sockets, for example).
|
||||||
Port() string
|
Port() string
|
||||||
|
|
||||||
|
// LogError sends a log message to the server's error log.
|
||||||
|
LogError(keyvals ...any) error
|
||||||
}
|
}
|
||||||
|
|
Reference in New Issue