From 2ef530daa47b301a40c1ee93cd43b8f36fc68c0b Mon Sep 17 00:00:00 2001 From: tjpcc Date: Tue, 17 Jan 2023 15:59:29 -0700 Subject: [PATCH] pull request, response, handlers out of the gemini package --- contrib/cgi/cgi.go | 15 +- contrib/fs/dir.go | 23 +-- contrib/fs/file.go | 9 +- contrib/log/log.go | 8 +- examples/cowsay/main.go | 3 +- examples/fetch/main.go | 6 +- examples/fileserver/main.go | 3 +- examples/inspectls/main.go | 3 +- gemini/client.go | 4 +- gemini/handler.go | 54 ------ gemini/request.go | 41 +---- gemini/request_test.go | 17 -- gemini/response.go | 204 ++++++++++------------ gemini/response_test.go | 23 +-- gemini/roundtrip_test.go | 5 +- gemini/serve.go | 72 ++++---- handler.go | 52 ++++++ gemini/handler_test.go => handler_test.go | 37 ++-- request.go | 43 +++++ request_test.go | 24 +++ response.go | 28 +++ server.go | 36 ++++ 22 files changed, 392 insertions(+), 318 deletions(-) delete mode 100644 gemini/handler.go create mode 100644 handler.go rename gemini/handler_test.go => handler_test.go (66%) create mode 100644 request.go create mode 100644 request_test.go create mode 100644 response.go create mode 100644 server.go 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 /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 (r *Response) ensureReader() { - if r.reader != nil { +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() { + 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 + } + + response = s.handler(s.ctx, req) + if response == nil { + response = NotFound("Resource does not exist.") + } + defer response.Close() } - req.Server = s - req.RemoteAddr = conn.RemoteAddr() - if tlsconn, ok := conn.(*tls.Conn); req != nil && ok { - state := tlsconn.ConnectionState() - req.TLSState = &state - } - - 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 similarity index 66% rename from gemini/handler_test.go rename to 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 +}