package gemini import ( "bytes" "context" "crypto/tls" "errors" "io" "net" neturl "net/url" "strconv" "strings" "tildegit.org/tjp/sliderule/internal/types" ) // 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 is a usable Client with no client TLS certificate and which will not // follow redirects. type Client struct { MaxRedirects int tlsConf *tls.Config } // NewClient creates a gemini Client with the given TLS configuration and default MaxRedirects. func NewClient(tlsConf *tls.Config) Client { if tlsConf != nil && !tlsConf.InsecureSkipVerify { tlsConf = tlsConf.Clone() tlsConf.InsecureSkipVerify = true } return Client{tlsConf: tlsConf, MaxRedirects: DefaultMaxRedirects} } // DefaultMaxRedirects is the number of chained redirects a Client will perform for a // single request by default. This can be changed by altering the MaxRedirects field. const DefaultMaxRedirects int = 2 var ExceededMaxRedirects = errors.New("gemini.Client: exceeded MaxRedirects") // 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. // // This method will not automatically follow redirects or cache permanent failures or // redirects. func (client Client) RoundTrip(ctx context.Context, request *types.Request) (*types.Response, error) { if request.Scheme != "gemini" && request.Scheme != "titan" && request.Scheme != "" { return nil, errors.New("non-gemini protocols not supported") } host := request.Host if _, port, _ := net.SplitHostPort(host); port == "" { host = net.JoinHostPort(host, "1965") } tlsConf := client.tlsConf if tlsConf == nil { tlsConf = &tls.Config{InsecureSkipVerify: true} } conn, err := (&tls.Dialer{Config: tlsConf}).DialContext(ctx, "tcp", host) if err != nil { return nil, err } defer conn.Close() request.RemoteAddr = conn.RemoteAddr() st := conn.(*tls.Conn).ConnectionState() request.TLSState = &st destURL := *request.URL var body []byte if request.Scheme == "titan" { var err error if bodyrdr, ok := request.Meta.(io.Reader); ok { body, err = io.ReadAll(bodyrdr) if err != nil { return nil, err } if err := close(request.Meta); err != nil { return nil, err } path, params := pathparams(destURL.Path) params["size"] = strconv.Itoa(len(body)) destURL.Path = assemblepath(path, params) } else { body = []byte{} } } if _, err := conn.Write([]byte(destURL.String() + "\r\n")); err != nil { return nil, err } if _, err := conn.Write(body); 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 // closing the connection bodybuf, err := io.ReadAll(response.Body) if err != nil { return nil, err } response.Body = bytes.NewBuffer(bodybuf) response.Request = request return response, nil } // Fetch parses a URL string and fetches the gemini resource. // // It will resolve any redirects along the way, up to client.MaxRedirects. func (c Client) Fetch(ctx context.Context, url string) (*types.Response, error) { u, err := neturl.Parse(url) if err != nil { return nil, err } for i := 0; i <= c.MaxRedirects; i += 1 { response, err := c.RoundTrip(ctx, &types.Request{URL: u}) if err != nil { return nil, err } if !c.IsRedirect(response) { return response, nil } prev := u u, err = neturl.Parse(response.Meta.(string)) if err != nil { return nil, err } u = prev.ResolveReference(u) } return nil, ExceededMaxRedirects } func (c Client) IsRedirect(response *types.Response) bool { return ResponseCategoryForStatus(response.Status) == ResponseCategoryRedirect } func pathparams(basepath string) (string, map[string]string) { params := map[string]string{} path, paramstr, found := strings.Cut(basepath, ";") if !found { return path, params } for _, pairstr := range strings.Split(paramstr, ";") { key, val, found := strings.Cut(pairstr, "=") if found { params[key] = val } } return path, params } func assemblepath(basepath string, params map[string]string) string { path := strings.Builder{} _, _ = path.WriteString(basepath) for key, val := range params { _ = path.WriteByte(';') _, _ = path.WriteString(key) _ = path.WriteByte('=') _, _ = path.WriteString(val) } return path.String() } func close(closer any) error { if cl, ok := closer.(io.Closer); ok { return cl.Close() } return nil }