From e03e4037d2745dfa803b00728f71a75f31eb84e8 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Thu, 28 Dec 2023 17:11:42 +0100 Subject: [PATCH] Implement basic Client which supports the gemini protocol --- client.go | 140 +++++++++++++++++++++++++++++++++++++++++++++++++++ deadlinks.go | 17 +++++++ go.mod | 3 ++ go.sum | 9 ++++ store.go | 4 +- 5 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 client.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..f3e8082 --- /dev/null +++ b/client.go @@ -0,0 +1,140 @@ +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) +} diff --git a/deadlinks.go b/deadlinks.go index dd96582..9e551bc 100644 --- a/deadlinks.go +++ b/deadlinks.go @@ -50,6 +50,23 @@ func parseURLs(urlStrs []string) ([]URL, error) { return res, errors.Join(errs...) } +func (u URL) toStd() *url.URL { + uu, err := url.Parse(string(u)) + if err != nil { + panic(fmt.Sprintf("parsing URL %q: %v", u, err)) + } + return uu +} + +// ResolveReference is equivalend to the method of the same name in `net/url`. +func (u URL) ResolveReference(u2Str string) (URL, error) { + u2, err := url.Parse(u2Str) + if err != nil { + return "", err + } + return URL(u.toStd().ResolveReference(u2).String()), nil +} + // ResourceStatus describes what state a particular Resource is in. type ResourceStatus int diff --git a/go.mod b/go.mod index 36fbbf5..11a51b3 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( code.betamike.com/mediocregopher/mediocre-go-lib v0.0.0-20231226160338-0b5bdf3dfb03 // indirect + git.sr.ht/~adnano/go-gemini v0.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/mattn/go-sqlite3 v1.14.19 // indirect @@ -11,5 +12,7 @@ require ( github.com/rubenv/sql-migrate v1.6.0 // indirect github.com/stretchr/objx v0.5.0 // indirect github.com/stretchr/testify v1.8.4 // indirect + golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect + golang.org/x/text v0.3.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1006625..d77eaea 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ code.betamike.com/mediocregopher/mediocre-go-lib v0.0.0-20231226155808-e8376ef26 code.betamike.com/mediocregopher/mediocre-go-lib v0.0.0-20231226155808-e8376ef263a7/go.mod h1:GJhpoMNnN/OT6O9NmeQBV02yq9kQP8zPyY1IvsslHak= code.betamike.com/mediocregopher/mediocre-go-lib v0.0.0-20231226160338-0b5bdf3dfb03 h1:wJ6X1vc289RpHVGClD1P33yijPoNIdgCXbTn7DjVWYs= code.betamike.com/mediocregopher/mediocre-go-lib v0.0.0-20231226160338-0b5bdf3dfb03/go.mod h1:GJhpoMNnN/OT6O9NmeQBV02yq9kQP8zPyY1IvsslHak= +git.sr.ht/~adnano/go-gemini v0.2.3 h1:oJ+Y0/mheZ4Vg0ABjtf5dlmvq1yoONStiaQvmWWkofc= +git.sr.ht/~adnano/go-gemini v0.2.3/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -21,6 +23,13 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/store.go b/store.go index d875f7c..0dc7567 100644 --- a/store.go +++ b/store.go @@ -80,8 +80,8 @@ var migrations = &migrate.MemoryMigrationSource{Migrations: []*migrate.Migration }, }} -// SQLiteSQLiteStoreOpts are optional fields which can be provided to NewSQLiteStore. -// A nil SQLiteSQLiteStoreOpts is equivalent to an empty one. +// SQLiteSQLiteStoreOpts are optional fields which can be provided to +// NewSQLiteStore. A nil SQLiteSQLiteStoreOpts is equivalent to an empty one. type SQLiteStoreOpts struct { // Path to the database file to use. //