diff options
Diffstat (limited to 'internal')
29 files changed, 3709 insertions, 623 deletions
diff --git a/internal/api/client/accounts/accountdelete_test.go b/internal/api/client/accounts/accountdelete_test.go index fe328487b..d8889b680 100644 --- a/internal/api/client/accounts/accountdelete_test.go +++ b/internal/api/client/accounts/accountdelete_test.go @@ -44,7 +44,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandler() {  	}  	bodyBytes := requestBody.Bytes()  	recorder := httptest.NewRecorder() -	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeleteAccountPath, w.FormDataContentType()) +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeletePath, w.FormDataContentType())  	// call the handler  	suite.accountsModule.AccountDeletePOSTHandler(ctx) @@ -66,7 +66,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandlerWrongPassword()  	}  	bodyBytes := requestBody.Bytes()  	recorder := httptest.NewRecorder() -	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeleteAccountPath, w.FormDataContentType()) +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeletePath, w.FormDataContentType())  	// call the handler  	suite.accountsModule.AccountDeletePOSTHandler(ctx) @@ -86,7 +86,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandlerNoPassword() {  	}  	bodyBytes := requestBody.Bytes()  	recorder := httptest.NewRecorder() -	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeleteAccountPath, w.FormDataContentType()) +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeletePath, w.FormDataContentType())  	// call the handler  	suite.accountsModule.AccountDeletePOSTHandler(ctx) diff --git a/internal/api/client/accounts/accounts.go b/internal/api/client/accounts/accounts.go index 298104a8d..9bb13231d 100644 --- a/internal/api/client/accounts/accounts.go +++ b/internal/api/client/accounts/accounts.go @@ -25,53 +25,33 @@ import (  )  const ( -	// LimitKey is for setting the return amount limit for eg., requesting an account's statuses -	LimitKey = "limit" -	// ExcludeRepliesKey is for specifying whether to exclude replies in a list of returned statuses by an account. -	ExcludeRepliesKey = "exclude_replies" -	// ExcludeReblogsKey is for specifying whether to exclude reblogs in a list of returned statuses by an account.  	ExcludeReblogsKey = "exclude_reblogs" -	// PinnedKey is for specifying whether to include pinned statuses in a list of returned statuses by an account. -	PinnedKey = "pinned" -	// MaxIDKey is for specifying the maximum ID of the status to retrieve. -	MaxIDKey = "max_id" -	// MinIDKey is for specifying the minimum ID of the status to retrieve. -	MinIDKey = "min_id" -	// OnlyMediaKey is for specifying that only statuses with media should be returned in a list of returned statuses by an account. -	OnlyMediaKey = "only_media" -	// OnlyPublicKey is for specifying that only statuses with visibility public should be returned in a list of returned statuses by account. -	OnlyPublicKey = "only_public" - -	// IDKey is the key to use for retrieving account ID in requests -	IDKey = "id" -	// BasePath is the base API path for this module, excluding the 'api' prefix -	BasePath = "/v1/accounts" -	// BasePathWithID is the base path for this module with the ID key +	ExcludeRepliesKey = "exclude_replies" +	LimitKey          = "limit" +	MaxIDKey          = "max_id" +	MinIDKey          = "min_id" +	OnlyMediaKey      = "only_media" +	OnlyPublicKey     = "only_public" +	PinnedKey         = "pinned" + +	BasePath       = "/v1/accounts" +	IDKey          = "id"  	BasePathWithID = BasePath + "/:" + IDKey -	// VerifyPath is for verifying account credentials -	VerifyPath = BasePath + "/verify_credentials" -	// UpdateCredentialsPath is for updating account credentials -	UpdateCredentialsPath = BasePath + "/update_credentials" -	// GetStatusesPath is for showing an account's statuses -	GetStatusesPath = BasePathWithID + "/statuses" -	// GetFollowersPath is for showing an account's followers -	GetFollowersPath = BasePathWithID + "/followers" -	// GetFollowingPath is for showing account's that an account follows. -	GetFollowingPath = BasePathWithID + "/following" -	// GetRelationshipsPath is for showing an account's relationship with other accounts -	GetRelationshipsPath = BasePath + "/relationships" -	// FollowPath is for POSTing new follows to, and updating existing follows -	FollowPath = BasePathWithID + "/follow" -	// UnfollowPath is for POSTing an unfollow -	UnfollowPath = BasePathWithID + "/unfollow" -	// BlockPath is for creating a block of an account -	BlockPath = BasePathWithID + "/block" -	// UnblockPath is for removing a block of an account -	UnblockPath = BasePathWithID + "/unblock" -	// DeleteAccountPath is for deleting one's account via the API -	DeleteAccountPath = BasePath + "/delete" -	// ListsPath is for seeing which lists an account is. -	ListsPath = BasePathWithID + "/lists" + +	BlockPath         = BasePathWithID + "/block" +	DeletePath        = BasePath + "/delete" +	FollowersPath     = BasePathWithID + "/followers" +	FollowingPath     = BasePathWithID + "/following" +	FollowPath        = BasePathWithID + "/follow" +	ListsPath         = BasePathWithID + "/lists" +	LookupPath        = BasePath + "/lookup" +	RelationshipsPath = BasePath + "/relationships" +	SearchPath        = BasePath + "/search" +	StatusesPath      = BasePathWithID + "/statuses" +	UnblockPath       = BasePathWithID + "/unblock" +	UnfollowPath      = BasePathWithID + "/unfollow" +	UpdatePath        = BasePath + "/update_credentials" +	VerifyPath        = BasePath + "/verify_credentials"  )  type Module struct { @@ -92,23 +72,23 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H  	attachHandler(http.MethodGet, BasePathWithID, m.AccountGETHandler)  	// delete account -	attachHandler(http.MethodPost, DeleteAccountPath, m.AccountDeletePOSTHandler) +	attachHandler(http.MethodPost, DeletePath, m.AccountDeletePOSTHandler)  	// verify account  	attachHandler(http.MethodGet, VerifyPath, m.AccountVerifyGETHandler)  	// modify account -	attachHandler(http.MethodPatch, UpdateCredentialsPath, m.AccountUpdateCredentialsPATCHHandler) +	attachHandler(http.MethodPatch, UpdatePath, m.AccountUpdateCredentialsPATCHHandler)  	// get account's statuses -	attachHandler(http.MethodGet, GetStatusesPath, m.AccountStatusesGETHandler) +	attachHandler(http.MethodGet, StatusesPath, m.AccountStatusesGETHandler)  	// get following or followers -	attachHandler(http.MethodGet, GetFollowersPath, m.AccountFollowersGETHandler) -	attachHandler(http.MethodGet, GetFollowingPath, m.AccountFollowingGETHandler) +	attachHandler(http.MethodGet, FollowersPath, m.AccountFollowersGETHandler) +	attachHandler(http.MethodGet, FollowingPath, m.AccountFollowingGETHandler)  	// get relationship with account -	attachHandler(http.MethodGet, GetRelationshipsPath, m.AccountRelationshipsGETHandler) +	attachHandler(http.MethodGet, RelationshipsPath, m.AccountRelationshipsGETHandler)  	// follow or unfollow account  	attachHandler(http.MethodPost, FollowPath, m.AccountFollowPOSTHandler) @@ -120,4 +100,8 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H  	// account lists  	attachHandler(http.MethodGet, ListsPath, m.AccountListsGETHandler) + +	// search for accounts +	attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler) +	attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler)  } diff --git a/internal/api/client/accounts/accountupdate_test.go b/internal/api/client/accounts/accountupdate_test.go index f6bff4825..01d12ab27 100644 --- a/internal/api/client/accounts/accountupdate_test.go +++ b/internal/api/client/accounts/accountupdate_test.go @@ -76,7 +76,7 @@ func (suite *AccountUpdateTestSuite) updateAccount(  ) (*apimodel.Account, error) {  	// Initialize http test context.  	recorder := httptest.NewRecorder() -	ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, accounts.UpdateCredentialsPath, contentType) +	ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, accounts.UpdatePath, contentType)  	// Trigger the handler.  	suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx) diff --git a/internal/api/client/accounts/lookup.go b/internal/api/client/accounts/lookup.go new file mode 100644 index 000000000..4b31ea6cc --- /dev/null +++ b/internal/api/client/accounts/lookup.go @@ -0,0 +1,93 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package accounts + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountLookupGETHandler swagger:operation GET /api/v1/accounts/lookup accountLookupGet +// +// Quickly lookup a username to see if it is available, skipping WebFinger resolution. +// +//	--- +//	tags: +//	- accounts +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: acct +//		type: string +//		description: The username or Webfinger address to lookup. +//		in: query +//		required: true +// +//	security: +//	- OAuth2 Bearer: +//		- read:accounts +// +//	responses: +//		'200': +//			name: lookup result +//			description: Result of the lookup. +//			schema: +//				"$ref": "#/definitions/account" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'500': +//			description: internal server error +func (m *Module) AccountLookupGETHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	query, errWithCode := apiutil.ParseSearchLookup(c.Query(apiutil.SearchLookupKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	account, errWithCode := m.processor.Search().Lookup(c.Request.Context(), authed.Account, query) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	c.JSON(http.StatusOK, account) +} diff --git a/internal/api/client/accounts/search.go b/internal/api/client/accounts/search.go new file mode 100644 index 000000000..c10fb2960 --- /dev/null +++ b/internal/api/client/accounts/search.go @@ -0,0 +1,166 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package accounts + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountSearchGETHandler swagger:operation GET /api/v1/accounts/search accountSearchGet +// +// Search for accounts by username and/or display name. +// +//	--- +//	tags: +//	- accounts +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: limit +//		type: integer +//		description: Number of results to try to return. +//		default: 40 +//		maximum: 80 +//		minimum: 1 +//		in: query +//	- +//		name: offset +//		type: integer +//		description: >- +//			Page number of results to return (starts at 0). +//			This parameter is currently not used, offsets +//			over 0 will always return 0 results. +//		default: 0 +//		maximum: 10 +//		minimum: 0 +//		in: query +//	- +//		name: q +//		type: string +//		description: |- +//			Query string to search for. This can be in the following forms: +//			- `@[username]` -- search for an account with the given username on any domain. Can return multiple results. +//			- `@[username]@[domain]` -- search for a remote account with exact username and domain. Will only ever return 1 result at most. +//			- any arbitrary string -- search for accounts containing the given string in their username or display name. Can return multiple results. +//		in: query +//		required: true +//	- +//		name: resolve +//		type: boolean +//		description: >- +//			If query is for `@[username]@[domain]`, or a URL, allow the GoToSocial instance to resolve +//			the search by making calls to remote instances (webfinger, ActivityPub, etc). +//		default: false +//		in: query +//	- +//		name: following +//		type: boolean +//		description: >- +//			Show only accounts that the requesting account follows. If this is set to `true`, then the GoToSocial instance +//			will enhance the search by also searching within account notes, not just in usernames and display names. +//		default: false +//		in: query +// +//	security: +//	- OAuth2 Bearer: +//		- read:accounts +// +//	responses: +//		'200': +//			name: search results +//			description: Results of the search. +//			schema: +//				type: array +//				items: +//					"$ref": "#/definitions/account" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'500': +//			description: internal server error +func (m *Module) AccountSearchGETHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 40, 80, 1) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	offset, errWithCode := apiutil.ParseSearchOffset(c.Query(apiutil.SearchOffsetKey), 0, 10, 0) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	query, errWithCode := apiutil.ParseSearchQuery(c.Query(apiutil.SearchQueryKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	resolve, errWithCode := apiutil.ParseSearchResolve(c.Query(apiutil.SearchResolveKey), false) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	following, errWithCode := apiutil.ParseSearchFollowing(c.Query(apiutil.SearchFollowingKey), false) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	results, errWithCode := m.processor.Search().Accounts( +		c.Request.Context(), +		authed.Account, +		query, +		limit, +		offset, +		resolve, +		following, +	) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	c.JSON(http.StatusOK, results) +} diff --git a/internal/api/client/accounts/search_test.go b/internal/api/client/accounts/search_test.go new file mode 100644 index 000000000..7d778f090 --- /dev/null +++ b/internal/api/client/accounts/search_test.go @@ -0,0 +1,430 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package accounts_test + +import ( +	"encoding/json" +	"fmt" +	"io" +	"net/http" +	"net/http/httptest" +	"net/url" +	"strconv" +	"strings" +	"testing" + +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/accounts" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +type AccountSearchTestSuite struct { +	AccountStandardTestSuite +} + +func (suite *AccountSearchTestSuite) getSearch( +	requestingAccount *gtsmodel.Account, +	token *gtsmodel.Token, +	user *gtsmodel.User, +	limit *int, +	offset *int, +	query string, +	resolve *bool, +	following *bool, +	expectedHTTPStatus int, +	expectedBody string, +) ([]*apimodel.Account, error) { +	var ( +		recorder   = httptest.NewRecorder() +		ctx, _     = testrig.CreateGinTestContext(recorder, nil) +		requestURL = testrig.URLMustParse("/api" + accounts.BasePath + "/search") +		queryParts []string +	) + +	// Put the request together. +	if limit != nil { +		queryParts = append(queryParts, apiutil.LimitKey+"="+strconv.Itoa(*limit)) +	} + +	if offset != nil { +		queryParts = append(queryParts, apiutil.SearchOffsetKey+"="+strconv.Itoa(*offset)) +	} + +	queryParts = append(queryParts, apiutil.SearchQueryKey+"="+url.QueryEscape(query)) + +	if resolve != nil { +		queryParts = append(queryParts, apiutil.SearchResolveKey+"="+strconv.FormatBool(*resolve)) +	} + +	if following != nil { +		queryParts = append(queryParts, apiutil.SearchFollowingKey+"="+strconv.FormatBool(*following)) +	} + +	requestURL.RawQuery = strings.Join(queryParts, "&") +	ctx.Request = httptest.NewRequest(http.MethodGet, requestURL.String(), nil) +	ctx.Set(oauth.SessionAuthorizedAccount, requestingAccount) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token)) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, user) + +	// Trigger the function being tested. +	suite.accountsModule.AccountSearchGETHandler(ctx) + +	// Read the result. +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	errs := gtserror.MultiError{} + +	// Check expected code + body. +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs = append(errs, fmt.Sprintf("expected %d got %d", expectedHTTPStatus, resultCode)) +	} + +	// If we got an expected body, return early. +	if expectedBody != "" && string(b) != expectedBody { +		errs = append(errs, fmt.Sprintf("expected %s got %s", expectedBody, string(b))) +	} + +	if err := errs.Combine(); err != nil { +		suite.FailNow("", "%v (body %s)", err, string(b)) +	} + +	accounts := []*apimodel.Account{} +	if err := json.Unmarshal(b, &accounts); err != nil { +		suite.FailNow(err.Error()) +	} + +	return accounts, nil +} + +func (suite *AccountSearchTestSuite) TestSearchZorkOK() { +	var ( +		requestingAccount        = suite.testAccounts["local_account_1"] +		token                    = suite.testTokens["local_account_1"] +		user                     = suite.testUsers["local_account_1"] +		limit              *int  = nil +		offset             *int  = nil +		resolve            *bool = nil +		query                    = "zork" +		following          *bool = nil +		expectedHTTPStatus       = http.StatusOK +		expectedBody             = "" +	) + +	accounts, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		limit, +		offset, +		query, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody, +	) + +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	if l := len(accounts); l != 1 { +		suite.FailNow("", "expected length %d got %d", 1, l) +	} +} + +func (suite *AccountSearchTestSuite) TestSearchZorkExactOK() { +	var ( +		requestingAccount        = suite.testAccounts["local_account_1"] +		token                    = suite.testTokens["local_account_1"] +		user                     = suite.testUsers["local_account_1"] +		limit              *int  = nil +		offset             *int  = nil +		resolve            *bool = nil +		query                    = "@the_mighty_zork" +		following          *bool = nil +		expectedHTTPStatus       = http.StatusOK +		expectedBody             = "" +	) + +	accounts, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		limit, +		offset, +		query, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody, +	) + +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	if l := len(accounts); l != 1 { +		suite.FailNow("", "expected length %d got %d", 1, l) +	} +} + +func (suite *AccountSearchTestSuite) TestSearchZorkWithDomainOK() { +	var ( +		requestingAccount        = suite.testAccounts["local_account_1"] +		token                    = suite.testTokens["local_account_1"] +		user                     = suite.testUsers["local_account_1"] +		limit              *int  = nil +		offset             *int  = nil +		resolve            *bool = nil +		query                    = "@the_mighty_zork@localhost:8080" +		following          *bool = nil +		expectedHTTPStatus       = http.StatusOK +		expectedBody             = "" +	) + +	accounts, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		limit, +		offset, +		query, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody, +	) + +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	if l := len(accounts); l != 1 { +		suite.FailNow("", "expected length %d got %d", 1, l) +	} +} + +func (suite *AccountSearchTestSuite) TestSearchFossSatanNotFollowing() { +	var ( +		requestingAccount        = suite.testAccounts["local_account_1"] +		token                    = suite.testTokens["local_account_1"] +		user                     = suite.testUsers["local_account_1"] +		limit              *int  = nil +		offset             *int  = nil +		resolve            *bool = nil +		query                    = "foss_satan" +		following          *bool = func() *bool { i := false; return &i }() +		expectedHTTPStatus       = http.StatusOK +		expectedBody             = "" +	) + +	accounts, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		limit, +		offset, +		query, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody, +	) + +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	if l := len(accounts); l != 1 { +		suite.FailNow("", "expected length %d got %d", 1, l) +	} +} + +func (suite *AccountSearchTestSuite) TestSearchFossSatanFollowing() { +	var ( +		requestingAccount        = suite.testAccounts["local_account_1"] +		token                    = suite.testTokens["local_account_1"] +		user                     = suite.testUsers["local_account_1"] +		limit              *int  = nil +		offset             *int  = nil +		resolve            *bool = nil +		query                    = "foss_satan" +		following          *bool = func() *bool { i := true; return &i }() +		expectedHTTPStatus       = http.StatusOK +		expectedBody             = "" +	) + +	accounts, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		limit, +		offset, +		query, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody, +	) + +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	if l := len(accounts); l != 0 { +		suite.FailNow("", "expected length %d got %d", 0, l) +	} +} + +func (suite *AccountSearchTestSuite) TestSearchBonkersQuery() { +	var ( +		requestingAccount        = suite.testAccounts["local_account_1"] +		token                    = suite.testTokens["local_account_1"] +		user                     = suite.testUsers["local_account_1"] +		limit              *int  = nil +		offset             *int  = nil +		resolve            *bool = nil +		query                    = "aaaaa@aaaaaaaaa@aaaaa **** this won't@ return anything!@!!" +		following          *bool = nil +		expectedHTTPStatus       = http.StatusOK +		expectedBody             = "" +	) + +	accounts, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		limit, +		offset, +		query, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody, +	) + +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	if l := len(accounts); l != 0 { +		suite.FailNow("", "expected length %d got %d", 0, l) +	} +} + +func (suite *AccountSearchTestSuite) TestSearchAFollowing() { +	var ( +		requestingAccount        = suite.testAccounts["local_account_1"] +		token                    = suite.testTokens["local_account_1"] +		user                     = suite.testUsers["local_account_1"] +		limit              *int  = nil +		offset             *int  = nil +		resolve            *bool = nil +		query                    = "a" +		following          *bool = nil +		expectedHTTPStatus       = http.StatusOK +		expectedBody             = "" +	) + +	accounts, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		limit, +		offset, +		query, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody, +	) + +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	if l := len(accounts); l != 5 { +		suite.FailNow("", "expected length %d got %d", 5, l) +	} + +	usernames := make([]string, 0, 5) +	for _, account := range accounts { +		usernames = append(usernames, account.Username) +	} + +	suite.EqualValues([]string{"her_fuckin_maj", "foss_satan", "1happyturtle", "the_mighty_zork", "admin"}, usernames) +} + +func (suite *AccountSearchTestSuite) TestSearchANotFollowing() { +	var ( +		requestingAccount        = suite.testAccounts["local_account_1"] +		token                    = suite.testTokens["local_account_1"] +		user                     = suite.testUsers["local_account_1"] +		limit              *int  = nil +		offset             *int  = nil +		resolve            *bool = nil +		query                    = "a" +		following          *bool = func() *bool { i := true; return &i }() +		expectedHTTPStatus       = http.StatusOK +		expectedBody             = "" +	) + +	accounts, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		limit, +		offset, +		query, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody, +	) + +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	if l := len(accounts); l != 2 { +		suite.FailNow("", "expected length %d got %d", 2, l) +	} + +	usernames := make([]string, 0, 2) +	for _, account := range accounts { +		usernames = append(usernames, account.Username) +	} + +	suite.EqualValues([]string{"1happyturtle", "admin"}, usernames) +} + +func TestAccountSearchTestSuite(t *testing.T) { +	suite.Run(t, new(AccountSearchTestSuite)) +} diff --git a/internal/api/client/lists/listaccounts.go b/internal/api/client/lists/listaccounts.go index 9e87c4130..da902384f 100644 --- a/internal/api/client/lists/listaccounts.go +++ b/internal/api/client/lists/listaccounts.go @@ -129,7 +129,7 @@ func (m *Module) ListAccountsGETHandler(c *gin.Context) {  		return  	} -	limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20) +	limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)  	if errWithCode != nil {  		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)  		return diff --git a/internal/api/client/search/search.go b/internal/api/client/search/search.go index eaa3102f9..219e30280 100644 --- a/internal/api/client/search/search.go +++ b/internal/api/client/search/search.go @@ -25,39 +25,8 @@ import (  )  const ( -	// BasePathV1 is the base path for serving v1 of the search API, minus the 'api' prefix -	BasePathV1 = "/v1/search" - -	// BasePathV2 is the base path for serving v2 of the search API, minus the 'api' prefix -	BasePathV2 = "/v2/search" - -	// AccountIDKey -- If provided, statuses returned will be authored only by this account -	AccountIDKey = "account_id" -	// MaxIDKey -- Return results older than this id -	MaxIDKey = "max_id" -	// MinIDKey -- Return results immediately newer than this id -	MinIDKey = "min_id" -	// TypeKey -- Enum(accounts, hashtags, statuses) -	TypeKey = "type" -	// ExcludeUnreviewedKey -- Filter out unreviewed tags? Defaults to false. Use true when trying to find trending tags. -	ExcludeUnreviewedKey = "exclude_unreviewed" -	// QueryKey -- The search query -	QueryKey = "q" -	// ResolveKey -- Attempt WebFinger lookup. Defaults to false. -	ResolveKey = "resolve" -	// LimitKey -- Maximum number of results to load, per type. Defaults to 20. Max 40. -	LimitKey = "limit" -	// OffsetKey -- Offset in search results. Used for pagination. Defaults to 0. -	OffsetKey = "offset" -	// FollowingKey -- Only include accounts that the user is following. Defaults to false. -	FollowingKey = "following" - -	// TypeAccounts -- -	TypeAccounts = "accounts" -	// TypeHashtags -- -	TypeHashtags = "hashtags" -	// TypeStatuses -- -	TypeStatuses = "statuses" +	BasePathV1 = "/v1/search" // Base path for serving v1 of the search API, minus the 'api' prefix. +	BasePathV2 = "/v2/search" // Base path for serving v2 of the search API, minus the 'api' prefix.  )  type Module struct { diff --git a/internal/api/client/search/searchget.go b/internal/api/client/search/searchget.go index d129bf4d6..33a90e078 100644 --- a/internal/api/client/search/searchget.go +++ b/internal/api/client/search/searchget.go @@ -18,10 +18,7 @@  package search  import ( -	"errors" -	"fmt"  	"net/http" -	"strconv"  	"github.com/gin-gonic/gin"  	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" @@ -40,6 +37,98 @@ import (  //	tags:  //	- search  // +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: max_id +//		type: string +//		description: >- +//			Return only items *OLDER* than the given max ID. +//			The item with the specified ID will not be included in the response. +//			Currently only used if 'type' is set to a specific type. +//		in: query +//		required: false +//	- +//		name: min_id +//		type: string +//		description: >- +//			Return only items *immediately newer* than the given min ID. +//			The item with the specified ID will not be included in the response. +//			Currently only used if 'type' is set to a specific type. +//		in: query +//		required: false +//	- +//		name: limit +//		type: integer +//		description: Number of each type of item to return. +//		default: 20 +//		maximum: 40 +//		minimum: 1 +//		in: query +//		required: false +//	- +//		name: offset +//		type: integer +//		description: >- +//			Page number of results to return (starts at 0). +//			This parameter is currently not used, page by selecting +//			a specific query type and using maxID and minID instead. +//		default: 0 +//		maximum: 10 +//		minimum: 0 +//		in: query +//		required: false +//	- +//		name: q +//		type: string +//		description: |- +//			Query string to search for. This can be in the following forms: +//			- `@[username]` -- search for an account with the given username on any domain. Can return multiple results. +//			- @[username]@[domain]` -- search for a remote account with exact username and domain. Will only ever return 1 result at most. +//			- `https://example.org/some/arbitrary/url` -- search for an account OR a status with the given URL. Will only ever return 1 result at most. +//			- any arbitrary string -- search for accounts or statuses containing the given string. Can return multiple results. +//		in: query +//		required: true +//	- +//		name: type +//		type: string +//		description: |- +//			Type of item to return. One of: +//			- `` -- empty string; return any/all results. +//			- `accounts` -- return account(s). +//			- `statuses` -- return status(es). +//			- `hashtags` -- return hashtag(s). +//			If `type` is specified, paging can be performed using max_id and min_id parameters. +//			If `type` is not specified, see the `offset` parameter for paging. +//		in: query +//	- +//		name: resolve +//		type: boolean +//		description: >- +//			If searching query is for `@[username]@[domain]`, or a URL, allow the GoToSocial +//			instance to resolve the search by making calls to remote instances (webfinger, ActivityPub, etc). +//		default: false +//		in: query +//	- +//		name: following +//		type: boolean +//		description: >- +//			If search type includes accounts, and search query is an arbitrary string, show only accounts +//			that the requesting account follows. If this is set to `true`, then the GoToSocial instance will +//			enhance the search by also searching within account notes, not just in usernames and display names. +//		default: false +//		in: query +//	- +//		name: exclude_unreviewed +//		type: boolean +//		description: >- +//			If searching for hashtags, exclude those not yet approved by instance admin. +//			Currently this parameter is unused. +//		default: false +//		in: query +//  //	security:  //	- OAuth2 Bearer:  //		- read:search @@ -74,93 +163,55 @@ func (m *Module) SearchGETHandler(c *gin.Context) {  		return  	} -	excludeUnreviewed := false -	excludeUnreviewedString := c.Query(ExcludeUnreviewedKey) -	if excludeUnreviewedString != "" { -		var err error -		excludeUnreviewed, err = strconv.ParseBool(excludeUnreviewedString) -		if err != nil { -			err := fmt.Errorf("error parsing %s: %s", ExcludeUnreviewedKey, err) -			apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) -			return -		} +	limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return  	} -	query := c.Query(QueryKey) -	if query == "" { -		err := errors.New("query parameter q was empty") -		apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) +	offset, errWithCode := apiutil.ParseSearchOffset(c.Query(apiutil.SearchOffsetKey), 0, 10, 0) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)  		return  	} -	resolve := false -	resolveString := c.Query(ResolveKey) -	if resolveString != "" { -		var err error -		resolve, err = strconv.ParseBool(resolveString) -		if err != nil { -			err := fmt.Errorf("error parsing %s: %s", ResolveKey, err) -			apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) -			return -		} +	query, errWithCode := apiutil.ParseSearchQuery(c.Query(apiutil.SearchQueryKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return  	} -	limit := 2 -	limitString := c.Query(LimitKey) -	if limitString != "" { -		i, err := strconv.ParseInt(limitString, 10, 32) -		if err != nil { -			err := fmt.Errorf("error parsing %s: %s", LimitKey, err) -			apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) -			return -		} -		limit = int(i) -	} -	if limit > 40 { -		limit = 40 -	} -	if limit < 1 { -		limit = 1 +	resolve, errWithCode := apiutil.ParseSearchResolve(c.Query(apiutil.SearchResolveKey), false) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return  	} -	offset := 0 -	offsetString := c.Query(OffsetKey) -	if offsetString != "" { -		i, err := strconv.ParseInt(offsetString, 10, 32) -		if err != nil { -			err := fmt.Errorf("error parsing %s: %s", OffsetKey, err) -			apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) -			return -		} -		offset = int(i) +	following, errWithCode := apiutil.ParseSearchFollowing(c.Query(apiutil.SearchFollowingKey), false) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return  	} -	following := false -	followingString := c.Query(FollowingKey) -	if followingString != "" { -		var err error -		following, err = strconv.ParseBool(followingString) -		if err != nil { -			err := fmt.Errorf("error parsing %s: %s", FollowingKey, err) -			apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) -			return -		} +	excludeUnreviewed, errWithCode := apiutil.ParseSearchExcludeUnreviewed(c.Query(apiutil.SearchExcludeUnreviewedKey), false) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return  	} -	searchQuery := &apimodel.SearchQuery{ -		AccountID:         c.Query(AccountIDKey), -		MaxID:             c.Query(MaxIDKey), -		MinID:             c.Query(MinIDKey), -		Type:              c.Query(TypeKey), -		ExcludeUnreviewed: excludeUnreviewed, -		Query:             query, -		Resolve:           resolve, +	searchRequest := &apimodel.SearchRequest{ +		MaxID:             c.Query(apiutil.MaxIDKey), +		MinID:             c.Query(apiutil.MinIDKey),  		Limit:             limit,  		Offset:            offset, +		Query:             query, +		QueryType:         c.Query(apiutil.SearchTypeKey), +		Resolve:           resolve,  		Following:         following, +		ExcludeUnreviewed: excludeUnreviewed,  	} -	results, errWithCode := m.processor.SearchGet(c.Request.Context(), authed, searchQuery) +	results, errWithCode := m.processor.Search().Get(c.Request.Context(), authed.Account, searchRequest)  	if errWithCode != nil {  		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)  		return diff --git a/internal/api/client/search/searchget_test.go b/internal/api/client/search/searchget_test.go index fe817099f..9e0a8eb67 100644 --- a/internal/api/client/search/searchget_test.go +++ b/internal/api/client/search/searchget_test.go @@ -18,55 +18,174 @@  package search_test  import ( +	"context"  	"encoding/json"  	"fmt" -	"io/ioutil" +	"io"  	"net/http"  	"net/http/httptest" +	"net/url" +	"strconv" +	"strings"  	"testing"  	"github.com/stretchr/testify/suite"  	"github.com/superseriousbusiness/gotosocial/internal/api/client/search"  	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig"  )  type SearchGetTestSuite struct {  	SearchStandardTestSuite  } -func (suite *SearchGetTestSuite) testSearch(query string, resolve bool, expectedHTTPStatus int) (*apimodel.SearchResult, error) { -	requestPath := fmt.Sprintf("%s?q=%s&resolve=%t", search.BasePathV1, query, resolve) -	recorder := httptest.NewRecorder() +func (suite *SearchGetTestSuite) getSearch( +	requestingAccount *gtsmodel.Account, +	token *gtsmodel.Token, +	user *gtsmodel.User, +	maxID *string, +	minID *string, +	limit *int, +	offset *int, +	query string, +	queryType *string, +	resolve *bool, +	following *bool, +	expectedHTTPStatus int, +	expectedBody string, +) (*apimodel.SearchResult, error) { +	var ( +		recorder   = httptest.NewRecorder() +		ctx, _     = testrig.CreateGinTestContext(recorder, nil) +		requestURL = testrig.URLMustParse("/api" + search.BasePathV1) +		queryParts []string +	) + +	// Put the request together. +	if maxID != nil { +		queryParts = append(queryParts, apiutil.MaxIDKey+"="+url.QueryEscape(*maxID)) +	} + +	if minID != nil { +		queryParts = append(queryParts, apiutil.MinIDKey+"="+url.QueryEscape(*minID)) +	} + +	if limit != nil { +		queryParts = append(queryParts, apiutil.LimitKey+"="+strconv.Itoa(*limit)) +	} + +	if offset != nil { +		queryParts = append(queryParts, apiutil.SearchOffsetKey+"="+strconv.Itoa(*offset)) +	} + +	queryParts = append(queryParts, apiutil.SearchQueryKey+"="+url.QueryEscape(query)) -	ctx := suite.newContext(recorder, requestPath) +	if queryType != nil { +		queryParts = append(queryParts, apiutil.SearchTypeKey+"="+url.QueryEscape(*queryType)) +	} + +	if resolve != nil { +		queryParts = append(queryParts, apiutil.SearchResolveKey+"="+strconv.FormatBool(*resolve)) +	} + +	if following != nil { +		queryParts = append(queryParts, apiutil.SearchFollowingKey+"="+strconv.FormatBool(*following)) +	} + +	requestURL.RawQuery = strings.Join(queryParts, "&") +	ctx.Request = httptest.NewRequest(http.MethodGet, requestURL.String(), nil) +	ctx.Set(oauth.SessionAuthorizedAccount, requestingAccount) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token)) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, user) +	// Trigger the function being tested.  	suite.searchModule.SearchGETHandler(ctx) +	// Read the result.  	result := recorder.Result()  	defer result.Body.Close() +	b, err := io.ReadAll(result.Body) +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	errs := gtserror.MultiError{} + +	// Check expected code + body.  	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { -		return nil, fmt.Errorf("expected %d got %d", expectedHTTPStatus, resultCode) +		errs = append(errs, fmt.Sprintf("expected %d got %d", expectedHTTPStatus, resultCode))  	} -	b, err := ioutil.ReadAll(result.Body) -	if err != nil { -		return nil, err +	// If we got an expected body, return early. +	if expectedBody != "" && string(b) != expectedBody { +		errs = append(errs, fmt.Sprintf("expected %s got %s", expectedBody, string(b))) +	} + +	if err := errs.Combine(); err != nil { +		suite.FailNow("", "%v (body %s)", err, string(b))  	}  	searchResult := &apimodel.SearchResult{}  	if err := json.Unmarshal(b, searchResult); err != nil { -		return nil, err +		suite.FailNow(err.Error())  	}  	return searchResult, nil  } -func (suite *SearchGetTestSuite) TestSearchRemoteAccountByURI() { -	query := "https://unknown-instance.com/users/brand_new_person" -	resolve := true +func (suite *SearchGetTestSuite) bodgeLocalInstance(domain string) { +	// Set new host. +	config.SetHost(domain) -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	// Copy instance account to not mess up other tests. +	instanceAccount := >smodel.Account{} +	*instanceAccount = *suite.testAccounts["instance_account"] + +	// Set username of instance account to given domain. +	instanceAccount.Username = domain +	if err := suite.db.UpdateAccount(context.Background(), instanceAccount, "username"); err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *SearchGetTestSuite) TestSearchRemoteAccountByURI() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "https://unknown-instance.com/users/brand_new_person" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -80,10 +199,36 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByURI() {  }  func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestring() { -	query := "@brand_new_person@unknown-instance.com" -	resolve := true - -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "@brand_new_person@unknown-instance.com" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -97,10 +242,36 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestring() {  }  func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringUppercase() { -	query := "@Some_User@example.org" -	resolve := true - -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "@Some_User@example.org" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -114,10 +285,36 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringUppercase()  }  func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringNoLeadingAt() { -	query := "brand_new_person@unknown-instance.com" -	resolve := true - -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "brand_new_person@unknown-instance.com" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -131,10 +328,36 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringNoLeadingAt(  }  func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringNoResolve() { -	query := "@brand_new_person@unknown-instance.com" -	resolve := false - -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = nil +		query                      = "@brand_new_person@unknown-instance.com" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -143,10 +366,36 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringNoResolve()  }  func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringSpecialChars() { -	query := "@üser@ëxample.org" -	resolve := false - -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = nil +		query                      = "@üser@ëxample.org" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -158,10 +407,36 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringSpecialChars  }  func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringSpecialCharsPunycode() { -	query := "@üser@xn--xample-ova.org" -	resolve := false - -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = nil +		query                      = "@üser@xn--xample-ova.org" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -173,10 +448,36 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringSpecialChars  }  func (suite *SearchGetTestSuite) TestSearchLocalAccountByNamestring() { -	query := "@the_mighty_zork" -	resolve := false - -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = nil +		query                      = "@the_mighty_zork" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -190,10 +491,36 @@ func (suite *SearchGetTestSuite) TestSearchLocalAccountByNamestring() {  }  func (suite *SearchGetTestSuite) TestSearchLocalAccountByNamestringWithDomain() { -	query := "@the_mighty_zork@localhost:8080" -	resolve := false - -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = nil +		query                      = "@the_mighty_zork@localhost:8080" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -207,10 +534,36 @@ func (suite *SearchGetTestSuite) TestSearchLocalAccountByNamestringWithDomain()  }  func (suite *SearchGetTestSuite) TestSearchNonexistingLocalAccountByNamestringResolveTrue() { -	query := "@somone_made_up@localhost:8080" -	resolve := true - -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "@somone_made_up@localhost:8080" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -219,10 +572,36 @@ func (suite *SearchGetTestSuite) TestSearchNonexistingLocalAccountByNamestringRe  }  func (suite *SearchGetTestSuite) TestSearchLocalAccountByURI() { -	query := "http://localhost:8080/users/the_mighty_zork" -	resolve := false - -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = nil +		query                      = "http://localhost:8080/users/the_mighty_zork" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -235,11 +614,37 @@ func (suite *SearchGetTestSuite) TestSearchLocalAccountByURI() {  	suite.NotNil(gotAccount)  } -func (suite *SearchGetTestSuite) TestSearchLocalInstanceAccountByURI() { -	query := "http://localhost:8080/users/localhost:8080" -	resolve := false - -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +func (suite *SearchGetTestSuite) TestSearchLocalAccountByURL() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = nil +		query                      = "http://localhost:8080/@the_mighty_zork" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -252,57 +657,398 @@ func (suite *SearchGetTestSuite) TestSearchLocalInstanceAccountByURI() {  	suite.NotNil(gotAccount)  } -func (suite *SearchGetTestSuite) TestSearchLocalAccountByURL() { -	query := "http://localhost:8080/@the_mighty_zork" -	resolve := false +func (suite *SearchGetTestSuite) TestSearchNonexistingLocalAccountByURL() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "http://localhost:8080/@the_shmighty_shmork" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody) +	if err != nil { +		suite.FailNow(err.Error()) +	} -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	suite.Len(searchResult.Accounts, 0) +} + +func (suite *SearchGetTestSuite) TestSearchStatusByURL() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "https://turnip.farm/users/turniplover6969/statuses/70c53e54-3146-42d5-a630-83c8b6c7c042" +		queryType          *string = func() *string { i := "statuses"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} -	if !suite.Len(searchResult.Accounts, 1) { -		suite.FailNow("expected 1 account in search results but got 0") +	if !suite.Len(searchResult.Statuses, 1) { +		suite.FailNow("expected 1 status in search results but got 0")  	} -	gotAccount := searchResult.Accounts[0] -	suite.NotNil(gotAccount) +	gotStatus := searchResult.Statuses[0] +	suite.NotNil(gotStatus)  } -func (suite *SearchGetTestSuite) TestSearchNonexistingLocalAccountByURL() { -	query := "http://localhost:8080/@the_shmighty_shmork" -	resolve := true +func (suite *SearchGetTestSuite) TestSearchBlockedDomainURL() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "https://replyguys.com/@someone" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody) +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Len(searchResult.Accounts, 0) +	suite.Len(searchResult.Statuses, 0) +	suite.Len(searchResult.Hashtags, 0) +} -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +func (suite *SearchGetTestSuite) TestSearchBlockedDomainNamestring() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "@someone@replyguys.com" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	}  	suite.Len(searchResult.Accounts, 0) +	suite.Len(searchResult.Statuses, 0) +	suite.Len(searchResult.Hashtags, 0)  } -func (suite *SearchGetTestSuite) TestSearchStatusByURL() { -	query := "https://turnip.farm/users/turniplover6969/statuses/70c53e54-3146-42d5-a630-83c8b6c7c042" -	resolve := true +func (suite *SearchGetTestSuite) TestSearchAAny() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "a" +		queryType          *string = nil // Return anything. +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody) +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Len(searchResult.Accounts, 5) +	suite.Len(searchResult.Statuses, 4) +	suite.Len(searchResult.Hashtags, 0) +} -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +func (suite *SearchGetTestSuite) TestSearchAAnyFollowingOnly() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "a" +		queryType          *string = nil // Return anything. +		following          *bool   = func() *bool { i := true; return &i }() +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} -	if !suite.Len(searchResult.Statuses, 1) { -		suite.FailNow("expected 1 status in search results but got 0") +	suite.Len(searchResult.Accounts, 2) +	suite.Len(searchResult.Statuses, 4) +	suite.Len(searchResult.Hashtags, 0) +} + +func (suite *SearchGetTestSuite) TestSearchAStatuses() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "a" +		queryType          *string = func() *string { i := "statuses"; return &i }() // Only statuses. +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody) +	if err != nil { +		suite.FailNow(err.Error())  	} -	gotStatus := searchResult.Statuses[0] -	suite.NotNil(gotStatus) +	suite.Len(searchResult.Accounts, 0) +	suite.Len(searchResult.Statuses, 4) +	suite.Len(searchResult.Hashtags, 0)  } -func (suite *SearchGetTestSuite) TestSearchBlockedDomainURL() { -	query := "https://replyguys.com/@someone" -	resolve := true +func (suite *SearchGetTestSuite) TestSearchAAccounts() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "a" +		queryType          *string = func() *string { i := "accounts"; return &i }() // Only accounts. +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody) +	if err != nil { +		suite.FailNow(err.Error()) +	} -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	suite.Len(searchResult.Accounts, 5) +	suite.Len(searchResult.Statuses, 0) +	suite.Len(searchResult.Hashtags, 0) +} + +func (suite *SearchGetTestSuite) TestSearchAAccountsLimit1() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = func() *int { i := 1; return &i }() +		offset             *int    = nil +		resolve            *bool   = func() *bool { i := true; return &i }() +		query                      = "a" +		queryType          *string = func() *string { i := "accounts"; return &i }() // Only accounts. +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody) +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Len(searchResult.Accounts, 1) +	suite.Len(searchResult.Statuses, 0) +	suite.Len(searchResult.Hashtags, 0) +} + +func (suite *SearchGetTestSuite) TestSearchLocalInstanceAccountByURI() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = nil +		query                      = "http://localhost:8080/users/localhost:8080" +		queryType          *string = func() *string { i := "accounts"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -312,11 +1058,89 @@ func (suite *SearchGetTestSuite) TestSearchBlockedDomainURL() {  	suite.Len(searchResult.Hashtags, 0)  } -func (suite *SearchGetTestSuite) TestSearchBlockedDomainNamestring() { -	query := "@someone@replyguys.com" -	resolve := true +func (suite *SearchGetTestSuite) TestSearchInstanceAccountFull() { +	// Namestring excludes ':' in usernames, so we +	// need to fiddle with the instance account a +	// bit to get it to look like a different domain. +	newDomain := "example.org" +	suite.bodgeLocalInstance(newDomain) + +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = nil +		query                      = "@" + newDomain + "@" + newDomain +		queryType          *string = nil +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody) +	if err != nil { +		suite.FailNow(err.Error()) +	} -	searchResult, err := suite.testSearch(query, resolve, http.StatusOK) +	suite.Len(searchResult.Accounts, 0) +	suite.Len(searchResult.Statuses, 0) +	suite.Len(searchResult.Hashtags, 0) +} + +func (suite *SearchGetTestSuite) TestSearchInstanceAccountPartial() { +	// Namestring excludes ':' in usernames, so we +	// need to fiddle with the instance account a +	// bit to get it to look like a different domain. +	newDomain := "example.org" +	suite.bodgeLocalInstance(newDomain) + +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = nil +		query                      = "@" + newDomain +		queryType          *string = nil +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusOK +		expectedBody               = "" +	) + +	searchResult, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody)  	if err != nil {  		suite.FailNow(err.Error())  	} @@ -326,6 +1150,78 @@ func (suite *SearchGetTestSuite) TestSearchBlockedDomainNamestring() {  	suite.Len(searchResult.Hashtags, 0)  } +func (suite *SearchGetTestSuite) TestSearchBadQueryType() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = nil +		query                      = "whatever" +		queryType          *string = func() *string { i := "aaaaaaaaaaa"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusBadRequest +		expectedBody               = `{"error":"Bad Request: search query type aaaaaaaaaaa was not recognized, valid options are ['', 'accounts', 'statuses', 'hashtags']"}` +	) + +	_, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *SearchGetTestSuite) TestSearchEmptyQuery() { +	var ( +		requestingAccount          = suite.testAccounts["local_account_1"] +		token                      = suite.testTokens["local_account_1"] +		user                       = suite.testUsers["local_account_1"] +		maxID              *string = nil +		minID              *string = nil +		limit              *int    = nil +		offset             *int    = nil +		resolve            *bool   = nil +		query                      = "" +		queryType          *string = func() *string { i := "aaaaaaaaaaa"; return &i }() +		following          *bool   = nil +		expectedHTTPStatus         = http.StatusBadRequest +		expectedBody               = `{"error":"Bad Request: required key q was not set or had empty value"}` +	) + +	_, err := suite.getSearch( +		requestingAccount, +		token, +		user, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		resolve, +		following, +		expectedHTTPStatus, +		expectedBody) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} +  func TestSearchGetTestSuite(t *testing.T) {  	suite.Run(t, &SearchGetTestSuite{})  } diff --git a/internal/api/client/timelines/home.go b/internal/api/client/timelines/home.go index f64d61287..c3f075d5e 100644 --- a/internal/api/client/timelines/home.go +++ b/internal/api/client/timelines/home.go @@ -118,7 +118,7 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) {  		return  	} -	limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20) +	limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)  	if errWithCode != nil {  		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)  		return diff --git a/internal/api/client/timelines/list.go b/internal/api/client/timelines/list.go index 4f5232d8b..8b4f7fad9 100644 --- a/internal/api/client/timelines/list.go +++ b/internal/api/client/timelines/list.go @@ -125,7 +125,7 @@ func (m *Module) ListTimelineGETHandler(c *gin.Context) {  		return  	} -	limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20) +	limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)  	if errWithCode != nil {  		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)  		return diff --git a/internal/api/client/timelines/public.go b/internal/api/client/timelines/public.go index 5be9fcaa8..96958e6a4 100644 --- a/internal/api/client/timelines/public.go +++ b/internal/api/client/timelines/public.go @@ -129,7 +129,7 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) {  		return  	} -	limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20) +	limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)  	if errWithCode != nil {  		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)  		return diff --git a/internal/api/model/search.go b/internal/api/model/search.go index f7b86f884..664bf7b26 100644 --- a/internal/api/model/search.go +++ b/internal/api/model/search.go @@ -17,74 +17,24 @@  package model -// SearchQuery models a search request. -// -// swagger:parameters searchGet -type SearchQuery struct { -	// If type is `statuses`, then statuses returned will be authored only by this account. -	// -	// in: query -	AccountID string `json:"account_id"` -	// Return results *older* than this id. -	// -	// The entry with this ID will not be included in the search results. -	// in: query -	MaxID string `json:"max_id"` -	// Return results *newer* than this id. -	// -	// The entry with this ID will not be included in the search results. -	// in: query -	MinID string `json:"min_id"` -	// Type of the search query to perform. -	// -	// Must be one of: `accounts`, `hashtags`, `statuses`. -	// -	// enum: -	// - accounts -	// - hashtags -	// - statuses -	// required: true -	// in: query -	Type string `json:"type"` -	// Filter out tags that haven't been reviewed and approved by an instance admin. -	// -	// default: false -	// in: query -	ExcludeUnreviewed bool `json:"exclude_unreviewed"` -	// String to use as a search query. -	// -	// For accounts, this should be in the format `@someaccount@some.instance.com`, or the format `https://some.instance.com/@someaccount` -	// -	// For a status, this can be in the format: `https://some.instance.com/@someaccount/SOME_ID_OF_A_STATUS` -	// -	// required: true -	// in: query -	Query string `json:"q"` -	// Attempt to resolve the query by performing a remote webfinger lookup, if the query includes a remote host. -	// default: false -	Resolve bool `json:"resolve"` -	// Maximum number of results to load, per type. -	// default: 20 -	// minimum: 1 -	// maximum: 40 -	// in: query -	Limit int `json:"limit"` -	// Offset for paginating search results. -	// -	// default: 0 -	// in: query -	Offset int `json:"offset"` -	// Only include accounts that the searching account is following. -	// default: false -	// in: query -	Following bool `json:"following"` +// SearchRequest models a search request. +type SearchRequest struct { +	MaxID             string +	MinID             string +	Limit             int +	Offset            int +	Query             string +	QueryType         string +	Resolve           bool +	Following         bool +	ExcludeUnreviewed bool  }  // SearchResult models a search result.  //  // swagger:model searchResult  type SearchResult struct { -	Accounts []Account `json:"accounts"` -	Statuses []Status  `json:"statuses"` -	Hashtags []Tag     `json:"hashtags"` +	Accounts []*Account `json:"accounts"` +	Statuses []*Status  `json:"statuses"` +	Hashtags []*Tag     `json:"hashtags"`  } diff --git a/internal/api/util/parsequery.go b/internal/api/util/parsequery.go index 92578a739..460ca3e05 100644 --- a/internal/api/util/parsequery.go +++ b/internal/api/util/parsequery.go @@ -25,34 +25,162 @@ import (  )  const ( +	/* Common keys */ +  	LimitKey = "limit"  	LocalKey = "local" +	MaxIDKey = "max_id" +	MinIDKey = "min_id" + +	/* Search keys */ + +	SearchExcludeUnreviewedKey = "exclude_unreviewed" +	SearchFollowingKey         = "following" +	SearchLookupKey            = "acct" +	SearchOffsetKey            = "offset" +	SearchQueryKey             = "q" +	SearchResolveKey           = "resolve" +	SearchTypeKey              = "type"  ) -func ParseLimit(limit string, defaultLimit int) (int, gtserror.WithCode) { -	if limit == "" { -		return defaultLimit, nil +// parseError returns gtserror.WithCode set to 400 Bad Request, to indicate +// to the caller that a key was set to a value that could not be parsed. +func parseError(key string, value, defaultValue any, err error) gtserror.WithCode { +	err = fmt.Errorf("error parsing key %s with value %s as %T: %w", key, value, defaultValue, err) +	return gtserror.NewErrorBadRequest(err, err.Error()) +} + +func requiredError(key string) gtserror.WithCode { +	err := fmt.Errorf("required key %s was not set or had empty value", key) +	return gtserror.NewErrorBadRequest(err, err.Error()) +} + +/* +	Parse functions for *OPTIONAL* parameters with default values. +*/ + +func ParseLimit(value string, defaultValue int, max, min int) (int, gtserror.WithCode) { +	key := LimitKey + +	if value == "" { +		return defaultValue, nil  	} -	i, err := strconv.Atoi(limit) +	i, err := strconv.Atoi(value)  	if err != nil { -		err := fmt.Errorf("error parsing %s: %w", LimitKey, err) -		return 0, gtserror.NewErrorBadRequest(err, err.Error()) +		return defaultValue, parseError(key, value, defaultValue, err) +	} + +	if i > max { +		i = max +	} else if i < min { +		i = min  	}  	return i, nil  } -func ParseLocal(local string, defaultLocal bool) (bool, gtserror.WithCode) { -	if local == "" { -		return defaultLocal, nil +func ParseLocal(value string, defaultValue bool) (bool, gtserror.WithCode) { +	key := LimitKey + +	if value == "" { +		return defaultValue, nil  	} -	i, err := strconv.ParseBool(local) +	i, err := strconv.ParseBool(value)  	if err != nil { -		err := fmt.Errorf("error parsing %s: %w", LocalKey, err) -		return false, gtserror.NewErrorBadRequest(err, err.Error()) +		return defaultValue, parseError(key, value, defaultValue, err)  	}  	return i, nil  } + +func ParseSearchExcludeUnreviewed(value string, defaultValue bool) (bool, gtserror.WithCode) { +	key := SearchExcludeUnreviewedKey + +	if value == "" { +		return defaultValue, nil +	} + +	i, err := strconv.ParseBool(value) +	if err != nil { +		return defaultValue, parseError(key, value, defaultValue, err) +	} + +	return i, nil +} + +func ParseSearchFollowing(value string, defaultValue bool) (bool, gtserror.WithCode) { +	key := SearchFollowingKey + +	if value == "" { +		return defaultValue, nil +	} + +	i, err := strconv.ParseBool(value) +	if err != nil { +		return defaultValue, parseError(key, value, defaultValue, err) +	} + +	return i, nil +} + +func ParseSearchOffset(value string, defaultValue int, max, min int) (int, gtserror.WithCode) { +	key := SearchOffsetKey + +	if value == "" { +		return defaultValue, nil +	} + +	i, err := strconv.Atoi(value) +	if err != nil { +		return defaultValue, parseError(key, value, defaultValue, err) +	} + +	if i > max { +		i = max +	} else if i < min { +		i = min +	} + +	return i, nil +} + +func ParseSearchResolve(value string, defaultValue bool) (bool, gtserror.WithCode) { +	key := SearchResolveKey + +	if value == "" { +		return defaultValue, nil +	} + +	i, err := strconv.ParseBool(value) +	if err != nil { +		return defaultValue, parseError(key, value, defaultValue, err) +	} + +	return i, nil +} + +/* +	Parse functions for *REQUIRED* parameters. +*/ + +func ParseSearchLookup(value string) (string, gtserror.WithCode) { +	key := SearchLookupKey + +	if value == "" { +		return "", requiredError(key) +	} + +	return value, nil +} + +func ParseSearchQuery(value string) (string, gtserror.WithCode) { +	key := SearchQueryKey + +	if value == "" { +		return "", requiredError(key) +	} + +	return value, nil +} diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index f0329e898..9d616954a 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -71,6 +71,7 @@ type DBService struct {  	db.Notification  	db.Relationship  	db.Report +	db.Search  	db.Session  	db.Status  	db.StatusBookmark @@ -204,6 +205,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {  			conn:  conn,  			state: state,  		}, +		Search: &searchDB{ +			conn:  conn, +			state: state, +		},  		Session: &sessionDB{  			conn: conn,  		}, diff --git a/internal/db/bundb/migrations/20230620103932_search_updates.go b/internal/db/bundb/migrations/20230620103932_search_updates.go new file mode 100644 index 000000000..0e26069a8 --- /dev/null +++ b/internal/db/bundb/migrations/20230620103932_search_updates.go @@ -0,0 +1,64 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package migrations + +import ( +	"context" + +	"github.com/superseriousbusiness/gotosocial/internal/log" +	"github.com/uptrace/bun" +) + +func init() { +	up := func(ctx context.Context, db *bun.DB) error { +		return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { +			// Drop previous in_reply_to_account_id index. +			log.Info(ctx, "dropping previous statuses index, please wait and don't interrupt it (this may take a while)") +			if _, err := tx. +				NewDropIndex(). +				Index("statuses_in_reply_to_account_id_idx"). +				Exec(ctx); err != nil { +				return err +			} + +			// Create new index to replace it, which also includes id DESC. +			log.Info(ctx, "creating new statuses index, please wait and don't interrupt it (this may take a while)") +			if _, err := tx. +				NewCreateIndex(). +				Table("statuses"). +				Index("statuses_in_reply_to_account_id_id_idx"). +				Column("in_reply_to_account_id"). +				ColumnExpr("id DESC"). +				Exec(ctx); err != nil { +				return err +			} + +			return nil +		}) +	} + +	down := func(ctx context.Context, db *bun.DB) error { +		return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { +			return nil +		}) +	} + +	if err := Migrations.Register(up, down); err != nil { +		panic(err) +	} +} diff --git a/internal/db/bundb/search.go b/internal/db/bundb/search.go new file mode 100644 index 000000000..c05ebb8b1 --- /dev/null +++ b/internal/db/bundb/search.go @@ -0,0 +1,422 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package bundb + +import ( +	"context" +	"strings" + +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/id" +	"github.com/superseriousbusiness/gotosocial/internal/log" +	"github.com/superseriousbusiness/gotosocial/internal/state" +	"github.com/uptrace/bun" +	"github.com/uptrace/bun/dialect" +) + +// todo: currently we pass an 'offset' parameter into functions owned by this struct, +// which is ignored. +// +// The idea of 'offset' is to allow callers to page through results without supplying +// maxID or minID params; they simply use the offset as more or less a 'page number'. +// This works fine when you're dealing with something like Elasticsearch, but for +// SQLite or Postgres 'LIKE' queries it doesn't really, because for each higher offset +// you have to calculate the value of all the previous offsets as well *within the +// execution time of the query*. It's MUCH more efficient to page using maxID and +// minID for queries like this. For now, then, we just ignore the offset and hope that +// the caller will page using maxID and minID instead. +// +// In future, however, it would be good to support offset in a way that doesn't totally +// destroy database queries. One option would be to cache previous offsets when paging +// down (which is the most common use case). +// +// For example, say a caller makes a call with offset 0: we run the query as normal, +// and in a 10 minute cache or something, store the next maxID value as it would be for +// offset 1, for the supplied query, limit, following, etc. Then when they call for +// offset 1, instead of supplying 'offset' in the query and causing slowdown, we check +// the cache to see if we have the next maxID value stored for that query, and use that +// instead. If a caller out of the blue requests offset 4 or something, on an empty cache, +// we could run the previous 4 queries and store the offsets for those before making the +// 5th call for page 4. +// +// This isn't ideal, of course, but at least we could cover the most common use case of +// a caller paging down through results. +type searchDB struct { +	conn  *DBConn +	state *state.State +} + +// replacer is a thread-safe string replacer which escapes +// common SQLite + Postgres `LIKE` wildcard chars using the +// escape character `\`. Initialized as a var in this package +// so it can be reused. +var replacer = strings.NewReplacer( +	`\`, `\\`, // Escape char. +	`%`, `\%`, // Zero or more char. +	`_`, `\_`, // Exactly one char. +) + +// whereSubqueryLike appends a WHERE clause to the +// given SelectQuery q, which searches for matches +// of searchQuery in the given subQuery using LIKE. +func whereSubqueryLike( +	q *bun.SelectQuery, +	subQuery *bun.SelectQuery, +	searchQuery string, +) *bun.SelectQuery { +	// Escape existing wildcard + escape +	// chars in the search query string. +	searchQuery = replacer.Replace(searchQuery) + +	// Add our own wildcards back in; search +	// zero or more chars around the query. +	searchQuery = `%` + searchQuery + `%` + +	// Append resulting WHERE +	// clause to the main query. +	return q.Where( +		"(?) LIKE ? ESCAPE ?", +		subQuery, searchQuery, `\`, +	) +} + +// Query example (SQLite): +// +//	SELECT "account"."id" FROM "accounts" AS "account" +//	WHERE (("account"."domain" IS NULL) OR ("account"."domain" != "account"."username")) +//	AND ("account"."id" < 'ZZZZZZZZZZZZZZZZZZZZZZZZZZ') +//	AND ("account"."id" IN (SELECT "target_account_id" FROM "follows" WHERE ("account_id" = '016T5Q3SQKBT337DAKVSKNXXW1'))) +//	AND ((SELECT LOWER("account"."username" || COALESCE("account"."display_name", '') || COALESCE("account"."note", '')) AS "account_text") LIKE '%turtle%' ESCAPE '\') +//	ORDER BY "account"."id" DESC LIMIT 10 +func (s *searchDB) SearchForAccounts( +	ctx context.Context, +	accountID string, +	query string, +	maxID string, +	minID string, +	limit int, +	following bool, +	offset int, +) ([]*gtsmodel.Account, error) { +	// Ensure reasonable +	if limit < 0 { +		limit = 0 +	} + +	// Make educated guess for slice size +	var ( +		accountIDs  = make([]string, 0, limit) +		frontToBack = true +	) + +	q := s.conn. +		NewSelect(). +		TableExpr("? AS ?", bun.Ident("accounts"), bun.Ident("account")). +		// Select only IDs from table. +		Column("account.id"). +		// Try to ignore instance accounts. Account domain must +		// be either nil or, if set, not equal to the account's +		// username (which is commonly used to indicate it's an +		// instance service account). +		WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { +			return q. +				Where("? IS NULL", bun.Ident("account.domain")). +				WhereOr("? != ?", bun.Ident("account.domain"), bun.Ident("account.username")) +		}) + +	// Return only items with a LOWER id than maxID. +	if maxID == "" { +		maxID = id.Highest +	} +	q = q.Where("? < ?", bun.Ident("account.id"), maxID) + +	if minID != "" { +		// Return only items with a HIGHER id than minID. +		q = q.Where("? > ?", bun.Ident("account.id"), minID) + +		// page up +		frontToBack = false +	} + +	if following { +		// Select only from accounts followed by accountID. +		q = q.Where( +			"? IN (?)", +			bun.Ident("account.id"), +			s.followedAccounts(accountID), +		) +	} + +	// Select account text as subquery. +	accountTextSubq := s.accountText(following) + +	// Search using LIKE for matches of query +	// string within accountText subquery. +	q = whereSubqueryLike(q, accountTextSubq, query) + +	if limit > 0 { +		// Limit amount of accounts returned. +		q = q.Limit(limit) +	} + +	if frontToBack { +		// Page down. +		q = q.Order("account.id DESC") +	} else { +		// Page up. +		q = q.Order("account.id ASC") +	} + +	if err := q.Scan(ctx, &accountIDs); err != nil { +		return nil, s.conn.ProcessError(err) +	} + +	if len(accountIDs) == 0 { +		return nil, nil +	} + +	// If we're paging up, we still want accounts +	// to be sorted by ID desc, so reverse ids slice. +	// https://zchee.github.io/golang-wiki/SliceTricks/#reversing +	if !frontToBack { +		for l, r := 0, len(accountIDs)-1; l < r; l, r = l+1, r-1 { +			accountIDs[l], accountIDs[r] = accountIDs[r], accountIDs[l] +		} +	} + +	accounts := make([]*gtsmodel.Account, 0, len(accountIDs)) +	for _, id := range accountIDs { +		// Fetch account from db for ID +		account, err := s.state.DB.GetAccountByID(ctx, id) +		if err != nil { +			log.Errorf(ctx, "error fetching account %q: %v", id, err) +			continue +		} + +		// Append account to slice +		accounts = append(accounts, account) +	} + +	return accounts, nil +} + +// followedAccounts returns a subquery that selects only IDs +// of accounts that are followed by the given accountID. +func (s *searchDB) followedAccounts(accountID string) *bun.SelectQuery { +	return s.conn. +		NewSelect(). +		TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")). +		Column("follow.target_account_id"). +		Where("? = ?", bun.Ident("follow.account_id"), accountID) +} + +// statusText returns a subquery that selects a concatenation +// of account username and display name as "account_text". If +// `following` is true, then account note will also be included +// in the concatenation. +func (s *searchDB) accountText(following bool) *bun.SelectQuery { +	var ( +		accountText = s.conn.NewSelect() +		query       string +		args        []interface{} +	) + +	if following { +		// If querying for accounts we follow, +		// include note in text search params. +		args = []interface{}{ +			bun.Ident("account.username"), +			bun.Ident("account.display_name"), "", +			bun.Ident("account.note"), "", +			bun.Ident("account_text"), +		} +	} else { +		// If querying for accounts we're not following, +		// don't include note in text search params. +		args = []interface{}{ +			bun.Ident("account.username"), +			bun.Ident("account.display_name"), "", +			bun.Ident("account_text"), +		} +	} + +	// SQLite and Postgres use different syntaxes for +	// concatenation, and we also need to use a +	// different number of placeholders depending on +	// following/not following. COALESCE calls ensure +	// that we're not trying to concatenate null values. +	d := s.conn.Dialect().Name() +	switch { + +	case d == dialect.SQLite && following: +		query = "LOWER(? || COALESCE(?, ?) || COALESCE(?, ?)) AS ?" + +	case d == dialect.SQLite && !following: +		query = "LOWER(? || COALESCE(?, ?)) AS ?" + +	case d == dialect.PG && following: +		query = "LOWER(CONCAT(?, COALESCE(?, ?), COALESCE(?, ?))) AS ?" + +	case d == dialect.PG && !following: +		query = "LOWER(CONCAT(?, COALESCE(?, ?))) AS ?" + +	default: +		panic("db conn was neither pg not sqlite") +	} + +	return accountText.ColumnExpr(query, args...) +} + +// Query example (SQLite): +// +//	SELECT "status"."id" +//	FROM "statuses" AS "status" +//	WHERE ("status"."boost_of_id" IS NULL) +//	AND (("status"."account_id" = '01F8MH1H7YV1Z7D2C8K2730QBF') OR ("status"."in_reply_to_account_id" = '01F8MH1H7YV1Z7D2C8K2730QBF')) +//	AND ("status"."id" < 'ZZZZZZZZZZZZZZZZZZZZZZZZZZ') +//	AND ((SELECT LOWER("status"."content" || COALESCE("status"."content_warning", '')) AS "status_text") LIKE '%hello%' ESCAPE '\') +//	ORDER BY "status"."id" DESC LIMIT 10 +func (s *searchDB) SearchForStatuses( +	ctx context.Context, +	accountID string, +	query string, +	maxID string, +	minID string, +	limit int, +	offset int, +) ([]*gtsmodel.Status, error) { +	// Ensure reasonable +	if limit < 0 { +		limit = 0 +	} + +	// Make educated guess for slice size +	var ( +		statusIDs   = make([]string, 0, limit) +		frontToBack = true +	) + +	q := s.conn. +		NewSelect(). +		TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")). +		// Select only IDs from table +		Column("status.id"). +		// Ignore boosts. +		Where("? IS NULL", bun.Ident("status.boost_of_id")). +		// Select only statuses created by +		// accountID or replying to accountID. +		WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { +			return q. +				Where("? = ?", bun.Ident("status.account_id"), accountID). +				WhereOr("? = ?", bun.Ident("status.in_reply_to_account_id"), accountID) +		}) + +	// Return only items with a LOWER id than maxID. +	if maxID == "" { +		maxID = id.Highest +	} +	q = q.Where("? < ?", bun.Ident("status.id"), maxID) + +	if minID != "" { +		// return only statuses HIGHER (ie., newer) than minID +		q = q.Where("? > ?", bun.Ident("status.id"), minID) + +		// page up +		frontToBack = false +	} + +	// Select status text as subquery. +	statusTextSubq := s.statusText() + +	// Search using LIKE for matches of query +	// string within statusText subquery. +	q = whereSubqueryLike(q, statusTextSubq, query) + +	if limit > 0 { +		// Limit amount of statuses returned. +		q = q.Limit(limit) +	} + +	if frontToBack { +		// Page down. +		q = q.Order("status.id DESC") +	} else { +		// Page up. +		q = q.Order("status.id ASC") +	} + +	if err := q.Scan(ctx, &statusIDs); err != nil { +		return nil, s.conn.ProcessError(err) +	} + +	if len(statusIDs) == 0 { +		return nil, nil +	} + +	// If we're paging up, we still want statuses +	// to be sorted by ID desc, so reverse ids slice. +	// https://zchee.github.io/golang-wiki/SliceTricks/#reversing +	if !frontToBack { +		for l, r := 0, len(statusIDs)-1; l < r; l, r = l+1, r-1 { +			statusIDs[l], statusIDs[r] = statusIDs[r], statusIDs[l] +		} +	} + +	statuses := make([]*gtsmodel.Status, 0, len(statusIDs)) +	for _, id := range statusIDs { +		// Fetch status from db for ID +		status, err := s.state.DB.GetStatusByID(ctx, id) +		if err != nil { +			log.Errorf(ctx, "error fetching status %q: %v", id, err) +			continue +		} + +		// Append status to slice +		statuses = append(statuses, status) +	} + +	return statuses, nil +} + +// statusText returns a subquery that selects a concatenation +// of status content and content warning as "status_text". +func (s *searchDB) statusText() *bun.SelectQuery { +	statusText := s.conn.NewSelect() + +	// SQLite and Postgres use different +	// syntaxes for concatenation. +	switch s.conn.Dialect().Name() { + +	case dialect.SQLite: +		statusText = statusText.ColumnExpr( +			"LOWER(? || COALESCE(?, ?)) AS ?", +			bun.Ident("status.content"), bun.Ident("status.content_warning"), "", +			bun.Ident("status_text")) + +	case dialect.PG: +		statusText = statusText.ColumnExpr( +			"LOWER(CONCAT(?, COALESCE(?, ?))) AS ?", +			bun.Ident("status.content"), bun.Ident("status.content_warning"), "", +			bun.Ident("status_text")) + +	default: +		panic("db conn was neither pg not sqlite") +	} + +	return statusText +} diff --git a/internal/db/bundb/search_test.go b/internal/db/bundb/search_test.go new file mode 100644 index 000000000..d670c90d6 --- /dev/null +++ b/internal/db/bundb/search_test.go @@ -0,0 +1,82 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package bundb_test + +import ( +	"context" +	"testing" + +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/db" +) + +type SearchTestSuite struct { +	BunDBStandardTestSuite +} + +func (suite *SearchTestSuite) TestSearchAccountsTurtleAny() { +	testAccount := suite.testAccounts["local_account_1"] + +	accounts, err := suite.db.SearchForAccounts(context.Background(), testAccount.ID, "turtle", "", "", 10, false, 0) +	suite.NoError(err) +	suite.Len(accounts, 1) +} + +func (suite *SearchTestSuite) TestSearchAccountsTurtleFollowing() { +	testAccount := suite.testAccounts["local_account_1"] + +	accounts, err := suite.db.SearchForAccounts(context.Background(), testAccount.ID, "turtle", "", "", 10, true, 0) +	suite.NoError(err) +	suite.Len(accounts, 1) +} + +func (suite *SearchTestSuite) TestSearchAccountsPostFollowing() { +	testAccount := suite.testAccounts["local_account_1"] + +	accounts, err := suite.db.SearchForAccounts(context.Background(), testAccount.ID, "post", "", "", 10, true, 0) +	suite.NoError(err) +	suite.Len(accounts, 1) +} + +func (suite *SearchTestSuite) TestSearchAccountsPostAny() { +	testAccount := suite.testAccounts["local_account_1"] + +	accounts, err := suite.db.SearchForAccounts(context.Background(), testAccount.ID, "post", "", "", 10, false, 0) +	suite.NoError(err, db.ErrNoEntries) +	suite.Empty(accounts) +} + +func (suite *SearchTestSuite) TestSearchAccountsFossAny() { +	testAccount := suite.testAccounts["local_account_1"] + +	accounts, err := suite.db.SearchForAccounts(context.Background(), testAccount.ID, "foss", "", "", 10, false, 0) +	suite.NoError(err) +	suite.Len(accounts, 1) +} + +func (suite *SearchTestSuite) TestSearchStatuses() { +	testAccount := suite.testAccounts["local_account_1"] + +	statuses, err := suite.db.SearchForStatuses(context.Background(), testAccount.ID, "hello", "", "", 10, 0) +	suite.NoError(err) +	suite.Len(statuses, 1) +} + +func TestSearchTestSuite(t *testing.T) { +	suite.Run(t, new(SearchTestSuite)) +} diff --git a/internal/db/db.go b/internal/db/db.go index f47a35bb3..f99bd212e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -42,6 +42,7 @@ type DB interface {  	Notification  	Relationship  	Report +	Search  	Session  	Status  	StatusBookmark diff --git a/internal/db/search.go b/internal/db/search.go new file mode 100644 index 000000000..b2ade0cfe --- /dev/null +++ b/internal/db/search.go @@ -0,0 +1,32 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package db + +import ( +	"context" + +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type Search interface { +	// SearchForAccounts uses the given query text to search for accounts that accountID follows. +	SearchForAccounts(ctx context.Context, accountID string, query string, maxID string, minID string, limit int, following bool, offset int) ([]*gtsmodel.Account, error) + +	// SearchForStatuses uses the given query text to search for statuses created by accountID, or in reply to accountID. +	SearchForStatuses(ctx context.Context, accountID string, query string, maxID string, minID string, limit int, offset int) ([]*gtsmodel.Status, error) +} diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go index 7956157ff..bce9065c1 100644 --- a/internal/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -104,7 +104,8 @@ func (a *Account) IsInstance() bool {  	return a.Username == a.Domain ||  		a.FollowersURI == "" ||  		a.FollowingURI == "" || -		(a.Username == "internal.fetch" && strings.Contains(a.Note, "internal service actor")) +		(a.Username == "internal.fetch" && strings.Contains(a.Note, "internal service actor")) || +		a.Username == "instance.actor" // <- misskey  }  // EmojisPopulated returns whether emojis are populated according to current EmojiIDs. diff --git a/internal/processing/processor.go b/internal/processing/processor.go index b67e5252e..377f176e5 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -32,6 +32,7 @@ import (  	"github.com/superseriousbusiness/gotosocial/internal/processing/list"  	"github.com/superseriousbusiness/gotosocial/internal/processing/media"  	"github.com/superseriousbusiness/gotosocial/internal/processing/report" +	"github.com/superseriousbusiness/gotosocial/internal/processing/search"  	"github.com/superseriousbusiness/gotosocial/internal/processing/status"  	"github.com/superseriousbusiness/gotosocial/internal/processing/stream"  	"github.com/superseriousbusiness/gotosocial/internal/processing/timeline" @@ -60,6 +61,7 @@ type Processor struct {  	list     list.Processor  	media    media.Processor  	report   report.Processor +	search   search.Processor  	status   status.Processor  	stream   stream.Processor  	timeline timeline.Processor @@ -90,6 +92,10 @@ func (p *Processor) Report() *report.Processor {  	return &p.report  } +func (p *Processor) Search() *search.Processor { +	return &p.search +} +  func (p *Processor) Status() *status.Processor {  	return &p.status  } @@ -137,6 +143,7 @@ func NewProcessor(  	processor.media = media.New(state, tc, mediaManager, federator.TransportController())  	processor.report = report.New(state, tc)  	processor.timeline = timeline.New(state, tc, filter) +	processor.search = search.New(state, federator, tc, filter)  	processor.status = status.New(state, federator, tc, filter, parseMentionFunc)  	processor.stream = stream.New(state, oauthServer)  	processor.user = user.New(state, emailSender) diff --git a/internal/processing/search.go b/internal/processing/search.go deleted file mode 100644 index ef5da9ee7..000000000 --- a/internal/processing/search.go +++ /dev/null @@ -1,295 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program.  If not, see <http://www.gnu.org/licenses/>. - -package processing - -import ( -	"context" -	"errors" -	"fmt" -	"net/url" -	"strings" - -	"codeberg.org/gruf/go-kv" -	"github.com/superseriousbusiness/gotosocial/internal/ap" -	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" -	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" -	"github.com/superseriousbusiness/gotosocial/internal/gtserror" -	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/log" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -	"github.com/superseriousbusiness/gotosocial/internal/util" -) - -// Implementation note: in this function, we tend to log errors -// at debug level rather than return them. This is because the -// search has a sort of fallthrough logic: if we can't get a result -// with x search, we should try with y search rather than returning. -// -// If we get to the end and still haven't found anything, even then -// we shouldn't return an error, just return an empty search result. -// -// The only exception to this is when we get a malformed query, in -// which case we return a bad request error so the user knows they -// did something funky. -func (p *Processor) SearchGet(ctx context.Context, authed *oauth.Auth, search *apimodel.SearchQuery) (*apimodel.SearchResult, gtserror.WithCode) { -	// tidy up the query and make sure it wasn't just spaces -	query := strings.TrimSpace(search.Query) -	if query == "" { -		err := errors.New("search query was empty string after trimming space") -		return nil, gtserror.NewErrorBadRequest(err, err.Error()) -	} - -	l := log.WithContext(ctx). -		WithFields(kv.Fields{{"query", query}}...) - -	searchResult := &apimodel.SearchResult{ -		Accounts: []apimodel.Account{}, -		Statuses: []apimodel.Status{}, -		Hashtags: []apimodel.Tag{}, -	} - -	// currently the search will only ever return one result, -	// so return nothing if the offset is greater than 0 -	if search.Offset > 0 { -		return searchResult, nil -	} - -	foundAccounts := []*gtsmodel.Account{} -	foundStatuses := []*gtsmodel.Status{} - -	var foundOne bool - -	/* -		SEARCH BY MENTION -		check if the query is something like @whatever_username@example.org -- this means it's likely a remote account -	*/ -	maybeNamestring := query -	if maybeNamestring[0] != '@' { -		maybeNamestring = "@" + maybeNamestring -	} - -	if username, domain, err := util.ExtractNamestringParts(maybeNamestring); err == nil { -		l.Trace("search term is a mention, looking it up...") -		blocked, err := p.state.DB.IsDomainBlocked(ctx, domain) -		if err != nil { -			return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking domain block: %w", err)) -		} -		if blocked { -			l.Debug("domain is blocked") -			return searchResult, nil -		} - -		foundAccount, err := p.searchAccountByUsernameDomain(ctx, authed, username, domain, search.Resolve) -		if err != nil { -			var errNotRetrievable *dereferencing.ErrNotRetrievable -			if !errors.As(err, &errNotRetrievable) { -				// return a proper error only if it wasn't just not retrievable -				return nil, gtserror.NewErrorInternalError(fmt.Errorf("error looking up account: %w", err)) -			} -			return searchResult, nil -		} - -		foundAccounts = append(foundAccounts, foundAccount) -		foundOne = true -		l.Trace("got an account by searching by mention") -	} - -	/* -		SEARCH BY URI -		check if the query is a URI with a recognizable scheme and dereference it -	*/ -	if !foundOne { -		if uri, err := url.Parse(query); err == nil { -			if uri.Scheme == "https" || uri.Scheme == "http" { -				l.Trace("search term is a uri, looking it up...") -				blocked, err := p.state.DB.IsURIBlocked(ctx, uri) -				if err != nil { -					return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking domain block: %w", err)) -				} -				if blocked { -					l.Debug("domain is blocked") -					return searchResult, nil -				} - -				// check if it's a status... -				foundStatus, err := p.searchStatusByURI(ctx, authed, uri) -				if err != nil { -					// Check for semi-expected error types. -					var ( -						errNotRetrievable *dereferencing.ErrNotRetrievable -						errWrongType      *ap.ErrWrongType -					) -					if !errors.As(err, &errNotRetrievable) && !errors.As(err, &errWrongType) { -						return nil, gtserror.NewErrorInternalError(fmt.Errorf("error looking up status: %w", err)) -					} -				} else { -					foundStatuses = append(foundStatuses, foundStatus) -					foundOne = true -					l.Trace("got a status by searching by URI") -				} - -				// ... or an account -				if !foundOne { -					foundAccount, err := p.searchAccountByURI(ctx, authed, uri, search.Resolve) -					if err != nil { -						// Check for semi-expected error types. -						var ( -							errNotRetrievable *dereferencing.ErrNotRetrievable -							errWrongType      *ap.ErrWrongType -						) -						if !errors.As(err, &errNotRetrievable) && !errors.As(err, &errWrongType) { -							return nil, gtserror.NewErrorInternalError(fmt.Errorf("error looking up account: %w", err)) -						} -					} else { -						foundAccounts = append(foundAccounts, foundAccount) -						foundOne = true -						l.Trace("got an account by searching by URI") -					} -				} -			} -		} -	} - -	if !foundOne { -		// we got nothing, we can return early -		l.Trace("found nothing, returning") -		return searchResult, nil -	} - -	/* -		FROM HERE ON we have our search results, it's just a matter of filtering them according to what this user is allowed to see, -		and then converting them into our frontend format. -	*/ -	for _, foundAccount := range foundAccounts { -		// make sure there's no block in either direction between the account and the requester -		blocked, err := p.state.DB.IsEitherBlocked(ctx, authed.Account.ID, foundAccount.ID) -		if err != nil { -			err = fmt.Errorf("SearchGet: error checking block between %s and %s: %s", authed.Account.ID, foundAccount.ID, err) -			return nil, gtserror.NewErrorInternalError(err) -		} - -		if blocked { -			l.Tracef("block exists between %s and %s, skipping this result", authed.Account.ID, foundAccount.ID) -			continue -		} - -		apiAcct, err := p.tc.AccountToAPIAccountPublic(ctx, foundAccount) -		if err != nil { -			err = fmt.Errorf("SearchGet: error converting account %s to api account: %s", foundAccount.ID, err) -			return nil, gtserror.NewErrorInternalError(err) -		} - -		searchResult.Accounts = append(searchResult.Accounts, *apiAcct) -	} - -	for _, foundStatus := range foundStatuses { -		// make sure each found status is visible to the requester -		visible, err := p.filter.StatusVisible(ctx, authed.Account, foundStatus) -		if err != nil { -			err = fmt.Errorf("SearchGet: error checking visibility of status %s for account %s: %s", foundStatus.ID, authed.Account.ID, err) -			return nil, gtserror.NewErrorInternalError(err) -		} - -		if !visible { -			l.Tracef("status %s is not visible to account %s, skipping this result", foundStatus.ID, authed.Account.ID) -			continue -		} - -		apiStatus, err := p.tc.StatusToAPIStatus(ctx, foundStatus, authed.Account) -		if err != nil { -			err = fmt.Errorf("SearchGet: error converting status %s to api status: %s", foundStatus.ID, err) -			return nil, gtserror.NewErrorInternalError(err) -		} - -		searchResult.Statuses = append(searchResult.Statuses, *apiStatus) -	} - -	return searchResult, nil -} - -func (p *Processor) searchStatusByURI(ctx context.Context, authed *oauth.Auth, uri *url.URL) (*gtsmodel.Status, error) { -	status, _, err := p.federator.GetStatusByURI(gtscontext.SetFastFail(ctx), authed.Account.Username, uri) -	return status, err -} - -func (p *Processor) searchAccountByURI(ctx context.Context, authed *oauth.Auth, uri *url.URL, resolve bool) (*gtsmodel.Account, error) { -	if !resolve { -		var ( -			account *gtsmodel.Account -			err     error -			uriStr  = uri.String() -		) - -		// Search the database for existing account with ID URI. -		account, err = p.state.DB.GetAccountByURI(ctx, uriStr) -		if err != nil && !errors.Is(err, db.ErrNoEntries) { -			return nil, fmt.Errorf("searchAccountByURI: error checking database for account %s: %w", uriStr, err) -		} - -		if account == nil { -			// Else, search the database for existing by ID URL. -			account, err = p.state.DB.GetAccountByURL(ctx, uriStr) -			if err != nil { -				if !errors.Is(err, db.ErrNoEntries) { -					return nil, fmt.Errorf("searchAccountByURI: error checking database for account %s: %w", uriStr, err) -				} -				return nil, dereferencing.NewErrNotRetrievable(err) -			} -		} - -		return account, nil -	} - -	account, _, err := p.federator.GetAccountByURI( -		gtscontext.SetFastFail(ctx), -		authed.Account.Username, -		uri, -	) -	return account, err -} - -func (p *Processor) searchAccountByUsernameDomain(ctx context.Context, authed *oauth.Auth, username string, domain string, resolve bool) (*gtsmodel.Account, error) { -	if !resolve { -		if domain == config.GetHost() || domain == config.GetAccountDomain() { -			// We do local lookups using an empty domain, -			// else it will fail the db search below. -			domain = "" -		} - -		// Search the database for existing account with USERNAME@DOMAIN -		account, err := p.state.DB.GetAccountByUsernameDomain(ctx, username, domain) -		if err != nil { -			if !errors.Is(err, db.ErrNoEntries) { -				return nil, fmt.Errorf("searchAccountByUsernameDomain: error checking database for account %s@%s: %w", username, domain, err) -			} -			return nil, dereferencing.NewErrNotRetrievable(err) -		} - -		return account, nil -	} - -	account, _, err := p.federator.GetAccountByUsernameDomain( -		gtscontext.SetFastFail(ctx), -		authed.Account.Username, -		username, domain, -	) -	return account, err -} diff --git a/internal/processing/search/accounts.go b/internal/processing/search/accounts.go new file mode 100644 index 000000000..eb88647a3 --- /dev/null +++ b/internal/processing/search/accounts.go @@ -0,0 +1,110 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package search + +import ( +	"context" +	"errors" +	"strings" + +	"codeberg.org/gruf/go-kv" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/id" +	"github.com/superseriousbusiness/gotosocial/internal/log" +) + +// Accounts does a partial search for accounts that +// match the given query. It expects input that looks +// like a namestring, and will normalize plaintext to look +// more like a namestring. For queries that include domain, +// it will only return one match at most. For namestrings +// that exclude domain, multiple matches may be returned. +// +// This behavior aligns more or less with Mastodon's API. +// See https://docs.joinmastodon.org/methods/accounts/#search. +func (p *Processor) Accounts( +	ctx context.Context, +	requestingAccount *gtsmodel.Account, +	query string, +	limit int, +	offset int, +	resolve bool, +	following bool, +) ([]*apimodel.Account, gtserror.WithCode) { +	var ( +		foundAccounts = make([]*gtsmodel.Account, 0, limit) +		appendAccount = func(foundAccount *gtsmodel.Account) { foundAccounts = append(foundAccounts, foundAccount) } +	) + +	// Validate query. +	query = strings.TrimSpace(query) +	if query == "" { +		err := gtserror.New("search query was empty string after trimming space") +		return nil, gtserror.NewErrorBadRequest(err, err.Error()) +	} + +	// Be nice and normalize query by prepending '@'. +	// This will make it easier for accountsByNamestring +	// to pick this up as a valid namestring. +	if query[0] != '@' { +		query = "@" + query +	} + +	log. +		WithContext(ctx). +		WithFields(kv.Fields{ +			{"limit", limit}, +			{"offset", offset}, +			{"query", query}, +			{"resolve", resolve}, +			{"following", following}, +		}...). +		Debugf("beginning search") + +	// todo: Currently we don't support offset for paging; +	// if caller supplied an offset greater than 0, return +	// nothing as though there were no additional results. +	if offset > 0 { +		return p.packageAccounts(ctx, requestingAccount, foundAccounts) +	} + +	// Return all accounts we can find that match the +	// provided query. If it's not a namestring, this +	// won't return an error, it'll just return 0 results. +	if _, err := p.accountsByNamestring( +		ctx, +		requestingAccount, +		id.Highest, +		id.Lowest, +		limit, +		offset, +		query, +		resolve, +		following, +		appendAccount, +	); err != nil && !errors.Is(err, db.ErrNoEntries) { +		err = gtserror.Newf("error searching by namestring: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	// Return whatever we got (if anything). +	return p.packageAccounts(ctx, requestingAccount, foundAccounts) +} diff --git a/internal/processing/search/get.go b/internal/processing/search/get.go new file mode 100644 index 000000000..936e8acfa --- /dev/null +++ b/internal/processing/search/get.go @@ -0,0 +1,696 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package search + +import ( +	"context" +	"errors" +	"fmt" +	"net/mail" +	"net/url" +	"strings" + +	"codeberg.org/gruf/go-kv" +	"github.com/superseriousbusiness/gotosocial/internal/ap" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" +	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/log" +	"github.com/superseriousbusiness/gotosocial/internal/util" +) + +const ( +	queryTypeAny      = "" +	queryTypeAccounts = "accounts" +	queryTypeStatuses = "statuses" +	queryTypeHashtags = "hashtags" +) + +// Get performs a search for accounts and/or statuses using the +// provided request parameters. +// +// Implementation note: in this function, we try to only return +// an error to the caller they've submitted a bad request, or when +// a serious error has occurred. This is because the search has a +// sort of fallthrough logic: if we can't get a result with one +// type of search, we should proceed with y search rather than +// returning an early error. +// +// If we get to the end and still haven't found anything, even +// then we shouldn't return an error, just return an empty result. +func (p *Processor) Get( +	ctx context.Context, +	account *gtsmodel.Account, +	req *apimodel.SearchRequest, +) (*apimodel.SearchResult, gtserror.WithCode) { + +	var ( +		maxID     = req.MaxID +		minID     = req.MinID +		limit     = req.Limit +		offset    = req.Offset +		query     = strings.TrimSpace(req.Query)                      // Trim trailing/leading whitespace. +		queryType = strings.TrimSpace(strings.ToLower(req.QueryType)) // Trim trailing/leading whitespace; convert to lowercase. +		resolve   = req.Resolve +		following = req.Following +	) + +	// Validate query. +	if query == "" { +		err := errors.New("search query was empty string after trimming space") +		return nil, gtserror.NewErrorBadRequest(err, err.Error()) +	} + +	// Validate query type. +	switch queryType { +	case queryTypeAny, queryTypeAccounts, queryTypeStatuses, queryTypeHashtags: +		// No problem. +	default: +		err := fmt.Errorf( +			"search query type %s was not recognized, valid options are ['%s', '%s', '%s', '%s']", +			queryType, queryTypeAny, queryTypeAccounts, queryTypeStatuses, queryTypeHashtags, +		) +		return nil, gtserror.NewErrorBadRequest(err, err.Error()) +	} + +	log. +		WithContext(ctx). +		WithFields(kv.Fields{ +			{"maxID", maxID}, +			{"minID", minID}, +			{"limit", limit}, +			{"offset", offset}, +			{"query", query}, +			{"queryType", queryType}, +			{"resolve", resolve}, +			{"following", following}, +		}...). +		Debugf("beginning search") + +	// todo: Currently we don't support offset for paging; +	// a caller can page using maxID or minID, but if they +	// supply an offset greater than 0, return nothing as +	// though there were no additional results. +	if req.Offset > 0 { +		return p.packageSearchResult(ctx, account, nil, nil) +	} + +	var ( +		foundStatuses = make([]*gtsmodel.Status, 0, limit) +		foundAccounts = make([]*gtsmodel.Account, 0, limit) +		appendStatus  = func(foundStatus *gtsmodel.Status) { foundStatuses = append(foundStatuses, foundStatus) } +		appendAccount = func(foundAccount *gtsmodel.Account) { foundAccounts = append(foundAccounts, foundAccount) } +		keepLooking   bool +		err           error +	) + +	// Only try to search by namestring if search type includes +	// accounts, since this is all namestring search can return. +	if includeAccounts(queryType) { +		// Copy query to avoid altering original. +		var queryC = query + +		// If query looks vaguely like an email address, ie. it doesn't +		// start with '@' but it has '@' in it somewhere, it's probably +		// a poorly-formed namestring. Be generous and correct for this. +		if strings.Contains(queryC, "@") && queryC[0] != '@' { +			if _, err := mail.ParseAddress(queryC); err == nil { +				// Yep, really does look like +				// an email address! Be nice. +				queryC = "@" + queryC +			} +		} + +		// Search using what may or may not be a namestring. +		keepLooking, err = p.accountsByNamestring( +			ctx, +			account, +			maxID, +			minID, +			limit, +			offset, +			queryC, +			resolve, +			following, +			appendAccount, +		) +		if err != nil && !errors.Is(err, db.ErrNoEntries) { +			err = gtserror.Newf("error searching by namestring: %w", err) +			return nil, gtserror.NewErrorInternalError(err) +		} + +		if !keepLooking { +			// Return whatever we have. +			return p.packageSearchResult( +				ctx, +				account, +				foundAccounts, +				foundStatuses, +			) +		} +	} + +	// Check if the query is a URI with a recognizable +	// scheme and use it to look for accounts or statuses. +	keepLooking, err = p.byURI( +		ctx, +		account, +		query, +		queryType, +		resolve, +		appendAccount, +		appendStatus, +	) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		err = gtserror.Newf("error searching by URI: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	if !keepLooking { +		// Return whatever we have. +		return p.packageSearchResult( +			ctx, +			account, +			foundAccounts, +			foundStatuses, +		) +	} + +	// As a last resort, search for accounts and +	// statuses using the query as arbitrary text. +	if err := p.byText( +		ctx, +		account, +		maxID, +		minID, +		limit, +		offset, +		query, +		queryType, +		following, +		appendAccount, +		appendStatus, +	); err != nil && !errors.Is(err, db.ErrNoEntries) { +		err = gtserror.Newf("error searching by text: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	// Return whatever we ended +	// up with (could be nothing). +	return p.packageSearchResult( +		ctx, +		account, +		foundAccounts, +		foundStatuses, +	) +} + +// accountsByNamestring searches for accounts using the +// provided namestring query. If domain is not set in +// the namestring, it may return more than one result +// by doing a text search in the database for accounts +// matching the query. Otherwise, it tries to return an +// exact match. +func (p *Processor) accountsByNamestring( +	ctx context.Context, +	requestingAccount *gtsmodel.Account, +	maxID string, +	minID string, +	limit int, +	offset int, +	query string, +	resolve bool, +	following bool, +	appendAccount func(*gtsmodel.Account), +) (bool, error) { +	// See if we have something that looks like a namestring. +	username, domain, err := util.ExtractNamestringParts(query) +	if err != nil { +		// No need to return error; just not a namestring +		// we can search with. Caller should keep looking +		// with another search method. +		return true, nil //nolint:nilerr +	} + +	if domain == "" { +		// No error, but no domain set. That means the query +		// looked like '@someone' which is not an exact search. +		// Try to search for any accounts that match the query +		// string, and let the caller know they should stop. +		return false, p.accountsByText( +			ctx, +			requestingAccount.ID, +			maxID, +			minID, +			limit, +			offset, +			// OK to assume username is set now. Use +			// it instead of query to omit leading '@'. +			username, +			following, +			appendAccount, +		) +	} + +	// No error, and domain and username were both set. +	// Caller is likely trying to search for an exact +	// match, from either a remote instance or local. +	foundAccount, err := p.accountByUsernameDomain( +		ctx, +		requestingAccount, +		username, +		domain, +		resolve, +	) +	if err != nil { +		// Check for semi-expected error types. +		// On one of these, we can continue. +		var ( +			errNotRetrievable = new(*dereferencing.ErrNotRetrievable) // Item can't be dereferenced. +			errWrongType      = new(*ap.ErrWrongType)                 // Item was dereferenced, but wasn't an account. +		) + +		if !errors.As(err, errNotRetrievable) && !errors.As(err, errWrongType) { +			err = gtserror.Newf("error looking up %s as account: %w", query, err) +			return false, gtserror.NewErrorInternalError(err) +		} +	} else { +		appendAccount(foundAccount) +	} + +	// Regardless of whether we have a hit at this point, +	// return false to indicate caller should stop looking; +	// namestrings are a very specific format so it's unlikely +	// the caller was looking for something other than an account. +	return false, nil +} + +// accountByUsernameDomain looks for one account with the given +// username and domain. If domain is empty, or equal to our domain, +// search will be confined to local accounts. +// +// Will return either a hit, an ErrNotRetrievable, an ErrWrongType, +// or a real error that the caller should handle. +func (p *Processor) accountByUsernameDomain( +	ctx context.Context, +	requestingAccount *gtsmodel.Account, +	username string, +	domain string, +	resolve bool, +) (*gtsmodel.Account, error) { +	var usernameDomain string +	if domain == "" || domain == config.GetHost() || domain == config.GetAccountDomain() { +		// Local lookup, normalize domain. +		domain = "" +		usernameDomain = username +	} else { +		// Remote lookup. +		usernameDomain = username + "@" + domain + +		// Ensure domain not blocked. +		blocked, err := p.state.DB.IsDomainBlocked(ctx, domain) +		if err != nil { +			err = gtserror.Newf("error checking domain block: %w", err) +			return nil, gtserror.NewErrorInternalError(err) +		} + +		if blocked { +			// Don't search on blocked domain. +			return nil, dereferencing.NewErrNotRetrievable(err) +		} +	} + +	if resolve { +		// We're allowed to resolve, leave the +		// rest up to the dereferencer functions. +		account, _, err := p.federator.GetAccountByUsernameDomain( +			gtscontext.SetFastFail(ctx), +			requestingAccount.Username, +			username, domain, +		) + +		return account, err +	} + +	// We're not allowed to resolve. Search the database +	// for existing account with given username + domain. +	account, err := p.state.DB.GetAccountByUsernameDomain(ctx, username, domain) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		err = gtserror.Newf("error checking database for account %s: %w", usernameDomain, err) +		return nil, err +	} + +	if account != nil { +		// We got a hit! No need to continue. +		return account, nil +	} + +	err = fmt.Errorf("account %s could not be retrieved locally and we cannot resolve", usernameDomain) +	return nil, dereferencing.NewErrNotRetrievable(err) +} + +// byURI looks for account(s) or a status with the given URI +// set as either its URL or ActivityPub URI. If it gets hits, it +// will call the provided append functions to return results. +// +// The boolean return value indicates to the caller whether the +// search should continue (true) or stop (false). False will be +// returned in cases where a hit has been found, the domain of the +// searched URI is blocked, or an unrecoverable error has occurred. +func (p *Processor) byURI( +	ctx context.Context, +	requestingAccount *gtsmodel.Account, +	query string, +	queryType string, +	resolve bool, +	appendAccount func(*gtsmodel.Account), +	appendStatus func(*gtsmodel.Status), +) (bool, error) { +	uri, err := url.Parse(query) +	if err != nil { +		// No need to return error; just not a URI +		// we can search with. Caller should keep +		// looking with another search method. +		return true, nil //nolint:nilerr +	} + +	if !(uri.Scheme == "https" || uri.Scheme == "http") { +		// This might just be a weirdly-parsed URI, +		// since Go's url package tends to be a bit +		// trigger-happy when deciding things are URIs. +		// Indicate caller should keep looking. +		return true, nil +	} + +	blocked, err := p.state.DB.IsURIBlocked(ctx, uri) +	if err != nil { +		err = gtserror.Newf("error checking domain block: %w", err) +		return false, gtserror.NewErrorInternalError(err) +	} + +	if blocked { +		// Don't search for blocked domains. +		// Caller should stop looking. +		return false, nil +	} + +	if includeAccounts(queryType) { +		// Check if URI points to an account. +		foundAccount, err := p.accountByURI(ctx, requestingAccount, uri, resolve) +		if err != nil { +			// Check for semi-expected error types. +			// On one of these, we can continue. +			var ( +				errNotRetrievable = new(*dereferencing.ErrNotRetrievable) // Item can't be dereferenced. +				errWrongType      = new(*ap.ErrWrongType)                 // Item was dereferenced, but wasn't an account. +			) + +			if !errors.As(err, errNotRetrievable) && !errors.As(err, errWrongType) { +				err = gtserror.Newf("error looking up %s as account: %w", uri, err) +				return false, gtserror.NewErrorInternalError(err) +			} +		} else { +			// Hit; return false to indicate caller should +			// stop looking, since it's extremely unlikely +			// a status and an account will have the same URL. +			appendAccount(foundAccount) +			return false, nil +		} +	} + +	if includeStatuses(queryType) { +		// Check if URI points to a status. +		foundStatus, err := p.statusByURI(ctx, requestingAccount, uri, resolve) +		if err != nil { +			// Check for semi-expected error types. +			// On one of these, we can continue. +			var ( +				errNotRetrievable = new(*dereferencing.ErrNotRetrievable) // Item can't be dereferenced. +				errWrongType      = new(*ap.ErrWrongType)                 // Item was dereferenced, but wasn't a status. +			) + +			if !errors.As(err, errNotRetrievable) && !errors.As(err, errWrongType) { +				err = gtserror.Newf("error looking up %s as status: %w", uri, err) +				return false, gtserror.NewErrorInternalError(err) +			} +		} else { +			// Hit; return false to indicate caller should +			// stop looking, since it's extremely unlikely +			// a status and an account will have the same URL. +			appendStatus(foundStatus) +			return false, nil +		} +	} + +	// No errors, but no hits either; since this +	// was a URI, caller should stop looking. +	return false, nil +} + +// accountByURI looks for one account with the given URI. +// If resolve is false, it will only look in the database. +// If resolve is true, it will try to resolve the account +// from remote using the URI, if necessary. +// +// Will return either a hit, ErrNotRetrievable, ErrWrongType, +// or a real error that the caller should handle. +func (p *Processor) accountByURI( +	ctx context.Context, +	requestingAccount *gtsmodel.Account, +	uri *url.URL, +	resolve bool, +) (*gtsmodel.Account, error) { +	if resolve { +		// We're allowed to resolve, leave the +		// rest up to the dereferencer functions. +		account, _, err := p.federator.GetAccountByURI( +			gtscontext.SetFastFail(ctx), +			requestingAccount.Username, +			uri, +		) + +		return account, err +	} + +	// We're not allowed to resolve; search database only. +	uriStr := uri.String() // stringify uri just once + +	// Search by ActivityPub URI. +	account, err := p.state.DB.GetAccountByURI(ctx, uriStr) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		err = gtserror.Newf("error checking database for account using URI %s: %w", uriStr, err) +		return nil, err +	} + +	if account != nil { +		// We got a hit! No need to continue. +		return account, nil +	} + +	// No hit yet. Fallback to try by URL. +	account, err = p.state.DB.GetAccountByURL(ctx, uriStr) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		err = gtserror.Newf("error checking database for account using URL %s: %w", uriStr, err) +		return nil, err +	} + +	if account != nil { +		// We got a hit! No need to continue. +		return account, nil +	} + +	err = fmt.Errorf("account %s could not be retrieved locally and we cannot resolve", uriStr) +	return nil, dereferencing.NewErrNotRetrievable(err) +} + +// statusByURI looks for one status with the given URI. +// If resolve is false, it will only look in the database. +// If resolve is true, it will try to resolve the status +// from remote using the URI, if necessary. +// +// Will return either a hit, ErrNotRetrievable, ErrWrongType, +// or a real error that the caller should handle. +func (p *Processor) statusByURI( +	ctx context.Context, +	requestingAccount *gtsmodel.Account, +	uri *url.URL, +	resolve bool, +) (*gtsmodel.Status, error) { +	if resolve { +		// We're allowed to resolve, leave the +		// rest up to the dereferencer functions. +		status, _, err := p.federator.GetStatusByURI( +			gtscontext.SetFastFail(ctx), +			requestingAccount.Username, +			uri, +		) + +		return status, err +	} + +	// We're not allowed to resolve; search database only. +	uriStr := uri.String() // stringify uri just once + +	// Search by ActivityPub URI. +	status, err := p.state.DB.GetStatusByURI(ctx, uriStr) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		err = gtserror.Newf("error checking database for status using URI %s: %w", uriStr, err) +		return nil, err +	} + +	if status != nil { +		// We got a hit! No need to continue. +		return status, nil +	} + +	// No hit yet. Fallback to try by URL. +	status, err = p.state.DB.GetStatusByURL(ctx, uriStr) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		err = gtserror.Newf("error checking database for status using URL %s: %w", uriStr, err) +		return nil, err +	} + +	if status != nil { +		// We got a hit! No need to continue. +		return status, nil +	} + +	err = fmt.Errorf("status %s could not be retrieved locally and we cannot resolve", uriStr) +	return nil, dereferencing.NewErrNotRetrievable(err) +} + +// byText searches in the database for accounts and/or +// statuses containing the given query string, using +// the provided parameters. +// +// If queryType is any (empty string), both accounts +// and statuses will be searched, else only the given +// queryType of item will be returned. +func (p *Processor) byText( +	ctx context.Context, +	requestingAccount *gtsmodel.Account, +	maxID string, +	minID string, +	limit int, +	offset int, +	query string, +	queryType string, +	following bool, +	appendAccount func(*gtsmodel.Account), +	appendStatus func(*gtsmodel.Status), +) error { +	if queryType == queryTypeAny { +		// If search type is any, ignore maxID and minID +		// parameters, since we can't use them to page +		// on both accounts and statuses simultaneously. +		maxID = "" +		minID = "" +	} + +	if includeAccounts(queryType) { +		// Search for accounts using the given text. +		if err := p.accountsByText(ctx, +			requestingAccount.ID, +			maxID, +			minID, +			limit, +			offset, +			query, +			following, +			appendAccount, +		); err != nil { +			return err +		} +	} + +	if includeStatuses(queryType) { +		// Search for statuses using the given text. +		if err := p.statusesByText(ctx, +			requestingAccount.ID, +			maxID, +			minID, +			limit, +			offset, +			query, +			appendStatus, +		); err != nil { +			return err +		} +	} + +	return nil +} + +// accountsByText searches in the database for limit +// number of accounts using the given query text. +func (p *Processor) accountsByText( +	ctx context.Context, +	requestingAccountID string, +	maxID string, +	minID string, +	limit int, +	offset int, +	query string, +	following bool, +	appendAccount func(*gtsmodel.Account), +) error { +	accounts, err := p.state.DB.SearchForAccounts( +		ctx, +		requestingAccountID, +		query, maxID, minID, limit, following, offset) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		return gtserror.Newf("error checking database for accounts using text %s: %w", query, err) +	} + +	for _, account := range accounts { +		appendAccount(account) +	} + +	return nil +} + +// statusesByText searches in the database for limit +// number of statuses using the given query text. +func (p *Processor) statusesByText( +	ctx context.Context, +	requestingAccountID string, +	maxID string, +	minID string, +	limit int, +	offset int, +	query string, +	appendStatus func(*gtsmodel.Status), +) error { +	statuses, err := p.state.DB.SearchForStatuses( +		ctx, +		requestingAccountID, +		query, maxID, minID, limit, offset) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		return gtserror.Newf("error checking database for statuses using text %s: %w", query, err) +	} + +	for _, status := range statuses { +		appendStatus(status) +	} + +	return nil +} diff --git a/internal/processing/search/lookup.go b/internal/processing/search/lookup.go new file mode 100644 index 000000000..0f2a4191b --- /dev/null +++ b/internal/processing/search/lookup.go @@ -0,0 +1,114 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package search + +import ( +	"context" +	"errors" +	"fmt" +	"strings" + +	errorsv2 "codeberg.org/gruf/go-errors/v2" +	"codeberg.org/gruf/go-kv" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/log" +	"github.com/superseriousbusiness/gotosocial/internal/util" +) + +// Lookup does a quick, non-resolving search for accounts that +// match the given query. It expects input that looks like a +// namestring, and will normalize plaintext to look more like +// a namestring. Will only ever return one account, and only on +// an exact match. +// +// This behavior aligns more or less with Mastodon's API. +// See https://docs.joinmastodon.org/methods/accounts/#lookup +func (p *Processor) Lookup( +	ctx context.Context, +	requestingAccount *gtsmodel.Account, +	query string, +) (*apimodel.Account, gtserror.WithCode) { +	// Validate query. +	query = strings.TrimSpace(query) +	if query == "" { +		err := errors.New("search query was empty string after trimming space") +		return nil, gtserror.NewErrorBadRequest(err, err.Error()) +	} + +	// Be nice and normalize query by prepending '@'. +	// This will make it easier for accountsByNamestring +	// to pick this up as a valid namestring. +	if query[0] != '@' { +		query = "@" + query +	} + +	log. +		WithContext(ctx). +		WithFields(kv.Fields{ +			{"query", query}, +		}...). +		Debugf("beginning search") + +	// See if we have something that looks like a namestring. +	username, domain, err := util.ExtractNamestringParts(query) +	if err != nil { +		err := errors.New("bad search query, must in the form '[username]' or '[username]@[domain]") +		return nil, gtserror.NewErrorBadRequest(err, err.Error()) +	} + +	account, err := p.accountByUsernameDomain( +		ctx, +		requestingAccount, +		username, +		domain, +		false, // never resolve! +	) +	if err != nil { +		if errorsv2.Assignable(err, (*dereferencing.ErrNotRetrievable)(nil)) { +			// ErrNotRetrievable is fine, just wrap it in +			// a 404 to indicate we couldn't find anything. +			err := fmt.Errorf("%s not found", query) +			return nil, gtserror.NewErrorNotFound(err, err.Error()) +		} + +		// Real error has occurred. +		err = gtserror.Newf("error looking up %s as account: %w", query, err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	// If we reach this point, we found an account. Shortcut +	// using the packageAccounts function to return it. This +	// may cause the account to be filtered out if it's not +	// visible to the caller, so anticipate this. +	accounts, errWithCode := p.packageAccounts(ctx, requestingAccount, []*gtsmodel.Account{account}) +	if errWithCode != nil { +		return nil, errWithCode +	} + +	if len(accounts) == 0 { +		// Account was not visible to the requesting account. +		err := fmt.Errorf("%s not found", query) +		return nil, gtserror.NewErrorNotFound(err, err.Error()) +	} + +	// We got a hit! +	return accounts[0], nil +} diff --git a/internal/processing/search/search.go b/internal/processing/search/search.go new file mode 100644 index 000000000..907877789 --- /dev/null +++ b/internal/processing/search/search.go @@ -0,0 +1,42 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package search + +import ( +	"github.com/superseriousbusiness/gotosocial/internal/federation" +	"github.com/superseriousbusiness/gotosocial/internal/state" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +	"github.com/superseriousbusiness/gotosocial/internal/visibility" +) + +type Processor struct { +	state     *state.State +	federator federation.Federator +	tc        typeutils.TypeConverter +	filter    *visibility.Filter +} + +// New returns a new status processor. +func New(state *state.State, federator federation.Federator, tc typeutils.TypeConverter, filter *visibility.Filter) Processor { +	return Processor{ +		state:     state, +		federator: federator, +		tc:        tc, +		filter:    filter, +	} +} diff --git a/internal/processing/search/util.go b/internal/processing/search/util.go new file mode 100644 index 000000000..4172e4e1a --- /dev/null +++ b/internal/processing/search/util.go @@ -0,0 +1,138 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package search + +import ( +	"context" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/log" +) + +// return true if given queryType should include accounts. +func includeAccounts(queryType string) bool { +	return queryType == queryTypeAny || queryType == queryTypeAccounts +} + +// return true if given queryType should include statuses. +func includeStatuses(queryType string) bool { +	return queryType == queryTypeAny || queryType == queryTypeStatuses +} + +// packageAccounts is a util function that just +// converts the given accounts into an apimodel +// account slice, or errors appropriately. +func (p *Processor) packageAccounts( +	ctx context.Context, +	requestingAccount *gtsmodel.Account, +	accounts []*gtsmodel.Account, +) ([]*apimodel.Account, gtserror.WithCode) { +	apiAccounts := make([]*apimodel.Account, 0, len(accounts)) + +	for _, account := range accounts { +		if account.IsInstance() { +			// No need to show instance accounts. +			continue +		} + +		// Ensure requester can see result account. +		visible, err := p.filter.AccountVisible(ctx, requestingAccount, account) +		if err != nil { +			err = gtserror.Newf("error checking visibility of account %s for account %s: %w", account.ID, requestingAccount.ID, err) +			return nil, gtserror.NewErrorInternalError(err) +		} + +		if !visible { +			log.Debugf(ctx, "account %s is not visible to account %s, skipping this result", account.ID, requestingAccount.ID) +			continue +		} + +		apiAccount, err := p.tc.AccountToAPIAccountPublic(ctx, account) +		if err != nil { +			log.Debugf(ctx, "skipping account %s because it couldn't be converted to its api representation: %s", account.ID, err) +			continue +		} + +		apiAccounts = append(apiAccounts, apiAccount) +	} + +	return apiAccounts, nil +} + +// packageStatuses is a util function that just +// converts the given statuses into an apimodel +// status slice, or errors appropriately. +func (p *Processor) packageStatuses( +	ctx context.Context, +	requestingAccount *gtsmodel.Account, +	statuses []*gtsmodel.Status, +) ([]*apimodel.Status, gtserror.WithCode) { +	apiStatuses := make([]*apimodel.Status, 0, len(statuses)) + +	for _, status := range statuses { +		// Ensure requester can see result status. +		visible, err := p.filter.StatusVisible(ctx, requestingAccount, status) +		if err != nil { +			err = gtserror.Newf("error checking visibility of status %s for account %s: %w", status.ID, requestingAccount.ID, err) +			return nil, gtserror.NewErrorInternalError(err) +		} + +		if !visible { +			log.Debugf(ctx, "status %s is not visible to account %s, skipping this result", status.ID, requestingAccount.ID) +			continue +		} + +		apiStatus, err := p.tc.StatusToAPIStatus(ctx, status, requestingAccount) +		if err != nil { +			log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err) +			continue +		} + +		apiStatuses = append(apiStatuses, apiStatus) +	} + +	return apiStatuses, nil +} + +// packageSearchResult wraps up the given accounts +// and statuses into an apimodel SearchResult that +// can be serialized to an API caller as JSON. +func (p *Processor) packageSearchResult( +	ctx context.Context, +	requestingAccount *gtsmodel.Account, +	accounts []*gtsmodel.Account, +	statuses []*gtsmodel.Status, +) (*apimodel.SearchResult, gtserror.WithCode) { +	apiAccounts, errWithCode := p.packageAccounts(ctx, requestingAccount, accounts) +	if errWithCode != nil { +		return nil, errWithCode +	} + +	apiStatuses, errWithCode := p.packageStatuses(ctx, requestingAccount, statuses) +	if errWithCode != nil { +		return nil, errWithCode +	} + +	return &apimodel.SearchResult{ +		Accounts: apiAccounts, +		Statuses: apiStatuses, +		Hashtags: make([]*apimodel.Tag, 0), +	}, nil +}  | 
