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