diff options
Diffstat (limited to 'internal/api')
-rw-r--r-- | internal/api/client/accounts/accountdelete_test.go | 6 | ||||
-rw-r--r-- | internal/api/client/accounts/accounts.go | 86 | ||||
-rw-r--r-- | internal/api/client/accounts/accountupdate_test.go | 2 | ||||
-rw-r--r-- | internal/api/client/accounts/lookup.go | 93 | ||||
-rw-r--r-- | internal/api/client/accounts/search.go | 166 | ||||
-rw-r--r-- | internal/api/client/accounts/search_test.go | 430 | ||||
-rw-r--r-- | internal/api/client/lists/listaccounts.go | 2 | ||||
-rw-r--r-- | internal/api/client/search/search.go | 35 | ||||
-rw-r--r-- | internal/api/client/search/searchget.go | 195 | ||||
-rw-r--r-- | internal/api/client/search/searchget_test.go | 1070 | ||||
-rw-r--r-- | internal/api/client/timelines/home.go | 2 | ||||
-rw-r--r-- | internal/api/client/timelines/list.go | 2 | ||||
-rw-r--r-- | internal/api/client/timelines/public.go | 2 | ||||
-rw-r--r-- | internal/api/model/search.go | 78 | ||||
-rw-r--r-- | internal/api/util/parsequery.go | 152 |
15 files changed, 1994 insertions, 327 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 +} |