diff options
Diffstat (limited to 'gopher')
-rw-r--r-- | gopher/client.go | 55 | ||||
-rw-r--r-- | gopher/gophermap/parse.go | 61 | ||||
-rw-r--r-- | gopher/gophermap/parse_test.go | 96 | ||||
-rw-r--r-- | gopher/request.go | 72 | ||||
-rw-r--r-- | gopher/request_test.go | 43 | ||||
-rw-r--r-- | gopher/response.go | 162 | ||||
-rw-r--r-- | gopher/serve.go | 72 |
7 files changed, 561 insertions, 0 deletions
diff --git a/gopher/client.go b/gopher/client.go new file mode 100644 index 0000000..8f5ca81 --- /dev/null +++ b/gopher/client.go @@ -0,0 +1,55 @@ +package gopher + +import ( + "bytes" + "errors" + "io" + "net" + + "tildegit.org/tjp/gus" +) + +// Client is used for sending gopher requests and producing the responses. +// +// It carries no state and is reusable simultaneously by multiple goroutines. +// +// The zero value is immediately usable. +type Client struct{} + +// RoundTrip sends a single gopher request and returns its response. +func (c Client) RoundTrip(request *gus.Request) (*gus.Response, error) { + if request.Scheme != "gopher" && request.Scheme != "" { + return nil, errors.New("non-gopher protocols not supported") + } + + host := request.Host + if _, port, _ := net.SplitHostPort(host); port == "" { + host = net.JoinHostPort(host, "70") + } + + conn, err := net.Dial("tcp", host) + if err != nil { + return nil, err + } + defer conn.Close() + + request.RemoteAddr = conn.RemoteAddr() + request.TLSState = nil + + requestBody := request.Path + if request.RawQuery != "" { + requestBody += "\t" + request.UnescapedQuery() + } + requestBody += "\r\n" + + if _, err := conn.Write([]byte(requestBody)); err != nil { + return nil, err + } + + response, err := io.ReadAll(conn) + if err != nil { + return nil, err + } + + return &gus.Response{Body: bytes.NewBuffer(response)}, nil +} diff --git a/gopher/gophermap/parse.go b/gopher/gophermap/parse.go new file mode 100644 index 0000000..302aef0 --- /dev/null +++ b/gopher/gophermap/parse.go @@ -0,0 +1,61 @@ +package gophermap + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + + "tildegit.org/tjp/gus" + "tildegit.org/tjp/gus/gopher" +) + +// Parse reads a gophermap document from a reader. +func Parse(input io.Reader) (gopher.MapDocument, error) { + rdr := bufio.NewReader(input) + doc := gopher.MapDocument{} + + num := 0 + for { + num += 1 + line, err := rdr.ReadBytes('\n') + isEOF := errors.Is(err, io.EOF) + if err != nil && !isEOF { + return nil, err + } + + if len(line) > 2 && !bytes.Equal(line, []byte(".\r\n")) { + if line[len(line)-2] != '\r' || line[len(line)-1] != '\n' { + return nil, InvalidLine(num) + } + + item := gopher.MapItem{Type: gus.Status(line[0])} + + spl := bytes.Split(line[1:len(line)-2], []byte{'\t'}) + if len(spl) != 4 { + return nil, InvalidLine(num) + } + item.Display = string(spl[0]) + item.Selector = string(spl[1]) + item.Hostname = string(spl[2]) + item.Port = string(spl[3]) + + doc = append(doc, item) + } + + if isEOF { + break + } + } + + return doc, nil +} + +// InvalidLine is returned from Parse when the reader contains a line which is invalid gophermap. +type InvalidLine int + +// Error implements the error interface. +func (il InvalidLine) Error() string { + return fmt.Sprintf("Invalid gophermap on line %d.", il) +} diff --git a/gopher/gophermap/parse_test.go b/gopher/gophermap/parse_test.go new file mode 100644 index 0000000..0e5c09e --- /dev/null +++ b/gopher/gophermap/parse_test.go @@ -0,0 +1,96 @@ +package gophermap_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tildegit.org/tjp/gus/gopher" + "tildegit.org/tjp/gus/gopher/gophermap" +) + +func TestParse(t *testing.T) { + tests := []struct { + doc string + lines gopher.MapDocument + }{ + { + doc: ` +iI am informational text localhost 70 +icontinued on this line localhost 70 +i localhost 70 +0this is my text file /file.txt localhost 70 +i localhost 70 +1here's a sub-menu /sub/ localhost 70 +. +`[1:], + lines: gopher.MapDocument{ + gopher.MapItem{ + Type: gopher.InfoMessageType, + Display: "I am informational text", + Selector: "", + Hostname: "localhost", + Port: "70", + }, + gopher.MapItem{ + Type: gopher.InfoMessageType, + Display: "continued on this line", + Selector: "", + Hostname: "localhost", + Port: "70", + }, + gopher.MapItem{ + Type: gopher.InfoMessageType, + Display: "", + Selector: "", + Hostname: "localhost", + Port: "70", + }, + gopher.MapItem{ + Type: gopher.TextFileType, + Display: "this is my text file", + Selector: "/file.txt", + Hostname: "localhost", + Port: "70", + }, + gopher.MapItem{ + Type: gopher.InfoMessageType, + Display: "", + Selector: "", + Hostname: "localhost", + Port: "70", + }, + gopher.MapItem{ + Type: gopher.MenuType, + Display: "here's a sub-menu", + Selector: "/sub/", + Hostname: "localhost", + Port: "70", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.lines[0].Display, func(t *testing.T) { + text := strings.ReplaceAll(test.doc, "\n", "\r\n") + doc, err := gophermap.Parse(bytes.NewBufferString(text)) + require.Nil(t, err) + + if assert.Equal(t, len(test.lines), len(doc)) { + for i, line := range doc { + expect := test.lines[i] + + assert.Equal(t, expect.Type, line.Type) + assert.Equal(t, expect.Display, line.Display) + assert.Equal(t, expect.Selector, line.Selector) + assert.Equal(t, expect.Hostname, line.Hostname) + assert.Equal(t, expect.Port, line.Port) + } + } + }) + } +} diff --git a/gopher/request.go b/gopher/request.go new file mode 100644 index 0000000..6c708c0 --- /dev/null +++ b/gopher/request.go @@ -0,0 +1,72 @@ +package gopher + +import ( + "bytes" + "errors" + "io" + "net/url" + "path" + "strings" + + "tildegit.org/tjp/gus" +) + +// ParseRequest parses a gopher protocol request into a gus.Request object. +func ParseRequest(rdr io.Reader) (*gus.Request, error) { + selector, search, err := readFullRequest(rdr) + if err != nil { + return nil, err + } + + if !strings.HasPrefix(selector, "/") { + selector = "/" + selector + } + + return &gus.Request{ + URL: &url.URL{ + Scheme: "gopher", + Path: path.Clean(strings.TrimRight(selector, "\r\n")), + OmitHost: true, //nolint:typecheck + // (for some reason typecheck on drone-ci doesn't realize OmitHost is a field in url.URL) + RawQuery: url.QueryEscape(strings.TrimRight(search, "\r\n")), + }, + }, nil +} + +func readFullRequest(rdr io.Reader) (string, string, error) { + // The vast majority of requests will fit in this size: + // the specified 255 byte max for selector, then CRLF. + buf := make([]byte, 257) + + n, err := rdr.Read(buf) + if err != nil && !errors.Is(err, io.EOF) { + return "", "", err + } + buf = buf[:n] + + // Full-text search transactions are the exception, they + // may be longer because there is an additional search string + if n == 257 && buf[256] != '\n' { + intake := buf[n:cap(buf)] + total := n + for { + intake = append(intake, 0) + intake = intake[:cap(intake)] + + n, err = rdr.Read(intake) + if err != nil && err != io.EOF { + return "", "", err + } + total += n + + if n < cap(intake) || intake[cap(intake)-1] == '\n' { + break + } + intake = intake[n:] + } + buf = buf[:total] + } + + selector, search, _ := bytes.Cut(buf, []byte{'\t'}) + return string(selector), string(search), nil +} diff --git a/gopher/request_test.go b/gopher/request_test.go new file mode 100644 index 0000000..1ab7801 --- /dev/null +++ b/gopher/request_test.go @@ -0,0 +1,43 @@ +package gopher_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tildegit.org/tjp/gus/gopher" +) + +func TestParseRequest(t *testing.T) { + tests := []struct { + requestLine string + path string + query string + }{ + { + requestLine: "\r\n", + path: "/", + }, + { + requestLine: "foo/bar\r\n", + path: "/foo/bar", + }, + { + requestLine: "search\tthis AND that\r\n", + path: "/search", + query: "this+AND+that", + }, + } + + for _, test := range tests { + t.Run(test.requestLine, func(t *testing.T) { + request, err := gopher.ParseRequest(bytes.NewBufferString(test.requestLine)) + require.Nil(t, err) + + assert.Equal(t, test.path, request.Path) + assert.Equal(t, test.query, request.RawQuery) + }) + } +} diff --git a/gopher/response.go b/gopher/response.go new file mode 100644 index 0000000..c600b10 --- /dev/null +++ b/gopher/response.go @@ -0,0 +1,162 @@ +package gopher + +import ( + "bytes" + "fmt" + "io" + "sync" + + "tildegit.org/tjp/gus" +) + +// The Canonical gopher item types. +const ( + TextFileType gus.Status = '0' + MenuType gus.Status = '1' + CSOPhoneBookType gus.Status = '2' + ErrorType gus.Status = '3' + MacBinHexType gus.Status = '4' + DosBinType gus.Status = '5' + UuencodedType gus.Status = '6' + SearchType gus.Status = '7' + TelnetSessionType gus.Status = '8' + BinaryFileType gus.Status = '9' + MirrorServerType gus.Status = '+' + GifFileType gus.Status = 'g' + ImageFileType gus.Status = 'I' + Telnet3270Type gus.Status = 'T' +) + +// The gopher+ types. +const ( + BitmapType gus.Status = ':' + MovieFileType gus.Status = ';' + SoundFileType gus.Status = '<' +) + +// The various non-canonical gopher types. +const ( + DocumentType gus.Status = 'd' + HTMLType gus.Status = 'h' + InfoMessageType gus.Status = 'i' + PngImageFileType gus.Status = 'p' + RtfDocumentType gus.Status = 'r' + WavSoundFileType gus.Status = 's' + PdfDocumentType gus.Status = 'P' + XmlDocumentType gus.Status = 'X' +) + +// MapItem is a single item in a gophermap. +type MapItem struct { + Type gus.Status + Display string + Selector string + Hostname string + Port string +} + +// String serializes the item into a gophermap CRLF-terminated text line. +func (mi MapItem) String() string { + return fmt.Sprintf( + "%s%s\t%s\t%s\t%s\r\n", + []byte{byte(mi.Type)}, + mi.Display, + mi.Selector, + mi.Hostname, + mi.Port, + ) +} + +// Response builds a response which contains just this single MapItem. +// +// Meta in the response will be a pointer to the MapItem. +func (mi *MapItem) Response() *gus.Response { + return &gus.Response{ + Status: mi.Type, + Meta: &mi, + Body: bytes.NewBufferString(mi.String() + ".\r\n"), + } +} + +// MapDocument is a list of map items which can print out a full gophermap document. +type MapDocument []MapItem + +// String serializes the document into gophermap format. +func (md MapDocument) String() string { + return md.serialize().String() +} + +// Response builds a gopher response containing the gophermap. +// +// Meta will be the MapDocument itself. +func (md MapDocument) Response() *gus.Response { + return &gus.Response{ + Status: DocumentType, + Meta: md, + Body: md.serialize(), + } +} + +func (md MapDocument) serialize() *bytes.Buffer { + buf := &bytes.Buffer{} + for _, mi := range md { + _, _ = buf.WriteString(mi.String()) + } + _, _ = buf.WriteString(".\r\n") + return buf +} + +// Error builds an error message MapItem. +func Error(err error) *MapItem { + return &MapItem{ + Type: ErrorType, + Display: err.Error(), + Hostname: "none", + Port: "0", + } +} + +// File builds a minimal response delivering a file's contents. +// +// Meta is nil and Status is 0 in this response. +func File(status gus.Status, contents io.Reader) *gus.Response { + return &gus.Response{Status: status, Body: contents} +} + +// NewResponseReader produces a reader which supports reading gopher protocol responses. +func NewResponseReader(response *gus.Response) gus.ResponseReader { + return &responseReader{ + Response: response, + once: &sync.Once{}, + } +} + +type responseReader struct { + *gus.Response + reader io.Reader + once *sync.Once +} + +func (rdr *responseReader) Read(b []byte) (int, error) { + rdr.ensureReader() + return rdr.reader.Read(b) +} + +func (rdr *responseReader) WriteTo(dst io.Writer) (int64, error) { + rdr.ensureReader() + return rdr.reader.(io.WriterTo).WriteTo(dst) +} + +func (rdr *responseReader) ensureReader() { + rdr.once.Do(func() { + if _, ok := rdr.Body.(io.WriterTo); ok { + rdr.reader = rdr.Body + return + } + + // rdr.reader needs to implement WriterTo, so in this case + // we borrow an implementation in terms of io.Reader from + // io.MultiReader. + rdr.reader = io.MultiReader(rdr.Body) + }) +} diff --git a/gopher/serve.go b/gopher/serve.go new file mode 100644 index 0000000..84745d7 --- /dev/null +++ b/gopher/serve.go @@ -0,0 +1,72 @@ +package gopher + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strings" + + "tildegit.org/tjp/gus" + "tildegit.org/tjp/gus/internal" + "tildegit.org/tjp/gus/logging" +) + +type gopherServer struct { + internal.Server + handler gus.Handler +} + +func (gs gopherServer) Protocol() string { return "GOPHER" } + +// NewServer builds a gopher server. +func NewServer( + ctx context.Context, + hostname string, + network string, + address string, + handler gus.Handler, + errLog logging.Logger, +) (gus.Server, error) { + gs := &gopherServer{handler: handler} + + if strings.IndexByte(hostname, ':') < 0 { + hostname = net.JoinHostPort(hostname, "70") + } + + var err error + gs.Server, err = internal.NewServer(ctx, hostname, network, address, errLog, gs.handleConn) + if err != nil { + return nil, err + } + + return gs, nil +} + +func (gs *gopherServer) handleConn(conn net.Conn) { + var response *gus.Response + request, err := ParseRequest(conn) + if err != nil { + response = Error(errors.New("Malformed request.")).Response() + } else { + request.Server = gs + request.RemoteAddr = conn.RemoteAddr() + + defer func() { + if r := recover(); r != nil { + err := fmt.Errorf("%s", r) + _ = gs.LogError("msg", "panic in handler", "err", err) + rdr := NewResponseReader(Error(errors.New("Server error.")).Response()) + _, _ = io.Copy(conn, rdr) + } + }() + response = gs.handler(gs.Ctx, request) + if response == nil { + response = Error(errors.New("Resource does not exist.")).Response() + } + } + + defer response.Close() + _, _ = io.Copy(conn, NewResponseReader(response)) +} |