aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYotam Nachum <me@yotam.net>2019-10-26 13:39:56 +0300
committerYotam Nachum <me@yotam.net>2019-10-26 13:39:56 +0300
commit1c7e11ca599d2cf2783639ace662b7039d71a182 (patch)
tree2fdb099fb2859d8f5af0089235b0cfa8ed474235
downloadgo-gemini-1c7e11ca599d2cf2783639ace662b7039d71a182.tar.gz
go-gemini-1c7e11ca599d2cf2783639ace662b7039d71a182.zip
Initial commit
-rw-r--r--client.go107
-rw-r--r--client_test.go77
-rw-r--r--gemini.go37
-rw-r--r--gemini_test.go23
-rw-r--r--go.mod5
-rw-r--r--go.sum2
-rw-r--r--resources/tests/simple_response2
-rw-r--r--server.go118
8 files changed, 371 insertions, 0 deletions
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
+}