finger.go (7669B)
1 // GoToSocial 2 // Copyright (C) GoToSocial Authors admin@gotosocial.org 3 // SPDX-License-Identifier: AGPL-3.0-or-later 4 // 5 // This program is free software: you can redistribute it and/or modify 6 // it under the terms of the GNU Affero General Public License as published by 7 // the Free Software Foundation, either version 3 of the License, or 8 // (at your option) any later version. 9 // 10 // This program is distributed in the hope that it will be useful, 11 // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 // GNU Affero General Public License for more details. 14 // 15 // You should have received a copy of the GNU Affero General Public License 16 // along with this program. If not, see <http://www.gnu.org/licenses/>. 17 18 package transport 19 20 import ( 21 "context" 22 "encoding/xml" 23 "fmt" 24 "io" 25 "net/http" 26 "net/url" 27 28 apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" 29 apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" 30 "github.com/superseriousbusiness/gotosocial/internal/gtserror" 31 ) 32 33 // webfingerURLFor returns the URL to try a webfinger request against, as 34 // well as if the URL was retrieved from cache. When the URL is retrieved 35 // from cache we don't have to try and do host-meta discovery 36 func (t *transport) webfingerURLFor(targetDomain string) (string, bool) { 37 url := "https://" + targetDomain + "/.well-known/webfinger" 38 39 wc := t.controller.state.Caches.GTS.Webfinger() 40 // We're doing the manual locking/unlocking here to be able to 41 // safely call Cache.Get instead of Get, as the latter updates the 42 // item expiry which we don't want to do here 43 wc.Lock() 44 item, ok := wc.Cache.Get(targetDomain) 45 wc.Unlock() 46 47 if ok { 48 url = item.Value 49 } 50 51 return url, ok 52 } 53 54 func prepWebfingerReq(ctx context.Context, loc, domain, username string) (*http.Request, error) { 55 req, err := http.NewRequestWithContext(ctx, http.MethodGet, loc, nil) 56 if err != nil { 57 return nil, err 58 } 59 60 value := url.QueryEscape("acct:" + username + "@" + domain) 61 req.URL.RawQuery = "resource=" + value 62 63 // Prefer application/jrd+json, fall back to application/json. 64 // See https://www.rfc-editor.org/rfc/rfc7033#section-10.2. 65 // 66 // Some implementations don't handle multiple accept headers properly, 67 // including Gin itself. So concat the accept header with a comma 68 // instead which seems to work reliably 69 req.Header.Add("Accept", string(apiutil.AppJRDJSON)+","+string(apiutil.AppJSON)) 70 req.Header.Set("Host", req.URL.Host) 71 72 return req, nil 73 } 74 75 func (t *transport) Finger(ctx context.Context, targetUsername string, targetDomain string) ([]byte, error) { 76 // Generate new GET request 77 url, cached := t.webfingerURLFor(targetDomain) 78 req, err := prepWebfingerReq(ctx, url, targetDomain, targetUsername) 79 if err != nil { 80 return nil, err 81 } 82 83 // Perform the HTTP request 84 rsp, err := t.GET(req) 85 if err != nil { 86 return nil, err 87 } 88 defer rsp.Body.Close() 89 90 // Check if the request succeeded so we can bail out early or if we explicitly 91 // got a "this resource is gone" response which will happen when a user has 92 // deleted the account 93 if rsp.StatusCode == http.StatusOK || rsp.StatusCode == http.StatusGone { 94 if cached { 95 // If we got a response we consider successful on a cached URL, i.e one set 96 // by us later on when a host-meta based webfinger request succeeded, set it 97 // again here to renew the TTL 98 t.controller.state.Caches.GTS.Webfinger().Set(targetDomain, url) 99 } 100 if rsp.StatusCode == http.StatusGone { 101 return nil, fmt.Errorf("account has been deleted/is gone") 102 } 103 return io.ReadAll(rsp.Body) 104 } 105 106 // From here on out, we're handling different failure scenarios and 107 // deciding whether we should do a host-meta based fallback or not 108 109 // Response status codes >= 500 are returned as errors by the wrapped HTTP client. 110 // 111 // if (rsp.StatusCode >= 500 && rsp.StatusCode < 600) || cached { 112 // In case we got a 5xx, bail out irrespective of if the value 113 // was cached or not. The target may be broken or be signalling 114 // us to back-off. 115 // 116 // If it's any error but the URL was cached, bail out too 117 // return nil, gtserror.NewResponseError(rsp) 118 // } 119 120 // So far we've failed to get a successful response from the expected 121 // webfinger endpoint. Lets try and discover the webfinger endpoint 122 // through /.well-known/host-meta 123 host, err := t.webfingerFromHostMeta(ctx, targetDomain) 124 if err != nil { 125 return nil, fmt.Errorf("failed to discover webfinger URL fallback for: %s through host-meta: %w", targetDomain, err) 126 } 127 128 // Check if the original and host-meta URL are the same. If they 129 // are there's no sense in us trying the request again as it just 130 // failed 131 if host == url { 132 return nil, fmt.Errorf("webfinger discovery on %s returned endpoint we already tried: %s", targetDomain, host) 133 } 134 135 // Now that we have a different URL for the webfinger 136 // endpoint, try the request against that endpoint instead 137 req, err = prepWebfingerReq(ctx, host, targetDomain, targetUsername) 138 if err != nil { 139 return nil, err 140 } 141 142 // Perform the HTTP request 143 rsp, err = t.GET(req) 144 if err != nil { 145 return nil, err 146 } 147 defer rsp.Body.Close() 148 149 if rsp.StatusCode != http.StatusOK { 150 // A HTTP 410 indicates we got a response to our webfinger query, but the resource 151 // we asked for is gone. This means the endpoint itself is valid and we should 152 // cache it for future queries to the same domain 153 if rsp.StatusCode == http.StatusGone { 154 t.controller.state.Caches.GTS.Webfinger().Set(targetDomain, host) 155 return nil, fmt.Errorf("account has been deleted/is gone") 156 } 157 // We've reached the end of the line here, both the original request 158 // and our attempt to resolve it through the fallback have failed 159 return nil, gtserror.NewFromResponse(rsp) 160 } 161 162 // Set the URL in cache here, since host-meta told us this should be the 163 // valid one, it's different from the default and our request to it did 164 // not fail in any manner 165 t.controller.state.Caches.GTS.Webfinger().Set(targetDomain, host) 166 167 return io.ReadAll(rsp.Body) 168 } 169 170 func (t *transport) webfingerFromHostMeta(ctx context.Context, targetDomain string) (string, error) { 171 // Build the request for the host-meta endpoint 172 hmurl := "https://" + targetDomain + "/.well-known/host-meta" 173 req, err := http.NewRequestWithContext(ctx, http.MethodGet, hmurl, nil) 174 if err != nil { 175 return "", err 176 } 177 178 // We're doing XML 179 req.Header.Add("Accept", string(apiutil.AppXML)) 180 req.Header.Add("Accept", "application/xrd+xml") 181 req.Header.Set("Host", req.URL.Host) 182 183 // Perform the HTTP request 184 rsp, err := t.GET(req) 185 if err != nil { 186 return "", err 187 } 188 defer rsp.Body.Close() 189 190 // Doesn't look like host-meta is working for this instance 191 if rsp.StatusCode != http.StatusOK { 192 return "", fmt.Errorf("GET request for %s failed: %s", req.URL.String(), rsp.Status) 193 } 194 195 e := xml.NewDecoder(rsp.Body) 196 var hm apimodel.HostMeta 197 if err := e.Decode(&hm); err != nil { 198 // We got something, but it's not a host-meta document we understand 199 return "", fmt.Errorf("failed to decode host-meta response for %s at %s: %w", targetDomain, req.URL.String(), err) 200 } 201 202 for _, link := range hm.Link { 203 // Based on what we currently understand, there should not be more than one 204 // of these with Rel="lrdd" in a host-meta document 205 if link.Rel == "lrdd" { 206 u, err := url.Parse(link.Template) 207 if err != nil { 208 return "", fmt.Errorf("lrdd link is not a valid url: %w", err) 209 } 210 // Get rid of the query template, we only want the scheme://host/path part 211 u.RawQuery = "" 212 urlStr := u.String() 213 return urlStr, nil 214 } 215 } 216 return "", fmt.Errorf("no webfinger URL found") 217 }