pull request, response, handlers out of the gemini package

This commit is contained in:
tjpcc 2023-01-17 15:59:29 -07:00
parent 30e21f8513
commit 2ef530daa4
22 changed files with 392 additions and 318 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

52
handler.go Normal file
View File

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

View File

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

43
request.go Normal file
View File

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

24
request_test.go Normal file
View File

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

28
response.go Normal file
View File

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

36
server.go Normal file
View File

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