gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

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 }