diff options
Diffstat (limited to 'internal/transport/finger.go')
-rw-r--r-- | internal/transport/finger.go | 159 |
1 files changed, 149 insertions, 10 deletions
diff --git a/internal/transport/finger.go b/internal/transport/finger.go index 4e6594df4..6631ff8f1 100644 --- a/internal/transport/finger.go +++ b/internal/transport/finger.go @@ -20,29 +20,61 @@ package transport import ( "context" + "encoding/xml" "fmt" "io" "net/http" + "net/url" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" ) -func (t *transport) Finger(ctx context.Context, targetUsername string, targetDomain string) ([]byte, error) { - // Prepare URL string - urlStr := "https://" + - targetDomain + - "/.well-known/webfinger?resource=acct:" + - targetUsername + "@" + targetDomain +// webfingerURLFor returns the URL to try a webfinger request against, as +// well as if the URL was retrieved from cache. When the URL is retrieved +// from cache we don't have to try and do host-meta discovery +func (t *transport) webfingerURLFor(targetDomain string) (string, bool) { + url := "https://" + targetDomain + "/.well-known/webfinger" + + wc := t.controller.state.Caches.GTS.Webfinger() + // We're doing the manual locking/unlocking here to be able to + // safely call Cache.Get instead of Get, as the latter updates the + // item expiry which we don't want to do here + wc.Lock() + item, ok := wc.Cache.Get(targetDomain) + wc.Unlock() + + if ok { + url = item.Value + } - // Generate new GET request from URL string - req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) + return url, ok +} + +func prepWebfingerReq(ctx context.Context, loc, domain, username string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, loc, nil) if err != nil { return nil, err } + + value := url.QueryEscape("acct:" + username + "@" + domain) + req.URL.RawQuery = "resource=" + value + req.Header.Add("Accept", string(apiutil.AppJSON)) req.Header.Add("Accept", "application/jrd+json") req.Header.Set("Host", req.URL.Host) + return req, nil +} + +func (t *transport) Finger(ctx context.Context, targetUsername string, targetDomain string) ([]byte, error) { + // Generate new GET request + url, cached := t.webfingerURLFor(targetDomain) + req, err := prepWebfingerReq(ctx, url, targetDomain, targetUsername) + if err != nil { + return nil, err + } + // Perform the HTTP request rsp, err := t.GET(req) if err != nil { @@ -50,10 +82,117 @@ func (t *transport) Finger(ctx context.Context, targetUsername string, targetDom } defer rsp.Body.Close() - // Check for an expected status code + // Check if the request succeeded so we can bail out early + if rsp.StatusCode == http.StatusOK { + if cached { + // If we got a success on a cached URL, i.e one set by us later on when + // a host-meta based webfinger request succeeded, set it again here to + // renew the TTL + t.controller.state.Caches.GTS.Webfinger().Set(targetDomain, url) + } + return io.ReadAll(rsp.Body) + } + + // From here on out, we're handling different failure scenarios and + // deciding whether we should do a host-meta based fallback or not + + if (rsp.StatusCode >= 500 && rsp.StatusCode < 600) || cached { + // In case we got a 5xx, bail out irrespective of if the value + // was cached or not. The target may be broken or be signalling + // us to back-off. + // + // If it's any error but the URL was cached, bail out too + return nil, fmt.Errorf("GET request to %s failed: %s", req.URL.String(), rsp.Status) + } + + // So far we've failed to get a successful response from the expected + // webfinger endpoint. Lets try and discover the webfinger endpoint + // through /.well-known/host-meta + host, err := t.webfingerFromHostMeta(ctx, targetDomain) + if err != nil { + return nil, fmt.Errorf("failed to discover webfinger URL fallback for: %s through host-meta: %w", targetDomain, err) + } + + // Check if the original and host-meta URL are the same. If they + // are there's no sense in us trying the request again as it just + // failed + if host == url { + return nil, fmt.Errorf("webfinger discovery on %s returned endpoint we already tried: %s", targetDomain, host) + } + + // Now that we have a different URL for the webfinger + // endpoint, try the request against that endpoint instead + req, err = prepWebfingerReq(ctx, host, targetDomain, targetUsername) + if err != nil { + return nil, err + } + + // Perform the HTTP request + rsp, err = t.GET(req) + if err != nil { + return nil, err + } + defer rsp.Body.Close() + if rsp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GET request to %s failed: %s", urlStr, rsp.Status) + // We've reached the end of the line here, both the original request + // and our attempt to resolve it through the fallback have failed + return nil, fmt.Errorf("GET request to %s failed: %s", req.URL.String(), rsp.Status) } + // Set the URL in cache here, since host-meta told us this should be the + // valid one, it's different from the default and our request to it did + // not fail in any manner + t.controller.state.Caches.GTS.Webfinger().Set(targetDomain, host) + return io.ReadAll(rsp.Body) } + +func (t *transport) webfingerFromHostMeta(ctx context.Context, targetDomain string) (string, error) { + // Build the request for the host-meta endpoint + hmurl := "https://" + targetDomain + "/.well-known/host-meta" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, hmurl, nil) + if err != nil { + return "", err + } + + // We're doing XML + req.Header.Add("Accept", string(apiutil.AppXML)) + req.Header.Add("Accept", "application/xrd+xml") + req.Header.Set("Host", req.URL.Host) + + // Perform the HTTP request + rsp, err := t.GET(req) + if err != nil { + return "", err + } + defer rsp.Body.Close() + + // Doesn't look like host-meta is working for this instance + if rsp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GET request for %s failed: %s", req.URL.String(), rsp.Status) + } + + e := xml.NewDecoder(rsp.Body) + var hm apimodel.HostMeta + if err := e.Decode(&hm); err != nil { + // We got something, but it's not a host-meta document we understand + return "", fmt.Errorf("failed to decode host-meta response for %s at %s: %w", targetDomain, req.URL.String(), err) + } + + for _, link := range hm.Link { + // Based on what we currently understand, there should not be more than one + // of these with Rel="lrdd" in a host-meta document + if link.Rel == "lrdd" { + u, err := url.Parse(link.Template) + if err != nil { + return "", fmt.Errorf("lrdd link is not a valid url: %w", err) + } + // Get rid of the query template, we only want the scheme://host/path part + u.RawQuery = "" + urlStr := u.String() + return urlStr, nil + } + } + return "", fmt.Errorf("no webfinger URL found") +} |