From 831ae09f8bab04af854243421047371339c3e190 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:26:40 +0200 Subject: [PATCH] [feature] Add partial text search for accounts + statuses (#1836) --- docs/api/swagger.yaml | 189 ++- .../api/client/accounts/accountdelete_test.go | 6 +- internal/api/client/accounts/accounts.go | 84 +- .../api/client/accounts/accountupdate_test.go | 2 +- internal/api/client/accounts/lookup.go | 93 ++ internal/api/client/accounts/search.go | 166 +++ internal/api/client/accounts/search_test.go | 430 +++++++ internal/api/client/lists/listaccounts.go | 2 +- internal/api/client/search/search.go | 35 +- internal/api/client/search/searchget.go | 199 ++-- internal/api/client/search/searchget_test.go | 1050 +++++++++++++++-- internal/api/client/timelines/home.go | 2 +- internal/api/client/timelines/list.go | 2 +- internal/api/client/timelines/public.go | 2 +- internal/api/model/search.go | 78 +- internal/api/util/parsequery.go | 152 ++- internal/db/bundb/bundb.go | 5 + .../20230620103932_search_updates.go | 64 + internal/db/bundb/search.go | 422 +++++++ internal/db/bundb/search_test.go | 82 ++ internal/db/db.go | 1 + internal/db/search.go | 32 + internal/gtsmodel/account.go | 3 +- internal/processing/processor.go | 7 + internal/processing/search.go | 295 ----- internal/processing/search/accounts.go | 110 ++ internal/processing/search/get.go | 696 +++++++++++ internal/processing/search/lookup.go | 114 ++ internal/processing/search/search.go | 42 + internal/processing/search/util.go | 138 +++ 30 files changed, 3834 insertions(+), 669 deletions(-) create mode 100644 internal/api/client/accounts/lookup.go create mode 100644 internal/api/client/accounts/search.go create mode 100644 internal/api/client/accounts/search_test.go create mode 100644 internal/db/bundb/migrations/20230620103932_search_updates.go create mode 100644 internal/db/bundb/search.go create mode 100644 internal/db/bundb/search_test.go create mode 100644 internal/db/search.go delete mode 100644 internal/processing/search.go create mode 100644 internal/processing/search/accounts.go create mode 100644 internal/processing/search/get.go create mode 100644 internal/processing/search/lookup.go create mode 100644 internal/processing/search/search.go create mode 100644 internal/processing/search/util.go diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index cbd68532f..a0ee30cab 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -3111,6 +3111,38 @@ paths: summary: Delete your account. tags: - accounts + /api/v1/accounts/lookup: + get: + operationId: accountLookupGet + parameters: + - description: The username or Webfinger address to lookup. + in: query + name: acct + required: true + type: string + produces: + - application/json + responses: + "200": + 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 + security: + - OAuth2 Bearer: + - read:accounts + summary: Quickly lookup a username to see if it is available, skipping WebFinger resolution. + tags: + - accounts /api/v1/accounts/relationships: get: operationId: accountRelationships @@ -3147,6 +3179,68 @@ paths: summary: See your account's relationships with the given account IDs. tags: - accounts + /api/v1/accounts/search: + get: + operationId: accountSearchGet + parameters: + - default: 40 + description: Number of results to try to return. + in: query + maximum: 80 + minimum: 1 + name: limit + type: integer + - default: 0 + description: Page number of results to return (starts at 0). This parameter is currently not used, offsets over 0 will always return 0 results. + in: query + maximum: 10 + minimum: 0 + name: offset + type: integer + - 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 + name: q + required: true + type: string + - default: false + 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). + in: query + name: resolve + type: boolean + - default: false + 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. + in: query + name: following + type: boolean + produces: + - application/json + responses: + "200": + description: Results of the search. + schema: + items: + $ref: '#/definitions/account' + type: array + "400": + description: bad request + "401": + description: unauthorized + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - read:accounts + summary: Search for accounts by username and/or display name. + tags: + - accounts /api/v1/accounts/update_credentials: patch: consumes: @@ -5278,81 +5372,66 @@ paths: description: If statuses are in the result, they will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). operationId: searchGet parameters: - - description: If type is `statuses`, then statuses returned will be authored only by this account. - in: query - name: account_id - type: string - x-go-name: AccountID - - description: |- - Return results *older* than this id. - - The entry with this ID will not be included in the search results. + - 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 name: max_id type: string - x-go-name: MaxID - - description: |- - Return results *newer* than this id. - - The entry with this ID will not be included in the search results. + - 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 name: min_id type: string - x-go-name: MinID - - description: |- - Type of the search query to perform. - - Must be one of: `accounts`, `hashtags`, `statuses`. - in: query - name: type - required: true - type: string - x-go-name: Type - - default: false - description: Filter out tags that haven't been reviewed and approved by an instance admin. - in: query - name: exclude_unreviewed - type: boolean - x-go-name: ExcludeUnreviewed - - description: |- - 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` - in: query - name: q - required: true - type: string - x-go-name: Query - - default: false - description: Attempt to resolve the query by performing a remote webfinger lookup, if the query includes a remote host. - in: query - name: resolve - type: boolean - x-go-name: Resolve - default: 20 - description: Maximum number of results to load, per type. - format: int64 + description: Number of each type of item to return. in: query maximum: 40 minimum: 1 name: limit type: integer - x-go-name: Limit - default: 0 - description: Offset for paginating search results. - format: int64 + 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. in: query + maximum: 10 + minimum: 0 name: offset type: integer - x-go-name: Offset + - 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 + name: q + required: true + 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: type + type: string - default: false - description: Only include accounts that the searching account is following. + 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). + in: query + name: resolve + type: boolean + - default: false + 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. in: query name: following type: boolean - x-go-name: Following + - default: false + description: If searching for hashtags, exclude those not yet approved by instance admin. Currently this parameter is unused. + in: query + name: exclude_unreviewed + type: boolean + produces: + - application/json responses: "200": description: Results of the search. 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 @@ ) 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" + ExcludeRepliesKey = "exclude_replies" + LimitKey = "limit" + MaxIDKey = "max_id" + MinIDKey = "min_id" + OnlyMediaKey = "only_media" + OnlyPublicKey = "only_public" + PinnedKey = "pinned" - // 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 + 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 . + +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 . + +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 . + +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 @@ ) 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 @@ // 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 - } - } - - 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) + limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1) + 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 - } + offset, errWithCode := apiutil.ParseSearchOffset(c.Query(apiutil.SearchOffsetKey), 0, 10, 0) + 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 + query, errWithCode := apiutil.ParseSearchQuery(c.Query(apiutil.SearchQueryKey)) + 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) + resolve, errWithCode := apiutil.ParseSearchResolve(c.Query(apiutil.SearchResolveKey), 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 - } + following, errWithCode := apiutil.ParseSearchFollowing(c.Query(apiutil.SearchFollowingKey), 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, + excludeUnreviewed, errWithCode := apiutil.ParseSearchExcludeUnreviewed(c.Query(apiutil.SearchExcludeUnreviewedKey), false) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + 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 + ) - ctx := suite.newContext(recorder, requestPath) + // 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)) + + 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() - if resultCode := recorder.Code; expectedHTTPStatus != resultCode { - return nil, fmt.Errorf("expected %d got %d", expectedHTTPStatus, resultCode) + b, err := io.ReadAll(result.Body) + if err != nil { + suite.FailNow(err.Error()) } - b, err := ioutil.ReadAll(result.Body) - if err != nil { - return nil, err + 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)) } 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 + 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.testSearch(query, resolve, http.StatusOK) + 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 + 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.testSearch(query, resolve, http.StatusOK) + 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 + 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.testSearch(query, resolve, http.StatusOK) + 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 + 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.testSearch(query, resolve, http.StatusOK) + 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 + 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.testSearch(query, resolve, http.StatusOK) + 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 + 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.testSearch(query, resolve, http.StatusOK) + 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 + 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.testSearch(query, resolve, http.StatusOK) + 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 + 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.testSearch(query, resolve, http.StatusOK) + 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 + 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.testSearch(query, resolve, http.StatusOK) + 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,27 +572,36 @@ func (suite *SearchGetTestSuite) TestSearchNonexistingLocalAccountByNamestringRe } func (suite *SearchGetTestSuite) TestSearchLocalAccountByURI() { - query := "http://localhost:8080/users/the_mighty_zork" - resolve := false + 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.testSearch(query, resolve, http.StatusOK) - if err != nil { - suite.FailNow(err.Error()) - } - - if !suite.Len(searchResult.Accounts, 1) { - suite.FailNow("expected 1 account in search results but got 0") - } - - gotAccount := searchResult.Accounts[0] - suite.NotNil(gotAccount) -} - -func (suite *SearchGetTestSuite) TestSearchLocalInstanceAccountByURI() { - query := "http://localhost:8080/users/localhost:8080" - resolve := false - - searchResult, err := suite.testSearch(query, resolve, http.StatusOK) + searchResult, err := suite.getSearch( + requestingAccount, + token, + user, + maxID, + minID, + limit, + offset, + query, + queryType, + resolve, + following, + expectedHTTPStatus, + expectedBody) if err != nil { suite.FailNow(err.Error()) } @@ -253,10 +615,36 @@ func (suite *SearchGetTestSuite) TestSearchLocalInstanceAccountByURI() { } func (suite *SearchGetTestSuite) TestSearchLocalAccountByURL() { - query := "http://localhost:8080/@the_mighty_zork" - resolve := false + 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.testSearch(query, resolve, http.StatusOK) + searchResult, err := suite.getSearch( + requestingAccount, + token, + user, + maxID, + minID, + limit, + offset, + query, + queryType, + resolve, + following, + expectedHTTPStatus, + expectedBody) if err != nil { suite.FailNow(err.Error()) } @@ -270,10 +658,36 @@ func (suite *SearchGetTestSuite) TestSearchLocalAccountByURL() { } func (suite *SearchGetTestSuite) TestSearchNonexistingLocalAccountByURL() { - query := "http://localhost:8080/@the_shmighty_shmork" - resolve := true + 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.testSearch(query, resolve, http.StatusOK) + searchResult, err := suite.getSearch( + requestingAccount, + token, + user, + maxID, + minID, + limit, + offset, + query, + queryType, + resolve, + following, + expectedHTTPStatus, + expectedBody) if err != nil { suite.FailNow(err.Error()) } @@ -282,10 +696,36 @@ func (suite *SearchGetTestSuite) TestSearchNonexistingLocalAccountByURL() { } func (suite *SearchGetTestSuite) TestSearchStatusByURL() { - query := "https://turnip.farm/users/turniplover6969/statuses/70c53e54-3146-42d5-a630-83c8b6c7c042" - resolve := true + 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.testSearch(query, resolve, http.StatusOK) + searchResult, err := suite.getSearch( + requestingAccount, + token, + user, + maxID, + minID, + limit, + offset, + query, + queryType, + resolve, + following, + expectedHTTPStatus, + expectedBody) if err != nil { suite.FailNow(err.Error()) } @@ -299,10 +739,36 @@ func (suite *SearchGetTestSuite) TestSearchStatusByURL() { } func (suite *SearchGetTestSuite) TestSearchBlockedDomainURL() { - query := "https://replyguys.com/@someone" - resolve := true + 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.testSearch(query, resolve, http.StatusOK) + searchResult, err := suite.getSearch( + requestingAccount, + token, + user, + maxID, + minID, + limit, + offset, + query, + queryType, + resolve, + following, + expectedHTTPStatus, + expectedBody) if err != nil { suite.FailNow(err.Error()) } @@ -313,10 +779,36 @@ func (suite *SearchGetTestSuite) TestSearchBlockedDomainURL() { } func (suite *SearchGetTestSuite) TestSearchBlockedDomainNamestring() { - query := "@someone@replyguys.com" - resolve := true + 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.testSearch(query, resolve, http.StatusOK) + 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 +818,410 @@ func (suite *SearchGetTestSuite) TestSearchBlockedDomainNamestring() { suite.Len(searchResult.Hashtags, 0) } +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) +} + +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()) + } + + 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()) + } + + suite.Len(searchResult.Accounts, 0) + suite.Len(searchResult.Statuses, 4) + suite.Len(searchResult.Hashtags, 0) +} + +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()) + } + + 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()) + } + + suite.Len(searchResult.Accounts, 0) + suite.Len(searchResult.Statuses, 0) + suite.Len(searchResult.Hashtags, 0) +} + +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()) + } + + 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()) + } + + suite.Len(searchResult.Accounts, 0) + suite.Len(searchResult.Statuses, 0) + 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 @@ ) 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 . + +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 . + +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 . + +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 . + +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 @@ "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 . - -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 . + +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 . + +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 . + +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 . + +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 . + +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 +}