summaryrefslogtreecommitdiff
path: root/gopher
diff options
context:
space:
mode:
Diffstat (limited to 'gopher')
-rw-r--r--gopher/client.go55
-rw-r--r--gopher/gophermap/parse.go61
-rw-r--r--gopher/gophermap/parse_test.go96
-rw-r--r--gopher/request.go72
-rw-r--r--gopher/request_test.go43
-rw-r--r--gopher/response.go162
-rw-r--r--gopher/serve.go72
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))
+}