Simple client functionality and an example.
continuous-integration/drone/push Build is passing Details

This commit is contained in:
tjpcc 2023-01-11 10:12:32 -07:00
parent e46658d475
commit cc0c7e6eb5
4 changed files with 141 additions and 2 deletions

View File

@ -1,6 +1,8 @@
- [x] server
- [x] TLS configuration from cert+key files
- [ ] client
- [x] client
- [ ] follow redirects
- [ ] reject non-gemini requests
- [x] contrib - filesystem handling
- [x] serving files
- [x] directory index files

65
examples/fetch/main.go Normal file
View File

@ -0,0 +1,65 @@
package main
import (
"fmt"
"io"
"log"
"net/url"
"os"
"strings"
"tildegit.org/tjp/gus/gemini"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "usage: %s <gemini url>\n", os.Args[0])
}
certfile, keyfile := envConfig()
// build a client
var client gemini.Client
if certfile != "" && keyfile != "" {
tlsConf, err := gemini.FileTLS(certfile, keyfile)
if err != nil {
log.Fatal(err)
}
client = gemini.NewClient(tlsConf)
}
// parse the URL and build the request
request := &gemini.Request{URL: buildURL()}
// fetch the response
response, err := client.RoundTrip(request)
if err != nil {
log.Fatal(err)
}
if response.Status != gemini.StatusSuccess {
log.Fatalf("%d %s\n", response.Status, response.Meta)
}
//io.Copy(os.Stdout, response)
buf, err := io.ReadAll(response)
fmt.Printf("response: %s\n", buf)
}
func envConfig() (string, string) {
return os.Getenv("SERVER_CERTIFICATE"), os.Getenv("SERVER_PRIVATEKEY")
}
func buildURL() *url.URL {
raw := os.Args[1]
if strings.HasPrefix(raw, "//") {
raw = "gemini:" + raw
}
u, err := url.Parse(raw)
if err != nil {
log.Fatal(err)
}
return u
}

69
gemini/client.go Normal file
View File

@ -0,0 +1,69 @@
package gemini
import (
"bytes"
"crypto/tls"
"errors"
"io"
"net"
)
// Client is used for sending gemini requests and parsing gemini responses.
//
// It carries no state and is usable and reusable simultaneously by multiple goroutines.
// The only reason you might create more than one Client is to support separate TLS-cert
// driven identities.
//
// The zero value of Client is usable, it simply has no client TLS cert.
type Client struct {
tlsConf *tls.Config
}
// Create a gemini Client with the given TLS configuration.
func NewClient(tlsConf *tls.Config) Client {
return Client{tlsConf: tlsConf}
}
// RoundTrip sends a single gemini request to the correct server and returns its response.
//
// It also populates the TLSState and RemoteAddr fields on the request - the only field
// it needs populated beforehand is the URL.
func (client Client) RoundTrip(request *Request) (*Response, error) {
if request.Scheme != "gemini" && request.Scheme != "" {
return nil, errors.New("non-gemini protocols not supported")
}
host := request.Host
if _, port, _ := net.SplitHostPort(host); port == "" {
host += ":1965"
}
conn, err := tls.Dial("tcp", host, client.tlsConf)
if err != nil {
return nil, err
}
defer conn.Close()
request.RemoteAddr = conn.RemoteAddr()
st := conn.ConnectionState()
request.TLSState = &st
if _, err := conn.Write([]byte(request.URL.String() + "\r\n")); err != nil {
return nil, err
}
response, err := ParseResponse(conn)
if err != nil {
return nil, err
}
// read and store the request body in full or we may miss doing so before the
// connection gets closed.
bodybuf, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
response.Body = bytes.NewBuffer(bodybuf)
return response, nil
}

View File

@ -283,6 +283,9 @@ func ParseResponse(rdr io.Reader) (*Response, error) {
if hdrLine[len(hdrLine)-2] != '\r' {
return nil, InvalidResponseLineEnding
}
if hdrLine[2] != ' ' {
return nil, InvalidResponseHeaderLine
}
hdrLine = hdrLine[:len(hdrLine)-2]
status, err := strconv.Atoi(string(hdrLine[:2]))
@ -292,7 +295,7 @@ func ParseResponse(rdr io.Reader) (*Response, error) {
return &Response{
Status: Status(status),
Meta: string(hdrLine[2:]),
Meta: string(hdrLine[3:]),
Body: bufrdr,
}, nil
}