diff options
| author | tjpcc <tjp@ctrl-c.club> | 2023-01-17 15:59:29 -0700 | 
|---|---|---|
| committer | tjpcc <tjp@ctrl-c.club> | 2023-01-17 15:59:29 -0700 | 
| commit | 2ef530daa47b301a40c1ee93cd43b8f36fc68c0b (patch) | |
| tree | b9753719f5f0e5312bb5008d40f40247ce14e15a | |
| parent | 30e21f8513d49661cb6e1583d301e34e898d48a9 (diff) | |
pull request, response, handlers out of the gemini package
| -rw-r--r-- | contrib/cgi/cgi.go | 15 | ||||
| -rw-r--r-- | contrib/fs/dir.go | 23 | ||||
| -rw-r--r-- | contrib/fs/file.go | 9 | ||||
| -rw-r--r-- | contrib/log/log.go | 8 | ||||
| -rw-r--r-- | examples/cowsay/main.go | 3 | ||||
| -rw-r--r-- | examples/fetch/main.go | 6 | ||||
| -rw-r--r-- | examples/fileserver/main.go | 3 | ||||
| -rw-r--r-- | examples/inspectls/main.go | 3 | ||||
| -rw-r--r-- | gemini/client.go | 4 | ||||
| -rw-r--r-- | gemini/handler.go | 54 | ||||
| -rw-r--r-- | gemini/request.go | 41 | ||||
| -rw-r--r-- | gemini/request_test.go | 17 | ||||
| -rw-r--r-- | gemini/response.go | 204 | ||||
| -rw-r--r-- | gemini/response_test.go | 23 | ||||
| -rw-r--r-- | gemini/roundtrip_test.go | 5 | ||||
| -rw-r--r-- | gemini/serve.go | 70 | ||||
| -rw-r--r-- | handler.go | 52 | ||||
| -rw-r--r-- | handler_test.go (renamed from gemini/handler_test.go) | 37 | ||||
| -rw-r--r-- | request.go | 43 | ||||
| -rw-r--r-- | request_test.go | 24 | ||||
| -rw-r--r-- | response.go | 28 | ||||
| -rw-r--r-- | server.go | 36 | 
22 files changed, 391 insertions, 317 deletions
diff --git a/contrib/cgi/cgi.go b/contrib/cgi/cgi.go index e43f1ef..7f88e57 100644 --- a/contrib/cgi/cgi.go +++ b/contrib/cgi/cgi.go @@ -13,6 +13,7 @@ import (  	"os/exec"  	"strings" +	"tildegit.org/tjp/gus"  	"tildegit.org/tjp/gus/gemini"  ) @@ -22,12 +23,12 @@ import (  // 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) gemini.Handler { +func CGIDirectory(pathRoot, fsRoot string) gus.Handler {  	fsRoot = strings.TrimRight(fsRoot, "/") -	return func(ctx context.Context, req *gemini.Request) *gemini.Response { +	return func(ctx context.Context, req *gus.Request) *gus.Response {  		if !strings.HasPrefix(req.Path, pathRoot) { -			return gemini.NotFound("Resource does not exist.") +			return nil  		}  		path := req.Path[len(pathRoot):] @@ -53,7 +54,7 @@ func CGIDirectory(pathRoot, fsRoot string) gemini.Handler {  			}  		} -		return gemini.NotFound("Resource does not exist.") +		return nil  	}  } @@ -97,10 +98,10 @@ func isNotExistError(err error) bool {  // RunCGI runs a specific program as a CGI script.  func RunCGI(  	ctx context.Context, -	req *gemini.Request, +	req *gus.Request,  	executable string,  	pathInfo string, -) *gemini.Response { +) *gus.Response {  	pathSegments := strings.Split(executable, "/")  	dirPath := "." @@ -139,7 +140,7 @@ func RunCGI(  func prepareCGIEnv(  	ctx context.Context, -	req *gemini.Request, +	req *gus.Request,  	scriptName string,  	pathInfo string,  ) []string { diff --git a/contrib/fs/dir.go b/contrib/fs/dir.go index b219e22..6292f67 100644 --- a/contrib/fs/dir.go +++ b/contrib/fs/dir.go @@ -8,6 +8,7 @@ import (  	"strings"  	"text/template" +	"tildegit.org/tjp/gus"  	"tildegit.org/tjp/gus/gemini"  ) @@ -24,10 +25,10 @@ import (  //  // 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) gemini.Handler { -	return func(ctx context.Context, req *gemini.Request) *gemini.Response { +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 resp != nil { +		if dirFile == nil {  			return resp  		}  		defer dirFile.Close() @@ -50,7 +51,7 @@ func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gemini.Handler {  			}  		} -		return gemini.NotFound("Resource does not exist.") +		return nil  	}  } @@ -69,10 +70,10 @@ func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gemini.Handler {  //   - .FullPath: the complete path to the listed directory  //   - .DirName: the name of the directory itself  //   - .Entries: the []fs.DirEntry of the directory contents -//  +//  // The template argument may be nil, in which case a simple default template is used. -func DirectoryListing(fileSystem fs.FS, template *template.Template) gemini.Handler { -	return func(ctx context.Context, req *gemini.Request) *gemini.Response { +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 resp != nil {  			return resp @@ -132,7 +133,7 @@ func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, erro  	return m, nil  } -func handleDir(req *gemini.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gemini.Response) { +func handleDir(req *gus.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gus.Response) {  	path := strings.Trim(req.Path, "/")  	if path == "" {  		path = "." @@ -140,7 +141,7 @@ func handleDir(req *gemini.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *  	file, err := fileSystem.Open(path)  	if isNotFound(err) { -		return "", nil, gemini.NotFound("Resource does not exist.") +		return "", nil, nil  	}  	if err != nil {  		return "", nil, gemini.Failure(err) @@ -154,7 +155,7 @@ func handleDir(req *gemini.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *  	if !isDir {  		file.Close() -		return "", nil, gemini.NotFound("Resource does not exist.") +		return "", nil, nil  	}  	if !strings.HasSuffix(req.Path, "/") { @@ -167,7 +168,7 @@ func handleDir(req *gemini.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *  	dirFile, ok := file.(fs.ReadDirFile)  	if !ok {  		file.Close() -		return "", nil, gemini.NotFound("Resource does not exist.") +		return "", nil, nil  	}  	return path, dirFile, nil diff --git a/contrib/fs/file.go b/contrib/fs/file.go index cdcd1a9..8cb1aeb 100644 --- a/contrib/fs/file.go +++ b/contrib/fs/file.go @@ -6,15 +6,16 @@ import (  	"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) gemini.Handler { -	return func(ctx context.Context, req *gemini.Request) *gemini.Response { +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 gemini.NotFound("Resource does not exist.") +			return nil  		}  		if err != nil {  			return gemini.Failure(err) @@ -26,7 +27,7 @@ func FileHandler(fileSystem fs.FS) gemini.Handler {  		}  		if isDir { -			return gemini.NotFound("Resource does not exist.") +			return nil  		}  		return gemini.Success(mediaType(req.Path), file) diff --git a/contrib/log/log.go b/contrib/log/log.go index 2ccd3bc..0060f4e 100644 --- a/contrib/log/log.go +++ b/contrib/log/log.go @@ -7,16 +7,16 @@ import (  	kitlog "github.com/go-kit/log" -	"tildegit.org/tjp/gus/gemini" +	"tildegit.org/tjp/gus"  ) -func Requests(out io.Writer, logger kitlog.Logger) gemini.Middleware { +func Requests(out io.Writer, logger kitlog.Logger) gus.Middleware {  	if logger == nil {  		logger = kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(out))  	} -	return func(next gemini.Handler) gemini.Handler { -		return func(ctx context.Context, r *gemini.Request) (resp *gemini.Response) { +	return func(next gus.Handler) gus.Handler { +		return func(ctx context.Context, r *gus.Request) (resp *gus.Response) {  			start := time.Now()  			defer func() {  				end := time.Now() diff --git a/examples/cowsay/main.go b/examples/cowsay/main.go index be81f50..fc5e89f 100644 --- a/examples/cowsay/main.go +++ b/examples/cowsay/main.go @@ -8,6 +8,7 @@ import (  	"os"  	"os/exec" +	"tildegit.org/tjp/gus"  	guslog "tildegit.org/tjp/gus/contrib/log"  	"tildegit.org/tjp/gus/gemini"  ) @@ -33,7 +34,7 @@ func main() {  	server.Serve()  } -func cowsayHandler(ctx context.Context, req *gemini.Request) *gemini.Response { +func cowsayHandler(ctx context.Context, req *gus.Request) *gus.Response {  	// prompt for a query if there is none already  	if req.RawQuery == "" {  		return gemini.Input("enter a phrase") diff --git a/examples/fetch/main.go b/examples/fetch/main.go index adfece4..109a042 100644 --- a/examples/fetch/main.go +++ b/examples/fetch/main.go @@ -8,6 +8,7 @@ import (  	"os"  	"strings" +	"tildegit.org/tjp/gus"  	"tildegit.org/tjp/gus/gemini"  ) @@ -29,20 +30,21 @@ func main() {  	}  	// parse the URL and build the request -	request := &gemini.Request{URL: buildURL()} +	request := &gus.Request{URL: buildURL()}  	// fetch the response  	response, err := client.RoundTrip(request)  	if err != nil {  		log.Fatal(err)  	} +	defer response.Close()  	if response.Status != gemini.StatusSuccess {  		log.Fatalf("%d %s\n", response.Status, response.Meta)  	}  	//io.Copy(os.Stdout, response) -	buf, err := io.ReadAll(response) +	buf, err := io.ReadAll(gemini.NewResponseReader(response))  	fmt.Printf("response: %s\n", buf)  } diff --git a/examples/fileserver/main.go b/examples/fileserver/main.go index b38ae76..35c8708 100644 --- a/examples/fileserver/main.go +++ b/examples/fileserver/main.go @@ -5,6 +5,7 @@ import (  	"log"  	"os" +	"tildegit.org/tjp/gus"  	"tildegit.org/tjp/gus/contrib/fs"  	guslog "tildegit.org/tjp/gus/contrib/log"  	"tildegit.org/tjp/gus/gemini" @@ -23,7 +24,7 @@ func main() {  	// build the request handler  	fileSystem := os.DirFS(".")  	// Fallthrough tries each handler in succession until it gets something other than "51 Not Found" -	handler := gemini.FallthroughHandler( +	handler := gus.FallthroughHandler(  		// first see if they're fetching a directory and we have <dir>/index.gmi  		fs.DirectoryDefault(fileSystem, "index.gmi"),  		// next (still if they requested a directory) build a directory listing response diff --git a/examples/inspectls/main.go b/examples/inspectls/main.go index d690af1..65c5229 100644 --- a/examples/inspectls/main.go +++ b/examples/inspectls/main.go @@ -12,6 +12,7 @@ import (  	"os"  	"strings" +	"tildegit.org/tjp/gus"  	guslog "tildegit.org/tjp/gus/contrib/log"  	"tildegit.org/tjp/gus/gemini"  ) @@ -51,7 +52,7 @@ func envConfig() (string, string) {  	return certfile, keyfile  } -func inspectHandler(ctx context.Context, req *gemini.Request) *gemini.Response { +func inspectHandler(ctx context.Context, req *gus.Request) *gus.Response {  	// build and return a ```-wrapped description of the connection TLS state  	body := "```\n" + displayTLSState(req.TLSState) + "\n```"  	return gemini.Success("text/gemini", bytes.NewBufferString(body)) diff --git a/gemini/client.go b/gemini/client.go index 0e8dd07..4f99078 100644 --- a/gemini/client.go +++ b/gemini/client.go @@ -6,6 +6,8 @@ import (  	"errors"  	"io"  	"net" + +	"tildegit.org/tjp/gus"  )  // Client is used for sending gemini requests and parsing gemini responses. @@ -31,7 +33,7 @@ func NewClient(tlsConf *tls.Config) Client {  //  // This method will not automatically follow redirects or cache permanent failures or  // redirects. -func (client Client) RoundTrip(request *Request) (*Response, error) { +func (client Client) RoundTrip(request *gus.Request) (*gus.Response, error) {  	if request.Scheme != "gemini" && request.Scheme != "" {  		return nil, errors.New("non-gemini protocols not supported")  	} diff --git a/gemini/handler.go b/gemini/handler.go deleted file mode 100644 index 0f48e62..0000000 --- a/gemini/handler.go +++ /dev/null @@ -1,54 +0,0 @@ -package gemini - -import "context" - -// Handler is a function which can turn a gemini request into a gemini response. -// -// A Handler MUST NOT return a nil response. Errors should be returned in the form -// of error responses (4x, 5x, 6x response status). If the Handler should not be -// responsible for the requested resource it can return a 51 response. -type Handler func(context.Context, *Request) *Response - -// Middleware is a handle decorator. -// -// It returns a handler which may call the passed-in handler or not, or may -// transform the request or response in some way. -type Middleware func(Handler) Handler - -// FallthroughHandler builds a handler which tries multiple child handlers. -// -// The returned handler will invoke each of the passed child handlers in order, -// stopping when it receives a response with status other than 51. -func FallthroughHandler(handlers ...Handler) Handler { -	return func(ctx context.Context, req *Request) *Response { -		for _, handler := range handlers { -			response := handler(ctx, req) -			if response.Status != StatusNotFound { -				return response -			} -		} - -		return NotFound("Resource does not exist.") -	} -} - -// Filter wraps a handler with a predicate which determines whether to run the handler. -// -// When the predicate function returns false, the Filter returns the provided failure -// response. The failure argument may be nil, in which case a "51 Resource does not exist." -// response will be used. -func Filter( -	predicate func(context.Context, *Request) bool, -	handler Handler, -	failure *Response, -) Handler { -	if failure == nil { -		failure = NotFound("Resource does not exist.") -	} -	return func(ctx context.Context, req *Request) *Response { -		if !predicate(ctx, req) { -			return failure -		} -		return handler(ctx, req) -	} -} diff --git a/gemini/request.go b/gemini/request.go index 933281b..ced7d0b 100644 --- a/gemini/request.go +++ b/gemini/request.go @@ -2,43 +2,18 @@ package gemini  import (  	"bufio" -	"crypto/tls"  	"errors"  	"io" -	"net"  	"net/url" + +	"tildegit.org/tjp/gus"  )  // InvalidRequestLineEnding indicates that a gemini request didn't end with "\r\n".  var InvalidRequestLineEnding = errors.New("invalid request line ending") -// Request represents a request over the gemini protocol. -type Request struct { -	// URL is the specific URL being fetched by the request. -	*url.URL - -	// Server is the server which received the request. -	// -	// This is only populated in gemini servers. -	// It is unused on the client end. -	Server     *Server - -	// RemoteAddr is the address of the other side of the connection. -	// -	// This will be the server address for clients, or the connecting -	// client's address in servers. -	// -	// Be aware though that proxies (and reverse proxies) can confuse this. -	RemoteAddr net.Addr - -	// TLSState contains information about the TLS encryption over the connection. -	// -	// This includes peer certificates and version information. -	TLSState   *tls.ConnectionState -} -  // ParseRequest parses a single gemini request from a reader. -func ParseRequest(rdr io.Reader) (*Request, error) { +func ParseRequest(rdr io.Reader) (*gus.Request, error) {  	line, err := bufio.NewReader(rdr).ReadString('\n')  	if err != io.EOF && err != nil {  		return nil, err @@ -57,13 +32,5 @@ func ParseRequest(rdr io.Reader) (*Request, error) {  		u.Scheme = "gemini"  	} -	return &Request{URL: u}, nil -} - -// UnescapedQuery performs %XX unescaping on the URL query segment. -// -// Like URL.Query(), it silently drops malformed %-encoded sequences. -func (req Request) UnescapedQuery() string { -	unescaped, _ := url.QueryUnescape(req.RawQuery) -	return unescaped +	return &gus.Request{URL: u}, nil  } diff --git a/gemini/request_test.go b/gemini/request_test.go index c23d54b..1da24f7 100644 --- a/gemini/request_test.go +++ b/gemini/request_test.go @@ -3,7 +3,6 @@ package gemini_test  import (  	"bytes"  	"testing" -	"net/url"  	"tildegit.org/tjp/gus/gemini"  ) @@ -85,19 +84,3 @@ func TestParseRequest(t *testing.T) {  		})  	}  } - -func TestUnescapedQuery(t *testing.T) { -	table := []string{ -		"foo bar", -	} - -	for _, test := range table { -		t.Run(test, func(t *testing.T) { -			u, _ := url.Parse("gemini://domain.com/path?" + url.QueryEscape(test)) -			result := gemini.Request{ URL: u }.UnescapedQuery() -			if result != test { -				t.Errorf("expected %q, got %q", test, result) -			} -		}) -	} -} diff --git a/gemini/response.go b/gemini/response.go index 5b5ced4..0452462 100644 --- a/gemini/response.go +++ b/gemini/response.go @@ -6,65 +6,68 @@ import (  	"errors"  	"io"  	"strconv" + +	"tildegit.org/tjp/gus"  ) -// StatusCategory represents the various types of responses. -type StatusCategory int +// ResponseCategory represents the various types of gemini responses. +type ResponseCategory int  const ( -	// StatusCategoryInput is for responses which request additional input. +	// ResponseCategoryInput is for responses which request additional input.  	//  	// The META line will be the prompt to display to the user. -	StatusCategoryInput StatusCategory = iota*10 + 10 -	// StatusCategorySuccess is for successful responses. +	ResponseCategoryInput ResponseCategory = iota*10 + 10 +	// ResponseCategorySuccess is for successful responses.  	//  	// The META line will be the resource's mime type.  	// This is the only response status which indicates the presence of a response body,  	// and it will contain the resource itself. -	StatusCategorySuccess -	// StatusCategoryRedirect is for responses which direct the client to an alternative URL. +	ResponseCategorySuccess +	// ResponseCategoryRedirect is for responses which direct the client to an alternative URL.  	//  	// The META line will contain the new URL the client should try. -	StatusCategoryRedirect -	// StatusCategoryTemporaryFailure is for responses which indicate a transient server-side failure. +	ResponseCategoryRedirect +	// ResponseCategoryTemporaryFailure is for responses which indicate a transient server-side failure.  	//  	// The META line may contain a line with more information about the error. -	StatusCategoryTemporaryFailure -	// StatusCategoryPermanentFailure is for permanent failure responses. +	ResponseCategoryTemporaryFailure +	// ResponseCategoryPermanentFailure is for permanent failure responses.  	//  	// The META line may contain a line with more information about the error. -	StatusCategoryPermanentFailure -	// StatusCategoryCertificateRequired indicates client certificate related issues. +	ResponseCategoryPermanentFailure +	// ResponseCategoryCertificateRequired indicates client certificate related issues.  	//  	// The META line may contain a line with more information about the error. -	StatusCategoryCertificateRequired +	ResponseCategoryCertificateRequired  ) -// Status is the integer status code of a gemini response. -type Status int +func ResponseCategoryForStatus(status gus.Status) ResponseCategory { +	return ResponseCategory(status / 10) +}  const (  	// StatusInput indicates a required query parameter at the requested URL. -	StatusInput Status = Status(StatusCategoryInput) + iota +	StatusInput gus.Status = gus.Status(ResponseCategoryInput) + iota  	// StatusSensitiveInput indicates a sensitive query parameter is required.  	StatusSensitiveInput  )  const (  	// StatusSuccess is a successful response. -	StatusSuccess = Status(StatusCategorySuccess) + iota +	StatusSuccess = gus.Status(ResponseCategorySuccess) + iota  )  const (  	// StatusTemporaryRedirect indicates a temporary redirect to another URL. -	StatusTemporaryRedirect = Status(StatusCategoryRedirect) + iota +	StatusTemporaryRedirect = gus.Status(ResponseCategoryRedirect) + iota  	// StatusPermanentRedirect indicates that the resource should always be requested at the new URL.  	StatusPermanentRedirect  )  const (  	// StatusTemporaryFailure indicates that the request failed and there is no response body. -	StatusTemporaryFailure = Status(StatusCategoryTemporaryFailure) + iota +	StatusTemporaryFailure = gus.Status(ResponseCategoryTemporaryFailure) + iota  	// StatusServerUnavailable occurs when the server is unavailable due to overload or maintenance.  	StatusServerUnavailable  	// StatusCGIError is the result of a failure of a CGI script. @@ -80,7 +83,7 @@ const (  const (  	// StatusPermanentFailure is a server failure which should be expected to continue indefinitely. -	StatusPermanentFailure = Status(StatusCategoryPermanentFailure) + iota +	StatusPermanentFailure = gus.Status(ResponseCategoryPermanentFailure) + iota  	// StatusNotFound means the resource doesn't exist but it may in the future.  	StatusNotFound  	// StatusGone occurs when a resource will not be available any longer. @@ -88,58 +91,37 @@ const (  	// StatusProxyRequestRefused means the server is unwilling to act as a proxy for the resource.  	StatusProxyRequestRefused  	// StatusBadRequest indicates that the request was malformed somehow. -	StatusBadRequest = Status(StatusCategoryPermanentFailure) + 9 +	StatusBadRequest = gus.Status(ResponseCategoryPermanentFailure) + 9  )  const (  	// StatusClientCertificateRequired is returned when a certificate was required but not provided. -	StatusClientCertificateRequired = Status(StatusCategoryCertificateRequired) + iota +	StatusClientCertificateRequired = gus.Status(ResponseCategoryCertificateRequired) + iota  	// StatusCertificateNotAuthorized means the certificate doesn't grant access to the requested resource.  	StatusCertificateNotAuthorized  	// StatusCertificateNotValid means the provided client certificate is invalid.  	StatusCertificateNotValid  ) -// StatusCategory returns the category a specific status belongs to. -func (s Status) Category() StatusCategory { -	return StatusCategory(s / 10) -} - -// Response contains everything in a gemini protocol response. -type Response struct { -	// Status is the status code of the response. -	Status Status - -	// Meta is the status-specific line of additional information. -	Meta string - -	// Body is the response body, if any. -	// -	// It is not guaranteed to be readable more than once. -	Body io.Reader - -	reader io.Reader -} -  // Input builds an input-prompting response. -func Input(prompt string) *Response { -	return &Response{ +func Input(prompt string) *gus.Response { +	return &gus.Response{  		Status: StatusInput,  		Meta:   prompt,  	}  }  // SensitiveInput builds a password-prompting response. -func SensitiveInput(prompt string) *Response { -	return &Response{ +func SensitiveInput(prompt string) *gus.Response { +	return &gus.Response{  		Status: StatusSensitiveInput,  		Meta:   prompt,  	}  }  // Success builds a success response with resource body. -func Success(mediatype string, body io.Reader) *Response { -	return &Response{ +func Success(mediatype string, body io.Reader) *gus.Response { +	return &gus.Response{  		Status: StatusSuccess,  		Meta:   mediatype,  		Body:   body, @@ -147,120 +129,120 @@ func Success(mediatype string, body io.Reader) *Response {  }  // Redirect builds a redirect response. -func Redirect(url string) *Response { -	return &Response{ +func Redirect(url string) *gus.Response { +	return &gus.Response{  		Status: StatusTemporaryRedirect,  		Meta:   url,  	}  }  // PermanentRedirect builds a response with a permanent redirect. -func PermanentRedirect(url string) *Response { -	return &Response{ +func PermanentRedirect(url string) *gus.Response { +	return &gus.Response{  		Status: StatusPermanentRedirect,  		Meta:   url,  	}  }  // Failure builds a temporary failure response from an error. -func Failure(err error) *Response { -	return &Response{ +func Failure(err error) *gus.Response { +	return &gus.Response{  		Status: StatusTemporaryFailure,  		Meta:   err.Error(),  	}  }  // Unavailable build a "server unavailable" response. -func Unavailable(msg string) *Response { -	return &Response{ +func Unavailable(msg string) *gus.Response { +	return &gus.Response{  		Status: StatusServerUnavailable,  		Meta:   msg,  	}  }  // CGIError builds a "cgi error" response. -func CGIError(err string) *Response { -	return &Response{ +func CGIError(err string) *gus.Response { +	return &gus.Response{  		Status: StatusCGIError,  		Meta:   err,  	}  }  // ProxyError builds a proxy error response. -func ProxyError(msg string) *Response { -	return &Response{ +func ProxyError(msg string) *gus.Response { +	return &gus.Response{  		Status: StatusProxyError,  		Meta:   msg,  	}  }  // SlowDown builds a "slow down" response with the number of seconds until the resource is available. -func SlowDown(seconds int) *Response { -	return &Response{ +func SlowDown(seconds int) *gus.Response { +	return &gus.Response{  		Status: StatusSlowDown,  		Meta:   strconv.Itoa(seconds),  	}  }  // PermanentFailure builds a "permanent failure" from an error. -func PermanentFailure(err error) *Response { -	return &Response{ +func PermanentFailure(err error) *gus.Response { +	return &gus.Response{  		Status: StatusPermanentFailure,  		Meta:   err.Error(),  	}  }  // NotFound builds a "resource not found" response. -func NotFound(msg string) *Response { -	return &Response{ +func NotFound(msg string) *gus.Response { +	return &gus.Response{  		Status: StatusNotFound,  		Meta:   msg,  	}  }  // Gone builds a "resource gone" response. -func Gone(msg string) *Response { -	return &Response{ +func Gone(msg string) *gus.Response { +	return &gus.Response{  		Status: StatusGone,  		Meta:   msg,  	}  }  // RefuseProxy builds a "proxy request refused" response. -func RefuseProxy(msg string) *Response { -	return &Response{ +func RefuseProxy(msg string) *gus.Response { +	return &gus.Response{  		Status: StatusProxyRequestRefused,  		Meta:   msg,  	}  }  // BadRequest builds a "bad request" response. -func BadRequest(msg string) *Response { -	return &Response{ +func BadRequest(msg string) *gus.Response { +	return &gus.Response{  		Status: StatusBadRequest,  		Meta:   msg,  	}  }  // RequireCert builds a "client certificate required" response. -func RequireCert(msg string) *Response { -	return &Response{ +func RequireCert(msg string) *gus.Response { +	return &gus.Response{  		Status: StatusClientCertificateRequired,  		Meta:   msg,  	}  }  // CertAuthFailure builds a "certificate not authorized" response. -func CertAuthFailure(msg string) *Response { -	return &Response{ +func CertAuthFailure(msg string) *gus.Response { +	return &gus.Response{  		Status: StatusCertificateNotAuthorized,  		Meta:   msg,  	}  }  // CertInvalid builds a "client certificate not valid" response. -func CertInvalid(msg string) *Response { -	return &Response{ +func CertInvalid(msg string) *gus.Response { +	return &gus.Response{  		Status: StatusCertificateNotValid,  		Meta:   msg,  	} @@ -275,7 +257,7 @@ var InvalidResponseHeaderLine = errors.New("Invalid response header line.")  // ParseResponse parses a complete gemini response from a reader.  //  // The reader must contain only one gemini response. -func ParseResponse(rdr io.Reader) (*Response, error) { +func ParseResponse(rdr io.Reader) (*gus.Response, error) {  	bufrdr := bufio.NewReader(rdr)  	hdrLine, err := bufrdr.ReadBytes('\n') @@ -295,53 +277,57 @@ func ParseResponse(rdr io.Reader) (*Response, error) {  		return nil, InvalidResponseHeaderLine  	} -	return &Response{ -		Status: Status(status), +	return &gus.Response{ +		Status: gus.Status(status),  		Meta:   string(hdrLine[3:]),  		Body:   bufrdr,  	}, nil  } -// Read implements io.Reader for Response. -func (r *Response) Read(b []byte) (int, error) { -	r.ensureReader() -	return r.reader.Read(b) +type ResponseReader interface { +	io.Reader +	io.WriterTo +	io.Closer  } -// WriteTo implements io.WriterTo for Response. -func (r *Response) WriteTo(dst io.Writer) (int64, error) { -	r.ensureReader() -	return r.reader.(io.WriterTo).WriteTo(dst) +func NewResponseReader(response *gus.Response) ResponseReader { +	return &responseReader{ Response: response }  } -// Close implements io.Closer and ensures the body gets closed. -func (r *Response) Close() error { -	if r != nil { -		if cl, ok := r.Body.(io.Closer); ok { -			return cl.Close() -		} -	} -	return nil +type responseReader struct { +	*gus.Response +	reader io.Reader +} + +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 (r *Response) ensureReader() { -	if r.reader != nil { +func (rdr *responseReader) ensureReader() { +	if rdr.reader != nil {  		return  	} -	hdr := bytes.NewBuffer(r.headerLine()) -	if r.Body != nil { -		r.reader = io.MultiReader(hdr, r.Body) +	hdr := bytes.NewBuffer(rdr.headerLine()) +	if rdr.Body != nil { +		rdr.reader = io.MultiReader(hdr, rdr.Body)  	} else { -		r.reader = hdr +		rdr.reader = hdr  	}  } -func (r Response) headerLine() []byte { -	buf := make([]byte, len(r.Meta)+5) -	_ = strconv.AppendInt(buf[:0], int64(r.Status), 10) +func (rdr responseReader) headerLine() []byte { +	meta := rdr.Meta.(string) +	buf := make([]byte, len(meta)+5) +	_ = strconv.AppendInt(buf[:0], int64(rdr.Status), 10)  	buf[2] = ' ' -	copy(buf[3:], r.Meta) +	copy(buf[3:], meta)  	buf[len(buf)-2] = '\r'  	buf[len(buf)-1] = '\n'  	return buf diff --git a/gemini/response_test.go b/gemini/response_test.go index 616fac4..9287d71 100644 --- a/gemini/response_test.go +++ b/gemini/response_test.go @@ -6,14 +6,15 @@ import (  	"io"  	"testing" +	"tildegit.org/tjp/gus"  	"tildegit.org/tjp/gus/gemini"  )  func TestBuildResponses(t *testing.T) {  	table := []struct {  		name     string -		response *gemini.Response -		status   gemini.Status +		response *gus.Response +		status   gus.Status  		meta     string  		body     string  	}{ @@ -137,7 +138,7 @@ func TestBuildResponses(t *testing.T) {  				t.Errorf("expected meta %q, got %q", test.meta, test.response.Meta)  			} -			responseBytes, err := io.ReadAll(test.response) +			responseBytes, err := io.ReadAll(gemini.NewResponseReader(test.response))  			if err != nil {  				t.Fatalf("error reading response body: %q", err.Error())  			} @@ -153,7 +154,7 @@ func TestBuildResponses(t *testing.T) {  func TestParseResponses(t *testing.T) {  	table := []struct {  		input  string -		status gemini.Status +		status gus.Status  		meta   string  		body   string  		err    error @@ -232,7 +233,7 @@ func TestParseResponses(t *testing.T) {  func TestResponseClose(t *testing.T) {  	body := &rdCloser{Buffer: bytes.NewBufferString("the body here")} -	resp := &gemini.Response{ +	resp := &gus.Response{  		Status: gemini.StatusSuccess,  		Meta:   "text/gemini",  		Body:   body, @@ -246,7 +247,7 @@ func TestResponseClose(t *testing.T) {  		t.Error("response body was not closed by response.Close()")  	} -	resp = &gemini.Response{ +	resp = &gus.Response{  		Status: gemini.StatusInput,  		Meta:   "give me more",  	} @@ -269,8 +270,8 @@ func (rc *rdCloser) Close() error {  func TestResponseWriteTo(t *testing.T) {  	// invariant under test: WriteTo() sends the same bytes as Read() -	clone := func(resp *gemini.Response) *gemini.Response { -		other := &gemini.Response{ +	clone := func(resp *gus.Response) *gus.Response { +		other := &gus.Response{  			Status: resp.Status,  			Meta:   resp.Meta,  		} @@ -296,7 +297,7 @@ func TestResponseWriteTo(t *testing.T) {  	table := []struct {  		name     string -		response *gemini.Response +		response *gus.Response  	}{  		{  			name: "simple success", @@ -316,13 +317,13 @@ func TestResponseWriteTo(t *testing.T) {  			r1 := test.response  			r2 := clone(test.response) -			rdbuf, err := io.ReadAll(r1) +			rdbuf, err := io.ReadAll(gemini.NewResponseReader(r1))  			if err != nil {  				t.Fatalf("response.Read(): %s", err.Error())  			}  			wtbuf := &bytes.Buffer{} -			if _, err := r2.WriteTo(wtbuf); err != nil { +			if _, err := gemini.NewResponseReader(r2).WriteTo(wtbuf); err != nil {  				t.Fatalf("response.WriteTo(): %s", err.Error())  			} diff --git a/gemini/roundtrip_test.go b/gemini/roundtrip_test.go index 5dd61f1..326ffbc 100644 --- a/gemini/roundtrip_test.go +++ b/gemini/roundtrip_test.go @@ -9,6 +9,7 @@ import (  	"net/url"  	"testing" +	"tildegit.org/tjp/gus"  	"tildegit.org/tjp/gus/gemini"  ) @@ -18,7 +19,7 @@ func TestRoundTrip(t *testing.T) {  		t.Fatalf("FileTLS(): %s", err.Error())  	} -	handler := func(ctx context.Context, req *gemini.Request) *gemini.Response { +	handler := func(ctx context.Context, req *gus.Request) *gus.Response {  		return gemini.Success("text/gemini", bytes.NewBufferString("you've found my page"))  	} @@ -36,7 +37,7 @@ func TestRoundTrip(t *testing.T) {  	}  	cli := gemini.NewClient(testClientTLS()) -	response, err := cli.RoundTrip(&gemini.Request{URL: u}) +	response, err := cli.RoundTrip(&gus.Request{URL: u})  	if err != nil {  		t.Fatalf("RoundTrip(): %s", err.Error())  	} diff --git a/gemini/serve.go b/gemini/serve.go index bc13531..c148558 100644 --- a/gemini/serve.go +++ b/gemini/serve.go @@ -6,27 +6,28 @@ import (  	"io"  	"net"  	"sync" + +	"tildegit.org/tjp/gus"  ) -// Server listens on a network and serves the gemini protocol. -type Server struct { +type server struct {  	ctx      context.Context  	network  string  	address  string  	cancel   context.CancelFunc  	wg       *sync.WaitGroup  	listener net.Listener -	handler  Handler +	handler  gus.Handler  } -// NewServer builds a server. +// NewServer builds a gemini server.  func NewServer(  	ctx context.Context,  	tlsConfig *tls.Config,  	network string,  	address string, -	handler Handler, -) (*Server, error) { +	handler gus.Handler, +) (gus.Server, error) {  	listener, err := net.Listen(network, address)  	if err != nil {  		return nil, err @@ -34,7 +35,7 @@ func NewServer(  	addr := listener.Addr() -	s := &Server{ +	s := &server{  		ctx:      ctx,  		network:  addr.Network(),  		address:  addr.String(), @@ -54,7 +55,7 @@ func NewServer(  // 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. -func (s *Server) Serve() error { +func (s *server) Serve() error {  	s.wg.Add(1)  	defer s.wg.Done() @@ -66,7 +67,7 @@ func (s *Server) Serve() error {  	for {  		conn, err := s.listener.Accept()  		if err != nil { -			if s.closed() { +			if s.Closed() {  				err = nil  			}  			return err @@ -77,62 +78,57 @@ func (s *Server) Serve() error {  	}  } -// Close begins a graceful shutdown of the server. -// -// It cancels the server's context which interrupts all concurrently running -// request handlers, if they support it. It then blocks until all resources -// have been cleaned up and all request handlers have completed. -func (s *Server) Close() { +func (s *server) Close() {  	s.cancel()  	s.wg.Wait()  } -// Network returns the network type on which the server is running. -func (s *Server) Network() string { +func (s *server) Network() string {  	return s.network  } -// Address returns the address on which the server is listening. -func (s *Server) Address() string { +func (s *server) Address() string {  	return s.address  } -// Hostname returns just the hostname portion of the listen address. -func (s *Server) Hostname() string { +func (s *server) Hostname() string {  	host, _, _ := net.SplitHostPort(s.address)  	return host  } -// Port returns the port on which the server is listening. -func (s *Server) Port() string { +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() +	var response *gus.Response  	req, err := ParseRequest(conn)  	if err != nil { -		_, _ = io.Copy(conn, BadRequest(err.Error())) +		response = BadRequest(err.Error())  		return -	} +	} else { +		req.Server = s +		req.RemoteAddr = conn.RemoteAddr() +		if tlsconn, ok := conn.(*tls.Conn); req != nil && ok { +			state := tlsconn.ConnectionState() +			req.TLSState = &state +		} -	req.Server = s -	req.RemoteAddr = conn.RemoteAddr() -	if tlsconn, ok := conn.(*tls.Conn); req != nil && ok { -		state := tlsconn.ConnectionState() -		req.TLSState = &state +		response = s.handler(s.ctx, req) +		if response == nil { +			response = NotFound("Resource does not exist.") +		} +		defer response.Close()  	} -	resp := s.handler(s.ctx, req) -	defer resp.Close() - -	_, _ = io.Copy(conn, resp) +	_, _ = io.Copy(conn, NewResponseReader(response))  } -func (s *Server) propagateCancel() { +func (s *server) propagateCancel() {  	go func() {  		defer s.wg.Done() @@ -141,7 +137,7 @@ func (s *Server) propagateCancel() {  	}()  } -func (s *Server) closed() bool { +func (s *server) Closed() bool {  	select {  	case <-s.ctx.Done():  		return true diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..f940b77 --- /dev/null +++ b/handler.go @@ -0,0 +1,52 @@ +package gus + +import "context" + +// Handler is a function which can turn a request into a response. +// +// A Handler can return a nil response, in which case the Server is expected +// to build the protocol-appropriate "Not Found" response. +type Handler func(context.Context, *Request) *Response + +// Middleware is a handler decorator. +// +// It returns a handler which may call the passed-in handler or not, or may +// transform the request or response in some way. +type Middleware func(Handler) Handler + +// FallthroughHandler builds a handler which tries multiple child handlers. +// +// The returned handler will invoke each of the passed-in handlers in order, +// stopping when it receives a non-nil response. +func FallthroughHandler(handlers ...Handler) Handler { +	return func(ctx context.Context, request *Request) *Response { +		for _, handler := range handlers { +			if response := handler(ctx, request); response != nil { +				return response +			} +		} +		return nil +	} +} + +// Filter builds a middleware which only calls the wrapped under a condition. +// +// When the condition function returns false it instead invokes the +// test-failure handler. The failure handler may also be nil, in which case +// the final handler will return a nil response whenever the condition fails. +func Filter( +	condition func(context.Context, *Request) bool, +	failure Handler, +) Middleware { +	return func(success Handler) Handler { +		return func(ctx context.Context, request *Request) *Response { +			if condition(ctx, request) { +				return success(ctx, request) +			} +			if failure == nil { +				return nil +			} +			return failure(ctx, request) +		} +	} +} diff --git a/gemini/handler_test.go b/handler_test.go index c83df65..a83ef3b 100644 --- a/gemini/handler_test.go +++ b/handler_test.go @@ -1,4 +1,4 @@ -package gemini_test +package gus_test  import (  	"bytes" @@ -8,32 +8,33 @@ import (  	"strings"  	"testing" +	"tildegit.org/tjp/gus"  	"tildegit.org/tjp/gus/gemini"  )  func TestFallthrough(t *testing.T) { -	h1 := func(ctx context.Context, req *gemini.Request) *gemini.Response { +	h1 := func(ctx context.Context, req *gus.Request) *gus.Response {  		if req.Path == "/one" {  			return gemini.Success("text/gemini", bytes.NewBufferString("one"))  		} -		return gemini.NotFound("nope") +		return nil  	} -	h2 := func(ctx context.Context, req *gemini.Request) *gemini.Response { +	h2 := func(ctx context.Context, req *gus.Request) *gus.Response {  		if req.Path == "/two" {  			return gemini.Success("text/gemini", bytes.NewBufferString("two"))  		} -		return gemini.NotFound("no way") +		return nil  	} -	fth := gemini.FallthroughHandler(h1, h2) +	fth := gus.FallthroughHandler(h1, h2)  	u, err := url.Parse("gemini://test.local/one")  	if err != nil {  		t.Fatalf("url.Parse: %s", err.Error())  	} -	resp := fth(context.Background(), &gemini.Request{URL: u}) +	resp := fth(context.Background(), &gus.Request{URL: u})  	if resp.Status != gemini.StatusSuccess {  		t.Errorf("expected status %d, got %d", gemini.StatusSuccess, resp.Status) @@ -56,7 +57,7 @@ func TestFallthrough(t *testing.T) {  		t.Fatalf("url.Parse: %s", err.Error())  	} -	resp = fth(context.Background(), &gemini.Request{URL: u}) +	resp = fth(context.Background(), &gus.Request{URL: u})  	if resp.Status != gemini.StatusSuccess {  		t.Errorf("expected status %d, got %d", gemini.StatusSuccess, resp.Status) @@ -79,28 +80,28 @@ func TestFallthrough(t *testing.T) {  		t.Fatalf("url.Parse: %s", err.Error())  	} -	resp = fth(context.Background(), &gemini.Request{URL: u}) +	resp = fth(context.Background(), &gus.Request{URL: u}) -	if resp.Status != gemini.StatusNotFound { -		t.Errorf("expected status %d, got %d", gemini.StatusNotFound, resp.Status) +	if resp != nil { +		t.Errorf("expected nil, got %+v", resp)  	}  }  func TestFilter(t *testing.T) { -	pred := func(ctx context.Context, req *gemini.Request) bool { +	pred := func(ctx context.Context, req *gus.Request) bool {  		return strings.HasPrefix(req.Path, "/allow")  	} -	base := func(ctx context.Context, req *gemini.Request) *gemini.Response { +	base := func(ctx context.Context, req *gus.Request) *gus.Response {  		return gemini.Success("text/gemini", bytes.NewBufferString("allowed!"))  	} -	handler := gemini.Filter(pred, base, nil) +	handler := gus.Filter(pred, nil)(base)  	u, err := url.Parse("gemini://test.local/allow/please")  	if err != nil {  		t.Fatalf("url.Parse: %s", err.Error())  	} -	resp := handler(context.Background(), &gemini.Request{URL: u}) +	resp := handler(context.Background(), &gus.Request{URL: u})  	if resp.Status != gemini.StatusSuccess {  		t.Errorf("expected status %d, got %d", gemini.StatusSuccess, resp.Status)  	} @@ -110,8 +111,8 @@ func TestFilter(t *testing.T) {  		t.Fatalf("url.Parse: %s", err.Error())  	} -	resp = handler(context.Background(), &gemini.Request{URL: u}) -	if resp.Status != gemini.StatusNotFound { -		t.Errorf("expected status %d, got %d", gemini.StatusNotFound, resp.Status) +	resp = handler(context.Background(), &gus.Request{URL: u}) +	if resp != nil { +		t.Errorf("expected nil, got %+v", resp)  	}  } diff --git a/request.go b/request.go new file mode 100644 index 0000000..1e0f3e7 --- /dev/null +++ b/request.go @@ -0,0 +1,43 @@ +package gus + +import ( +	"crypto/tls" +	"net" +	"net/url" +) + +// Request represents a request over any small web protocol. +// +// Because protocols have so many differences, this type represents a +// greatest common denominator of request/response-oriented protocols. +type Request struct { +	// URL is the specific URL being fetched by the request. +	*url.URL + +	// Server is the server which received the request. +	// +	// This is only populated in servers. +	// It is unused on the client end. +	Server Server + +	// RemoteAddr is the address of the other side of the connection. +	// +	// This will be the server address for clients, or the connecting +	// client's address in servers. +	// +	// Be aware though that proxies (and reverse proxies) can confuse this. +	RemoteAddr net.Addr + +	// TLSState contains information about the TLS encryption over the connection. +	// +	// This includes peer certificates and version information. +	TLSState *tls.ConnectionState +} + +// UnescapedQuery performs %XX unescaping on the URL query segment. +// +// Like URL.Query(), it silently drops malformed %-encoded sequences. +func (req Request) UnescapedQuery() string { +	unescaped, _ := url.QueryUnescape(req.RawQuery) +	return unescaped +} diff --git a/request_test.go b/request_test.go new file mode 100644 index 0000000..0da744f --- /dev/null +++ b/request_test.go @@ -0,0 +1,24 @@ +package gus_test + +import ( +	"net/url" +	"testing" + +	"tildegit.org/tjp/gus" +) + +func TestUnescapedQuery(t *testing.T) { +	table := []string{ +		"foo bar", +	} + +	for _, test := range table { +		t.Run(test, func(t *testing.T) { +			u, _ := url.Parse("gemini://domain.com/path?" + url.QueryEscape(test)) +			result := gus.Request{URL: u}.UnescapedQuery() +			if result != test { +				t.Errorf("expected %q, got %q", test, result) +			} +		}) +	} +} diff --git a/response.go b/response.go new file mode 100644 index 0000000..5943552 --- /dev/null +++ b/response.go @@ -0,0 +1,28 @@ +package gus + +import "io" + +// Status is the integer status code of a response. +type Status int + +// Response contains the data in a response over the small web. +// +// Because protocols have so many differences, this type represents a +// greatest common denominator of request/response-oriented protocols. +type Response struct { +	// Status is the status code of the response. +	Status Status + +	// Meta contains status-specific additional information. +	Meta any + +	// Body is the response body, if any. +	Body io.Reader +} + +func (response *Response) Close() error { +	if cl, ok := response.Body.(io.Closer); ok { +		return cl.Close() +	} +	return nil +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..96b6433 --- /dev/null +++ b/server.go @@ -0,0 +1,36 @@ +package gus + +// Server is a type which can serve a protocol. +type Server interface { +	// Serve blocks listening for connections on an interface. +	// +	// It will only return after Close() has been called. +	Serve() error + +	// Close initiates a graceful shutdown of the server. +	// +	// It blocks until all resources have been cleaned up and all +	// outstanding requests have been handled and responses sent. +	Close() + +	// Closed indicates whether Close has been called. +	// +	// It may be true even if the graceful shutdown procedure +	// hasn't yet completed. +	Closed() bool + +	// Network returns the network type on which the server is running. +	Network() string + +	// Address returns the address on which the server is listening. +	Address() string + +	// Hostname returns just the hostname portion of the listen address. +	Hostname() string + +	// Port returns the port on which the server is listening. +	// +	// It will return the empty string if the network type does not +	// have ports (unix sockets, for example). +	Port() string +}  | 
