From 2cac5a4613ab51a5ac33a16cb54bb1210be9e8ce Mon Sep 17 00:00:00 2001 From: Daenney Date: Mon, 11 Sep 2023 18:38:31 +0200 Subject: [PATCH] [feature] Support Actor URIs for webfinger queries (#2187) * [feature] Support Actor URIs for webfinger queries It's now possible to pass an Actor URI as the resource to query for when doing a webfinger query. The code now extracts the username and domain from the URI. The URI needs to be fully qualified, including having a scheme of http or https to be recognised as such. The acct scheme is handled as we used to, including dealing with an erroneous leading @ on the username. We retain the ability to handle resources without a scheme by parsing them again with the acct scheme if the original parse failed. This can happen due to parsing ambiguities when dealing with a string like user@domain.tld:port. * [bugfix] Remove debugging changes * [chore] Make TestExtractNamestring table-driven * [chore] Unnest Trim and Split for readability --- .../wellknown/webfinger/webfingerget_test.go | 39 +++++ internal/util/namestring.go | 85 +++++++-- internal/util/namestring_test.go | 164 ++++++++++-------- 3 files changed, 203 insertions(+), 85 deletions(-) diff --git a/internal/api/wellknown/webfinger/webfingerget_test.go b/internal/api/wellknown/webfinger/webfingerget_test.go index 5b1cc47dd..fb450470f 100644 --- a/internal/api/wellknown/webfinger/webfingerget_test.go +++ b/internal/api/wellknown/webfinger/webfingerget_test.go @@ -143,6 +143,45 @@ func (suite *WebfingerGetTestSuite) TestFingerUser() { }`, resp) } +func (suite *WebfingerGetTestSuite) TestFingerUserActorURI() { + targetAccount := suite.testAccounts["local_account_1"] + host := config.GetHost() + + tests := []struct { + resource string + }{ + {resource: fmt.Sprintf("https://%s/@%s", host, targetAccount.Username)}, + {resource: fmt.Sprintf("https://%s/users/%s", host, targetAccount.Username)}, + } + + for _, tt := range tests { + tt := tt + suite.Run(tt.resource, func() { + requestPath := fmt.Sprintf("/%s?resource=%s", webfinger.WebfingerBasePath, tt.resource) + resp := suite.finger(requestPath) + suite.Equal(`{ + "subject": "acct:the_mighty_zork@localhost:8080", + "aliases": [ + "http://localhost:8080/users/the_mighty_zork", + "http://localhost:8080/@the_mighty_zork" + ], + "links": [ + { + "rel": "http://webfinger.net/rel/profile-page", + "type": "text/html", + "href": "http://localhost:8080/@the_mighty_zork" + }, + { + "rel": "self", + "type": "application/activity+json", + "href": "http://localhost:8080/users/the_mighty_zork" + } + ] +}`, resp) + }) + } +} + func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByHost() { targetAccount := suite.funkifyAccountDomain("gts.example.org", "example.org") requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, config.GetHost()) diff --git a/internal/util/namestring.go b/internal/util/namestring.go index 6109f8ebb..e510fe43f 100644 --- a/internal/util/namestring.go +++ b/internal/util/namestring.go @@ -19,6 +19,7 @@ import ( "fmt" + "net/url" "strings" "github.com/superseriousbusiness/gotosocial/internal/regexes" @@ -40,19 +41,83 @@ func ExtractNamestringParts(mention string) (username, host string, err error) { } } -// ExtractWebfingerParts returns username test_user and -// domain example.org from a string like acct:test_user@example.org, -// or acct:@test_user@example.org. +// ExtractWebfingerParts returns the username and domain from either an +// account query or an actor URI. // -// If nothing is extracted, it will return an error. +// All implementations in the wild generate webfinger account resource +// queries with the "acct" scheme and without a leading "@"" on the username. +// This is also the format the "subject" in a webfinger response adheres to. +// +// Despite this fact, we're being permissive about a single leading @. This +// makes a query for acct:user@domain.tld and acct:@user@domain.tld +// equivalent. But a query for acct:@@user@domain.tld will have its username +// returned with the @ prefix. +// +// We also permit a resource of user@domain.tld or @user@domain.tld, without +// a scheme. In that case it gets interpreted as if it was using the "acct" +// scheme. +// +// When parsing fails, an error is returned. func ExtractWebfingerParts(webfinger string) (username, host string, err error) { - // remove the acct: prefix if it's present - webfinger = strings.TrimPrefix(webfinger, "acct:") + orig := webfinger - // prepend an @ if necessary - if webfinger[0] != '@' { - webfinger = "@" + webfinger + u, oerr := url.ParseRequestURI(webfinger) + if oerr != nil { + // Most likely reason for failing to parse is if the "acct" scheme was + // missing but a :port was included. So try an extra time with the scheme. + u, err = url.ParseRequestURI("acct:" + webfinger) + if err != nil { + return "", "", fmt.Errorf("failed to parse %s with acct sheme: %w", orig, oerr) + } } - return ExtractNamestringParts(webfinger) + if u.Scheme == "http" || u.Scheme == "https" { + return ExtractWebfingerPartsFromURI(u) + } + + if u.Scheme != "acct" { + return "", "", fmt.Errorf("unsupported scheme: %s for resource: %s", u.Scheme, orig) + } + + stripped := strings.TrimPrefix(u.Opaque, "@") + userDomain := strings.Split(stripped, "@") + if len(userDomain) != 2 { + return "", "", fmt.Errorf("failed to extract user and domain from: %s", orig) + } + return userDomain[0], userDomain[1], nil +} + +// ExtractWebfingerPartsFromURI returns the user and domain extracted from +// the passed in URI. The URI should be an actor URI. +// +// The domain returned is the hostname, and the user will be extracted +// from either /@test_user or /users/test_user. These two paths match the +// "aliasses" we include in our webfinger response and are also present in +// our "links". +// +// Like with ExtractWebfingerParts, we're being permissive about a single +// leading @. +// +// Errors are returned in case we end up with an empty domain or username. +func ExtractWebfingerPartsFromURI(uri *url.URL) (username, host string, err error) { + host = uri.Host + if host == "" { + return "", "", fmt.Errorf("failed to extract domain from: %s", uri) + } + + // strip any leading slashes + path := strings.TrimLeft(uri.Path, "/") + segs := strings.Split(path, "/") + if segs[0] == "users" { + username = segs[1] + } else { + username = segs[0] + } + + username = strings.TrimPrefix(username, "@") + if username == "" { + return "", "", fmt.Errorf("failed to extract username from: %s", uri) + } + + return } diff --git a/internal/util/namestring_test.go b/internal/util/namestring_test.go index 30381a138..09beaeb8a 100644 --- a/internal/util/namestring_test.go +++ b/internal/util/namestring_test.go @@ -18,6 +18,7 @@ package util_test import ( + "net/url" "testing" "github.com/stretchr/testify/suite" @@ -28,88 +29,101 @@ type NamestringSuite struct { suite.Suite } -func (suite *NamestringSuite) TestExtractWebfingerParts1() { - webfinger := "acct:stonerkitty.monster@stonerkitty.monster" - username, host, err := util.ExtractWebfingerParts(webfinger) - suite.NoError(err) +func (suite *NamestringSuite) TestExtractWebfingerParts() { + tests := []struct { + in, username, domain, err string + }{ + {in: "acct:stonerkitty.monster@stonerkitty.monster", username: "stonerkitty.monster", domain: "stonerkitty.monster"}, + {in: "acct:stonerkitty.monster@stonerkitty.monster:8080", username: "stonerkitty.monster", domain: "stonerkitty.monster:8080"}, + {in: "acct:@stonerkitty.monster@stonerkitty.monster", username: "stonerkitty.monster", domain: "stonerkitty.monster"}, + {in: "stonerkitty.monster@stonerkitty.monster", username: "stonerkitty.monster", domain: "stonerkitty.monster"}, + {in: "stonerkitty.monster@stonerkitty.monster:8080", username: "stonerkitty.monster", domain: "stonerkitty.monster:8080"}, + {in: "@stonerkitty.monster@stonerkitty.monster", username: "stonerkitty.monster", domain: "stonerkitty.monster"}, + {in: "acct:@@stonerkitty.monster@stonerkitty.monster", err: "failed to extract user and domain from: acct:@@stonerkitty.monster@stonerkitty.monster"}, + {in: "acct:@stonerkitty.monster@@stonerkitty.monster", err: "failed to extract user and domain from: acct:@stonerkitty.monster@@stonerkitty.monster"}, + {in: "@@stonerkitty.monster@stonerkitty.monster", err: "failed to extract user and domain from: @@stonerkitty.monster@stonerkitty.monster"}, + {in: "@stonerkitty.monster@@stonerkitty.monster", err: "failed to extract user and domain from: @stonerkitty.monster@@stonerkitty.monster"}, + {in: "s3:stonerkitty.monster@stonerkitty.monster", err: "unsupported scheme: s3 for resource: s3:stonerkitty.monster@stonerkitty.monster"}, + } - suite.Equal("stonerkitty.monster", username) - suite.Equal("stonerkitty.monster", host) + for _, tt := range tests { + tt := tt + suite.Run(tt.in, func() { + suite.T().Parallel() + username, domain, err := util.ExtractWebfingerParts(tt.in) + if tt.err == "" { + suite.NoError(err) + suite.Equal(tt.username, username) + suite.Equal(tt.domain, domain) + } else { + suite.EqualError(err, tt.err) + } + }) + } } -func (suite *NamestringSuite) TestExtractWebfingerParts2() { - webfinger := "@stonerkitty.monster@stonerkitty.monster" - username, host, err := util.ExtractWebfingerParts(webfinger) - suite.NoError(err) +func (suite *NamestringSuite) TestExtractWebfingerPartsFromURI() { + tests := []struct { + in, username, domain, err string + }{ + {in: "https://stonerkitty.monster/users/stonerkitty.monster", username: "stonerkitty.monster", domain: "stonerkitty.monster"}, + {in: "https://stonerkitty.monster/users/@stonerkitty.monster", username: "stonerkitty.monster", domain: "stonerkitty.monster"}, + {in: "https://stonerkitty.monster/@stonerkitty.monster", username: "stonerkitty.monster", domain: "stonerkitty.monster"}, + {in: "https://stonerkitty.monster/@@stonerkitty.monster", username: "@stonerkitty.monster", domain: "stonerkitty.monster"}, + {in: "https://stonerkitty.monster:8080/users/stonerkitty.monster", username: "stonerkitty.monster", domain: "stonerkitty.monster:8080"}, + {in: "https://stonerkitty.monster/users/stonerkitty.monster/evil", username: "stonerkitty.monster", domain: "stonerkitty.monster"}, + {in: "https://stonerkitty.monster/@stonerkitty.monster/evil", username: "stonerkitty.monster", domain: "stonerkitty.monster"}, + {in: "/@stonerkitty.monster", err: "failed to extract domain from: /@stonerkitty.monster"}, + {in: "/users/stonerkitty.monster", err: "failed to extract domain from: /users/stonerkitty.monster"}, + {in: "@stonerkitty.monster", err: "failed to extract domain from: @stonerkitty.monster"}, + {in: "users/stonerkitty.monster", err: "failed to extract domain from: users/stonerkitty.monster"}, + {in: "https://stonerkitty.monster/users/", err: "failed to extract username from: https://stonerkitty.monster/users/"}, + {in: "https://stonerkitty.monster/users/@", err: "failed to extract username from: https://stonerkitty.monster/users/@"}, + {in: "https://stonerkitty.monster/@", err: "failed to extract username from: https://stonerkitty.monster/@"}, + {in: "https://stonerkitty.monster/", err: "failed to extract username from: https://stonerkitty.monster/"}, + } - suite.Equal("stonerkitty.monster", username) - suite.Equal("stonerkitty.monster", host) + for _, tt := range tests { + tt := tt + suite.Run(tt.in, func() { + suite.T().Parallel() + uri, _ := url.Parse(tt.in) + username, domain, err := util.ExtractWebfingerPartsFromURI(uri) + if tt.err == "" { + suite.NoError(err) + suite.Equal(tt.username, username) + suite.Equal(tt.domain, domain) + } else { + suite.EqualError(err, tt.err) + } + }) + } } -func (suite *NamestringSuite) TestExtractWebfingerParts3() { - webfinger := "acct:someone@somewhere" - username, host, err := util.ExtractWebfingerParts(webfinger) - suite.NoError(err) +func (suite *NamestringSuite) TestExtractNamestring() { + tests := []struct { + in, username, host, err string + }{ + {in: "@stonerkitty.monster@stonerkitty.monster", username: "stonerkitty.monster", host: "stonerkitty.monster"}, + {in: "@stonerkitty.monster", username: "stonerkitty.monster"}, + {in: "@someone@somewhere", username: "someone", host: "somewhere"}, + {in: "", err: "couldn't match mention "}, + } - suite.Equal("someone", username) - suite.Equal("somewhere", host) -} - -func (suite *NamestringSuite) TestExtractWebfingerParts4() { - webfinger := "@stoner-kitty.monster@stonerkitty.monster" - username, host, err := util.ExtractWebfingerParts(webfinger) - suite.NoError(err) - - suite.Equal("stoner-kitty.monster", username) - suite.Equal("stonerkitty.monster", host) -} - -func (suite *NamestringSuite) TestExtractWebfingerParts5() { - webfinger := "@stonerkitty.monster" - username, host, err := util.ExtractWebfingerParts(webfinger) - suite.NoError(err) - - suite.Equal("stonerkitty.monster", username) - suite.Empty(host) -} - -func (suite *NamestringSuite) TestExtractWebfingerParts6() { - webfinger := "@@stonerkitty.monster" - _, _, err := util.ExtractWebfingerParts(webfinger) - suite.EqualError(err, "couldn't match mention @@stonerkitty.monster") -} - -func (suite *NamestringSuite) TestExtractNamestringParts1() { - namestring := "@stonerkitty.monster@stonerkitty.monster" - username, host, err := util.ExtractNamestringParts(namestring) - suite.NoError(err) - - suite.Equal("stonerkitty.monster", username) - suite.Equal("stonerkitty.monster", host) -} - -func (suite *NamestringSuite) TestExtractNamestringParts2() { - namestring := "@stonerkitty.monster" - username, host, err := util.ExtractNamestringParts(namestring) - suite.NoError(err) - - suite.Equal("stonerkitty.monster", username) - suite.Empty(host) -} - -func (suite *NamestringSuite) TestExtractNamestringParts3() { - namestring := "@someone@somewhere" - username, host, err := util.ExtractWebfingerParts(namestring) - suite.NoError(err) - - suite.Equal("someone", username) - suite.Equal("somewhere", host) -} - -func (suite *NamestringSuite) TestExtractNamestringParts4() { - namestring := "" - _, _, err := util.ExtractNamestringParts(namestring) - suite.EqualError(err, "couldn't match mention ") + for _, tt := range tests { + tt := tt + suite.Run(tt.in, func() { + suite.T().Parallel() + username, host, err := util.ExtractNamestringParts(tt.in) + if tt.err != "" { + suite.EqualError(err, tt.err) + } else { + suite.NoError(err) + suite.Equal(tt.username, username) + suite.Equal(tt.host, host) + } + }) + } } func TestNamestringSuite(t *testing.T) {