package deadlinks import ( "context" "errors" "fmt" "io" "git.sr.ht/~adnano/go-gemini" ) // Client is a thread-safe type which fetches a resource at the given URL, // returning its MIME type and body. If the MIME type is not known then empty // string should be returned. type Client interface { Get(context.Context, URL) (string, io.ReadCloser, error) } // ClientOpts are optional fields which can be provided to NewClient. A nil // ClientOpts is equivalent to an empty one. type ClientOpts struct { // GeminiClient will be used for retrieving resources via the gemini // protocol. // // Defaults to `new(gemini.Client)`. GeminiClient interface { Do(context.Context, *gemini.Request) (*gemini.Response, error) } // MaxRedirects indicates the maximum number of redirects which will be // allowed when resolving a resource. A negative value indicates no // redirects are allowed. // // Default: 10. MaxRedirects int } func (o *ClientOpts) withDefaults() *ClientOpts { if o == nil { o = new(ClientOpts) } if o.GeminiClient == nil { o.GeminiClient = new(gemini.Client) } if o.MaxRedirects == 0 { o.MaxRedirects = 10 } return o } type client struct { opts ClientOpts } // NewClient initializes and returns a Client which supports commonly used // transport protocols. The returned Client will error when it encounters an // unfamiliar protocol. // // Supported URL schemas: // - gemini // - http/https (TODO) func NewClient(opts *ClientOpts) Client { return &client{*opts.withDefaults()} } func (c *client) getGemini( ctx context.Context, url URL, redirectDepth int, ) ( string, io.ReadCloser, error, ) { req, err := gemini.NewRequest(string(url)) if err != nil { return "", nil, fmt.Errorf("building request: %w", err) } // TODO allow specifying client cert res, err := c.opts.GeminiClient.Do(ctx, req) if err != nil { return "", nil, fmt.Errorf("performing request: %w", err) } // all status numbers are grouped by their first digit, and actions taken // can be entirely based on that. switch res.Status / 10 { case 1: // input required // Assume that input required is fine, even though we don't know the // MIME type. return "", res.Body, nil case 2: // success return res.Meta, res.Body, nil case 3: // redirect defer res.Body.Close() if redirectDepth >= c.opts.MaxRedirects { return "", nil, errors.New("too many redirects") } newURL, err := url.ResolveReference(res.Meta) if err != nil { return "", nil, fmt.Errorf("resolving redirect URL %q: %w", res.Meta, err) } return c.get(ctx, newURL, redirectDepth+1) default: defer res.Body.Close() return "", nil, fmt.Errorf( "response code %d (%v): %q", res.Status, res.Status, res.Meta, ) } } func (c *client) get( ctx context.Context, url URL, redirectDepth int, ) ( string, io.ReadCloser, error, ) { scheme := url.toStd().Scheme switch scheme { case "gemini": return c.getGemini(ctx, url, redirectDepth) default: return "", nil, fmt.Errorf("unsupported scheme %q", scheme) } } func (c *client) Get( ctx context.Context, url URL, ) ( string, io.ReadCloser, error, ) { return c.get(ctx, url, 0) }