From 1c7e11ca599d2cf2783639ace662b7039d71a182 Mon Sep 17 00:00:00 2001 From: Yotam Nachum Date: Sat, 26 Oct 2019 13:39:56 +0300 Subject: Initial commit --- client.go | 107 ++++++++++++++++++++++++++++++++++++ client_test.go | 77 ++++++++++++++++++++++++++ gemini.go | 37 +++++++++++++ gemini_test.go | 23 ++++++++ go.mod | 5 ++ go.sum | 2 + resources/tests/simple_response | 2 + server.go | 118 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 371 insertions(+) create mode 100644 client.go create mode 100644 client_test.go create mode 100644 gemini.go create mode 100644 gemini_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 resources/tests/simple_response create mode 100644 server.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..d529b60 --- /dev/null +++ b/client.go @@ -0,0 +1,107 @@ +package gemini + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "net/url" + "strconv" + "strings" +) + +// Response represent the response from a Gemini server. +type Response struct { + Status int + Meta string + Body io.ReadCloser +} + +type header struct { + status int + meta string +} + +// Fetch a resource from a Gemini server with the given URL +func Fetch(url string) (res Response, err error) { + conn, err := connectByURL(url) + if err != nil { + return Response{}, fmt.Errorf("failed to connect to the server: %v", err) + } + + err = sendRequest(conn, url) + if err != nil { + conn.Close() + return Response{}, err + } + + return getResponse(conn) +} + +func connectByURL(rawURL string) (io.ReadWriteCloser, error) { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("failed to parse given URL: %v", err) + } + + conf := &tls.Config{ + InsecureSkipVerify: true, + } + + return tls.Dial("tcp", parsedURL.Host, conf) +} + +func sendRequest(conn io.Writer, requestURL string) error { + _, err := fmt.Fprintf(conn, "%s\r\n", requestURL) + if err != nil { + return fmt.Errorf("could not send request to the server: %v", err) + } + + return nil +} + +func getResponse(conn io.ReadCloser) (Response, error) { + header, err := getHeader(conn) + if err != nil { + conn.Close() + return Response{}, fmt.Errorf("failed to get header: %v", err) + } + + return Response{header.status, header.meta, conn}, nil +} + +func getHeader(conn io.Reader) (header, error) { + line, err := readHeader(conn) + if err != nil { + return header{}, fmt.Errorf("failed to read header: %v", err) + } + + fields := strings.Fields(string(line)) + status, err := strconv.Atoi(fields[0]) + if err != nil { + return header{}, fmt.Errorf("unexpected status value %v: %v", fields[0], err) + } + + meta := strings.Join(fields[1:], " ") + + return header{status, meta}, nil +} + +func readHeader(conn io.Reader) ([]byte, error) { + var line []byte + delim := []byte("\r\n") + // A small buffer is inefficient but the maximum length of the header is small so it's okay + buf := make([]byte, 1) + + for { + _, err := conn.Read(buf) + if err != nil { + return []byte{}, err + } + + line = append(line, buf...) + if bytes.HasSuffix(line, delim) { + return line[:len(line)-len(delim)], nil + } + } +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..34d6158 --- /dev/null +++ b/client_test.go @@ -0,0 +1,77 @@ +package gemini + +import ( + "fmt" + "io/ioutil" + "os" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func compareResponses(expected, given Response) (diff string) { + diff = cmp.Diff(expected.Meta, given.Meta) + if diff != "" { + return + } + + diff = cmp.Diff(expected.Meta, given.Meta) + if diff != "" { + return + } + + expectedBody, err := ioutil.ReadAll(expected.Body) + if err != nil { + return fmt.Sprintf("failed to get expected body: %v", err) + } + + givenBody, err := ioutil.ReadAll(given.Body) + if err != nil { + return fmt.Sprintf("failed to get givenponse body: %v", err) + } + + diff = cmp.Diff(expectedBody, givenBody) + return +} + +func TestGetResponse(t *testing.T) { + tests := []struct { + file string + expected Response + }{ + {"resources/tests/simple_response", Response{20, "text/gemini", ioutil.NopCloser(strings.NewReader("This is the content of the page\r\n"))}}, + } + + for _, tc := range tests { + f, err := os.Open(tc.file) + if err != nil { + t.Fatalf("failed to get test case file %s: %v", tc.file, err) + } + + res, err := getResponse(f) + if err != nil { + t.Fatalf("failed to parse response %s: %v", tc.file, err) + } + + diff := compareResponses(tc.expected, res) + if diff != "" { + t.Fatalf(diff) + } + } + +} + +func TestGetResponseEmptyResponse(t *testing.T) { + _, err := getResponse(ioutil.NopCloser(strings.NewReader(""))) + if err == nil { + t.Fatalf("expected to get an error for empty response, got nil instead") + } +} + +func TestGetResponseInvalidStatus(t *testing.T) { + _, err := getResponse(ioutil.NopCloser(strings.NewReader("AA\tmeta\r\n"))) + if err == nil { + t.Fatalf("expected to get an error for invalid status response, got nil instead") + } +} diff --git a/gemini.go b/gemini.go new file mode 100644 index 0000000..5ebaa6a --- /dev/null +++ b/gemini.go @@ -0,0 +1,37 @@ +package gemini + +// Gemini status codes as defined in the Gemini spec Appendix 1. +const ( + StatusInput = 10 + + StatusSuccess = 20 + StatusSuccessEndOfClientCertificateSession = 21 + + StatusRedirect = 30 + StatusRedirectTemporary = 30 + StatusRedirectPermanent = 31 + + StatusTemporaryFailure = 40 + StatusUnavailable = 41 + StatusCGIError = 42 + StatusProxyError = 43 + StatusSlowDown = 44 + + StatusPermanentFailure = 50 + StatusNotFound = 51 + StatusGone = 52 + StatusProxyRequestRefused = 53 + StatusBadRequest = 59 + + StatusClientCertificateRequired = 60 + StatusTransientCertificateRequested = 61 + StatusAuthorisedCertificateRequired = 62 + StatusCertificateNotAccepted = 63 + StatusFutureCertificateRejected = 64 + StatusExpiredCertificateRejected = 65 +) + +// SimplifyStatus simplify the response status by omiting the detailed second digit of the status code. +func SimplifyStatus(status int) int { + return (status / 10) * 10 +} diff --git a/gemini_test.go b/gemini_test.go new file mode 100644 index 0000000..59fe3c8 --- /dev/null +++ b/gemini_test.go @@ -0,0 +1,23 @@ +package gemini + +import "testing" + +func TestSimplifyStatus(t *testing.T) { + tests := []struct { + ComplexStatus int + SimpleStatus int + }{ + {10, 10}, + {20, 20}, + {21, 20}, + {44, 40}, + {59, 50}, + } + + for _, tt := range tests { + result := SimplifyStatus(tt.ComplexStatus) + if result != tt.SimpleStatus { + t.Errorf("Expected the simplified status of %d to be %d, got %d instead", tt.ComplexStatus, tt.SimpleStatus, result) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4afecd0 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.sr.ht/~yotam/go-gemini + +go 1.12 + +require github.com/google/go-cmp v0.3.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ddb1d --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= diff --git a/resources/tests/simple_response b/resources/tests/simple_response new file mode 100644 index 0000000..10c8fee --- /dev/null +++ b/resources/tests/simple_response @@ -0,0 +1,2 @@ +20 text/gemini +This is the content of the page diff --git a/server.go b/server.go new file mode 100644 index 0000000..ff7df80 --- /dev/null +++ b/server.go @@ -0,0 +1,118 @@ +package gemini + +import ( + "bufio" + "crypto/tls" + "fmt" + "io" + "net" + "strings" +) + +// Request contains the data of the client request +type Request struct { + URL string +} + +// Handler is the interface a struct need to implement to be able to handle Gemini requests +type Handler interface { + Handle(r Request) Response +} + +// ListenAndServe create a TCP server on the specified address and pass +// new connections to the given handler. +// Each request is handled in a separate goroutine. +func ListenAndServe(addr, certFile, keyFile string, handler Handler) error { + if addr == "" { + addr = "127.0.0.1:1965" + } + + listener, err := listen(addr, certFile, keyFile) + if err != nil { + return err + } + + err = serve(listener, handler) + if err != nil { + return err + } + + err = listener.Close() + if err != nil { + return fmt.Errorf("failed to close the listener: %v", err) + } + + return nil +} + +func listen(addr, certFile, keyFile string) (net.Listener, error) { + cer, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("failed to load certificates: %v", err) + } + + config := &tls.Config{Certificates: []tls.Certificate{cer}} + ln, err := tls.Listen("tcp", addr, config) + if err != nil { + return nil, fmt.Errorf("failed to listen: %v", err) + } + + return ln, nil +} + +func serve(listener net.Listener, handler Handler) error { + for { + conn, err := listener.Accept() + if err != nil { + continue + } + + go handleConnection(conn, handler) + } +} + +func handleConnection(conn io.ReadWriteCloser, handler Handler) { + defer conn.Close() + + requestURL, err := getRequestURL(conn) + if err != nil { + return + } + + request := Request{requestURL} + response := handler.Handle(request) + defer response.Body.Close() + + err = writeResponse(conn, response) + if err != nil { + return + } +} + +func getRequestURL(conn io.Reader) (string, error) { + scanner := bufio.NewScanner(conn) + if ok := scanner.Scan(); !ok { + return "", scanner.Err() + } + + rawURL := scanner.Text() + if strings.Contains(rawURL, "://") { + return rawURL, nil + } + + return fmt.Sprintf("gemini://%s", rawURL), nil +} + +func writeResponse(conn io.Writer, response Response) error { + _, err := fmt.Fprintf(conn, "%d %s\r\n", response.Status, response.Meta) + if err != nil { + return fmt.Errorf("failed to write header line to the client: %v", err) + } + + _, err = io.Copy(conn, response.Body) + if err != nil { + return fmt.Errorf("failed to write the response body to the client: %v", err) + } + + return nil +} -- cgit v1.2.3