mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-12-22 18:22:11 +00:00
[feature] Add partial text search for accounts + statuses (#1836)
This commit is contained in:
parent
fab64a20b0
commit
831ae09f8b
|
@ -3111,6 +3111,38 @@ paths:
|
|||
summary: Delete your account.
|
||||
tags:
|
||||
- accounts
|
||||
/api/v1/accounts/lookup:
|
||||
get:
|
||||
operationId: accountLookupGet
|
||||
parameters:
|
||||
- description: The username or Webfinger address to lookup.
|
||||
in: query
|
||||
name: acct
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Result of the lookup.
|
||||
schema:
|
||||
$ref: '#/definitions/account'
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
"404":
|
||||
description: not found
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- read:accounts
|
||||
summary: Quickly lookup a username to see if it is available, skipping WebFinger resolution.
|
||||
tags:
|
||||
- accounts
|
||||
/api/v1/accounts/relationships:
|
||||
get:
|
||||
operationId: accountRelationships
|
||||
|
@ -3147,6 +3179,68 @@ paths:
|
|||
summary: See your account's relationships with the given account IDs.
|
||||
tags:
|
||||
- accounts
|
||||
/api/v1/accounts/search:
|
||||
get:
|
||||
operationId: accountSearchGet
|
||||
parameters:
|
||||
- default: 40
|
||||
description: Number of results to try to return.
|
||||
in: query
|
||||
maximum: 80
|
||||
minimum: 1
|
||||
name: limit
|
||||
type: integer
|
||||
- default: 0
|
||||
description: Page number of results to return (starts at 0). This parameter is currently not used, offsets over 0 will always return 0 results.
|
||||
in: query
|
||||
maximum: 10
|
||||
minimum: 0
|
||||
name: offset
|
||||
type: integer
|
||||
- description: |-
|
||||
Query string to search for. This can be in the following forms:
|
||||
- `@[username]` -- search for an account with the given username on any domain. Can return multiple results.
|
||||
- `@[username]@[domain]` -- search for a remote account with exact username and domain. Will only ever return 1 result at most.
|
||||
- any arbitrary string -- search for accounts containing the given string in their username or display name. Can return multiple results.
|
||||
in: query
|
||||
name: q
|
||||
required: true
|
||||
type: string
|
||||
- default: false
|
||||
description: If query is for `@[username]@[domain]`, or a URL, allow the GoToSocial instance to resolve the search by making calls to remote instances (webfinger, ActivityPub, etc).
|
||||
in: query
|
||||
name: resolve
|
||||
type: boolean
|
||||
- default: false
|
||||
description: Show only accounts that the requesting account follows. If this is set to `true`, then the GoToSocial instance will enhance the search by also searching within account notes, not just in usernames and display names.
|
||||
in: query
|
||||
name: following
|
||||
type: boolean
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Results of the search.
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/account'
|
||||
type: array
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
"404":
|
||||
description: not found
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- read:accounts
|
||||
summary: Search for accounts by username and/or display name.
|
||||
tags:
|
||||
- accounts
|
||||
/api/v1/accounts/update_credentials:
|
||||
patch:
|
||||
consumes:
|
||||
|
@ -5278,81 +5372,66 @@ paths:
|
|||
description: If statuses are in the result, they will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
|
||||
operationId: searchGet
|
||||
parameters:
|
||||
- description: If type is `statuses`, then statuses returned will be authored only by this account.
|
||||
in: query
|
||||
name: account_id
|
||||
type: string
|
||||
x-go-name: AccountID
|
||||
- description: |-
|
||||
Return results *older* than this id.
|
||||
|
||||
The entry with this ID will not be included in the search results.
|
||||
- description: Return only items *OLDER* than the given max ID. The item with the specified ID will not be included in the response. Currently only used if 'type' is set to a specific type.
|
||||
in: query
|
||||
name: max_id
|
||||
type: string
|
||||
x-go-name: MaxID
|
||||
- description: |-
|
||||
Return results *newer* than this id.
|
||||
|
||||
The entry with this ID will not be included in the search results.
|
||||
- description: Return only items *immediately newer* than the given min ID. The item with the specified ID will not be included in the response. Currently only used if 'type' is set to a specific type.
|
||||
in: query
|
||||
name: min_id
|
||||
type: string
|
||||
x-go-name: MinID
|
||||
- description: |-
|
||||
Type of the search query to perform.
|
||||
|
||||
Must be one of: `accounts`, `hashtags`, `statuses`.
|
||||
in: query
|
||||
name: type
|
||||
required: true
|
||||
type: string
|
||||
x-go-name: Type
|
||||
- default: false
|
||||
description: Filter out tags that haven't been reviewed and approved by an instance admin.
|
||||
in: query
|
||||
name: exclude_unreviewed
|
||||
type: boolean
|
||||
x-go-name: ExcludeUnreviewed
|
||||
- description: |-
|
||||
String to use as a search query.
|
||||
|
||||
For accounts, this should be in the format `@someaccount@some.instance.com`, or the format `https://some.instance.com/@someaccount`
|
||||
|
||||
For a status, this can be in the format: `https://some.instance.com/@someaccount/SOME_ID_OF_A_STATUS`
|
||||
in: query
|
||||
name: q
|
||||
required: true
|
||||
type: string
|
||||
x-go-name: Query
|
||||
- default: false
|
||||
description: Attempt to resolve the query by performing a remote webfinger lookup, if the query includes a remote host.
|
||||
in: query
|
||||
name: resolve
|
||||
type: boolean
|
||||
x-go-name: Resolve
|
||||
- default: 20
|
||||
description: Maximum number of results to load, per type.
|
||||
format: int64
|
||||
description: Number of each type of item to return.
|
||||
in: query
|
||||
maximum: 40
|
||||
minimum: 1
|
||||
name: limit
|
||||
type: integer
|
||||
x-go-name: Limit
|
||||
- default: 0
|
||||
description: Offset for paginating search results.
|
||||
format: int64
|
||||
description: Page number of results to return (starts at 0). This parameter is currently not used, page by selecting a specific query type and using maxID and minID instead.
|
||||
in: query
|
||||
maximum: 10
|
||||
minimum: 0
|
||||
name: offset
|
||||
type: integer
|
||||
x-go-name: Offset
|
||||
- description: |-
|
||||
Query string to search for. This can be in the following forms:
|
||||
- `@[username]` -- search for an account with the given username on any domain. Can return multiple results.
|
||||
- @[username]@[domain]` -- search for a remote account with exact username and domain. Will only ever return 1 result at most.
|
||||
- `https://example.org/some/arbitrary/url` -- search for an account OR a status with the given URL. Will only ever return 1 result at most.
|
||||
- any arbitrary string -- search for accounts or statuses containing the given string. Can return multiple results.
|
||||
in: query
|
||||
name: q
|
||||
required: true
|
||||
type: string
|
||||
- description: |-
|
||||
Type of item to return. One of:
|
||||
- `` -- empty string; return any/all results.
|
||||
- `accounts` -- return account(s).
|
||||
- `statuses` -- return status(es).
|
||||
- `hashtags` -- return hashtag(s).
|
||||
If `type` is specified, paging can be performed using max_id and min_id parameters.
|
||||
If `type` is not specified, see the `offset` parameter for paging.
|
||||
in: query
|
||||
name: type
|
||||
type: string
|
||||
- default: false
|
||||
description: Only include accounts that the searching account is following.
|
||||
description: If searching query is for `@[username]@[domain]`, or a URL, allow the GoToSocial instance to resolve the search by making calls to remote instances (webfinger, ActivityPub, etc).
|
||||
in: query
|
||||
name: resolve
|
||||
type: boolean
|
||||
- default: false
|
||||
description: If search type includes accounts, and search query is an arbitrary string, show only accounts that the requesting account follows. If this is set to `true`, then the GoToSocial instance will enhance the search by also searching within account notes, not just in usernames and display names.
|
||||
in: query
|
||||
name: following
|
||||
type: boolean
|
||||
x-go-name: Following
|
||||
- default: false
|
||||
description: If searching for hashtags, exclude those not yet approved by instance admin. Currently this parameter is unused.
|
||||
in: query
|
||||
name: exclude_unreviewed
|
||||
type: boolean
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Results of the search.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -25,53 +25,33 @@
|
|||
)
|
||||
|
||||
const (
|
||||
// LimitKey is for setting the return amount limit for eg., requesting an account's statuses
|
||||
LimitKey = "limit"
|
||||
// ExcludeRepliesKey is for specifying whether to exclude replies in a list of returned statuses by an account.
|
||||
ExcludeRepliesKey = "exclude_replies"
|
||||
// ExcludeReblogsKey is for specifying whether to exclude reblogs in a list of returned statuses by an account.
|
||||
ExcludeReblogsKey = "exclude_reblogs"
|
||||
// PinnedKey is for specifying whether to include pinned statuses in a list of returned statuses by an account.
|
||||
PinnedKey = "pinned"
|
||||
// MaxIDKey is for specifying the maximum ID of the status to retrieve.
|
||||
MaxIDKey = "max_id"
|
||||
// MinIDKey is for specifying the minimum ID of the status to retrieve.
|
||||
MinIDKey = "min_id"
|
||||
// OnlyMediaKey is for specifying that only statuses with media should be returned in a list of returned statuses by an account.
|
||||
OnlyMediaKey = "only_media"
|
||||
// OnlyPublicKey is for specifying that only statuses with visibility public should be returned in a list of returned statuses by account.
|
||||
OnlyPublicKey = "only_public"
|
||||
ExcludeRepliesKey = "exclude_replies"
|
||||
LimitKey = "limit"
|
||||
MaxIDKey = "max_id"
|
||||
MinIDKey = "min_id"
|
||||
OnlyMediaKey = "only_media"
|
||||
OnlyPublicKey = "only_public"
|
||||
PinnedKey = "pinned"
|
||||
|
||||
// IDKey is the key to use for retrieving account ID in requests
|
||||
IDKey = "id"
|
||||
// BasePath is the base API path for this module, excluding the 'api' prefix
|
||||
BasePath = "/v1/accounts"
|
||||
// BasePathWithID is the base path for this module with the ID key
|
||||
BasePath = "/v1/accounts"
|
||||
IDKey = "id"
|
||||
BasePathWithID = BasePath + "/:" + IDKey
|
||||
// VerifyPath is for verifying account credentials
|
||||
VerifyPath = BasePath + "/verify_credentials"
|
||||
// UpdateCredentialsPath is for updating account credentials
|
||||
UpdateCredentialsPath = BasePath + "/update_credentials"
|
||||
// GetStatusesPath is for showing an account's statuses
|
||||
GetStatusesPath = BasePathWithID + "/statuses"
|
||||
// GetFollowersPath is for showing an account's followers
|
||||
GetFollowersPath = BasePathWithID + "/followers"
|
||||
// GetFollowingPath is for showing account's that an account follows.
|
||||
GetFollowingPath = BasePathWithID + "/following"
|
||||
// GetRelationshipsPath is for showing an account's relationship with other accounts
|
||||
GetRelationshipsPath = BasePath + "/relationships"
|
||||
// FollowPath is for POSTing new follows to, and updating existing follows
|
||||
FollowPath = BasePathWithID + "/follow"
|
||||
// UnfollowPath is for POSTing an unfollow
|
||||
UnfollowPath = BasePathWithID + "/unfollow"
|
||||
// BlockPath is for creating a block of an account
|
||||
BlockPath = BasePathWithID + "/block"
|
||||
// UnblockPath is for removing a block of an account
|
||||
UnblockPath = BasePathWithID + "/unblock"
|
||||
// DeleteAccountPath is for deleting one's account via the API
|
||||
DeleteAccountPath = BasePath + "/delete"
|
||||
// ListsPath is for seeing which lists an account is.
|
||||
ListsPath = BasePathWithID + "/lists"
|
||||
|
||||
BlockPath = BasePathWithID + "/block"
|
||||
DeletePath = BasePath + "/delete"
|
||||
FollowersPath = BasePathWithID + "/followers"
|
||||
FollowingPath = BasePathWithID + "/following"
|
||||
FollowPath = BasePathWithID + "/follow"
|
||||
ListsPath = BasePathWithID + "/lists"
|
||||
LookupPath = BasePath + "/lookup"
|
||||
RelationshipsPath = BasePath + "/relationships"
|
||||
SearchPath = BasePath + "/search"
|
||||
StatusesPath = BasePathWithID + "/statuses"
|
||||
UnblockPath = BasePathWithID + "/unblock"
|
||||
UnfollowPath = BasePathWithID + "/unfollow"
|
||||
UpdatePath = BasePath + "/update_credentials"
|
||||
VerifyPath = BasePath + "/verify_credentials"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
|
@ -92,23 +72,23 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
|
|||
attachHandler(http.MethodGet, BasePathWithID, m.AccountGETHandler)
|
||||
|
||||
// delete account
|
||||
attachHandler(http.MethodPost, DeleteAccountPath, m.AccountDeletePOSTHandler)
|
||||
attachHandler(http.MethodPost, DeletePath, m.AccountDeletePOSTHandler)
|
||||
|
||||
// verify account
|
||||
attachHandler(http.MethodGet, VerifyPath, m.AccountVerifyGETHandler)
|
||||
|
||||
// modify account
|
||||
attachHandler(http.MethodPatch, UpdateCredentialsPath, m.AccountUpdateCredentialsPATCHHandler)
|
||||
attachHandler(http.MethodPatch, UpdatePath, m.AccountUpdateCredentialsPATCHHandler)
|
||||
|
||||
// get account's statuses
|
||||
attachHandler(http.MethodGet, GetStatusesPath, m.AccountStatusesGETHandler)
|
||||
attachHandler(http.MethodGet, StatusesPath, m.AccountStatusesGETHandler)
|
||||
|
||||
// get following or followers
|
||||
attachHandler(http.MethodGet, GetFollowersPath, m.AccountFollowersGETHandler)
|
||||
attachHandler(http.MethodGet, GetFollowingPath, m.AccountFollowingGETHandler)
|
||||
attachHandler(http.MethodGet, FollowersPath, m.AccountFollowersGETHandler)
|
||||
attachHandler(http.MethodGet, FollowingPath, m.AccountFollowingGETHandler)
|
||||
|
||||
// get relationship with account
|
||||
attachHandler(http.MethodGet, GetRelationshipsPath, m.AccountRelationshipsGETHandler)
|
||||
attachHandler(http.MethodGet, RelationshipsPath, m.AccountRelationshipsGETHandler)
|
||||
|
||||
// follow or unfollow account
|
||||
attachHandler(http.MethodPost, FollowPath, m.AccountFollowPOSTHandler)
|
||||
|
@ -120,4 +100,8 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
|
|||
|
||||
// account lists
|
||||
attachHandler(http.MethodGet, ListsPath, m.AccountListsGETHandler)
|
||||
|
||||
// search for accounts
|
||||
attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler)
|
||||
attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
93
internal/api/client/accounts/lookup.go
Normal file
93
internal/api/client/accounts/lookup.go
Normal file
|
@ -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)
|
||||
}
|
166
internal/api/client/accounts/search.go
Normal file
166
internal/api/client/accounts/search.go
Normal file
|
@ -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)
|
||||
}
|
430
internal/api/client/accounts/search_test.go
Normal file
430
internal/api/client/accounts/search_test.go
Normal file
|
@ -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))
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -25,39 +25,8 @@
|
|||
)
|
||||
|
||||
const (
|
||||
// BasePathV1 is the base path for serving v1 of the search API, minus the 'api' prefix
|
||||
BasePathV1 = "/v1/search"
|
||||
|
||||
// BasePathV2 is the base path for serving v2 of the search API, minus the 'api' prefix
|
||||
BasePathV2 = "/v2/search"
|
||||
|
||||
// AccountIDKey -- If provided, statuses returned will be authored only by this account
|
||||
AccountIDKey = "account_id"
|
||||
// MaxIDKey -- Return results older than this id
|
||||
MaxIDKey = "max_id"
|
||||
// MinIDKey -- Return results immediately newer than this id
|
||||
MinIDKey = "min_id"
|
||||
// TypeKey -- Enum(accounts, hashtags, statuses)
|
||||
TypeKey = "type"
|
||||
// ExcludeUnreviewedKey -- Filter out unreviewed tags? Defaults to false. Use true when trying to find trending tags.
|
||||
ExcludeUnreviewedKey = "exclude_unreviewed"
|
||||
// QueryKey -- The search query
|
||||
QueryKey = "q"
|
||||
// ResolveKey -- Attempt WebFinger lookup. Defaults to false.
|
||||
ResolveKey = "resolve"
|
||||
// LimitKey -- Maximum number of results to load, per type. Defaults to 20. Max 40.
|
||||
LimitKey = "limit"
|
||||
// OffsetKey -- Offset in search results. Used for pagination. Defaults to 0.
|
||||
OffsetKey = "offset"
|
||||
// FollowingKey -- Only include accounts that the user is following. Defaults to false.
|
||||
FollowingKey = "following"
|
||||
|
||||
// TypeAccounts --
|
||||
TypeAccounts = "accounts"
|
||||
// TypeHashtags --
|
||||
TypeHashtags = "hashtags"
|
||||
// TypeStatuses --
|
||||
TypeStatuses = "statuses"
|
||||
BasePathV1 = "/v1/search" // Base path for serving v1 of the search API, minus the 'api' prefix.
|
||||
BasePathV2 = "/v2/search" // Base path for serving v2 of the search API, minus the 'api' prefix.
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
|
|
|
@ -18,10 +18,7 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
|
@ -40,6 +37,98 @@
|
|||
// tags:
|
||||
// - search
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: max_id
|
||||
// type: string
|
||||
// description: >-
|
||||
// Return only items *OLDER* than the given max ID.
|
||||
// The item with the specified ID will not be included in the response.
|
||||
// Currently only used if 'type' is set to a specific type.
|
||||
// in: query
|
||||
// required: false
|
||||
// -
|
||||
// name: min_id
|
||||
// type: string
|
||||
// description: >-
|
||||
// Return only items *immediately newer* than the given min ID.
|
||||
// The item with the specified ID will not be included in the response.
|
||||
// Currently only used if 'type' is set to a specific type.
|
||||
// in: query
|
||||
// required: false
|
||||
// -
|
||||
// name: limit
|
||||
// type: integer
|
||||
// description: Number of each type of item to return.
|
||||
// default: 20
|
||||
// maximum: 40
|
||||
// minimum: 1
|
||||
// in: query
|
||||
// required: false
|
||||
// -
|
||||
// name: offset
|
||||
// type: integer
|
||||
// description: >-
|
||||
// Page number of results to return (starts at 0).
|
||||
// This parameter is currently not used, page by selecting
|
||||
// a specific query type and using maxID and minID instead.
|
||||
// default: 0
|
||||
// maximum: 10
|
||||
// minimum: 0
|
||||
// in: query
|
||||
// required: false
|
||||
// -
|
||||
// name: q
|
||||
// type: string
|
||||
// description: |-
|
||||
// Query string to search for. This can be in the following forms:
|
||||
// - `@[username]` -- search for an account with the given username on any domain. Can return multiple results.
|
||||
// - @[username]@[domain]` -- search for a remote account with exact username and domain. Will only ever return 1 result at most.
|
||||
// - `https://example.org/some/arbitrary/url` -- search for an account OR a status with the given URL. Will only ever return 1 result at most.
|
||||
// - any arbitrary string -- search for accounts or statuses containing the given string. Can return multiple results.
|
||||
// in: query
|
||||
// required: true
|
||||
// -
|
||||
// name: type
|
||||
// type: string
|
||||
// description: |-
|
||||
// Type of item to return. One of:
|
||||
// - `` -- empty string; return any/all results.
|
||||
// - `accounts` -- return account(s).
|
||||
// - `statuses` -- return status(es).
|
||||
// - `hashtags` -- return hashtag(s).
|
||||
// If `type` is specified, paging can be performed using max_id and min_id parameters.
|
||||
// If `type` is not specified, see the `offset` parameter for paging.
|
||||
// in: query
|
||||
// -
|
||||
// name: resolve
|
||||
// type: boolean
|
||||
// description: >-
|
||||
// If searching query is for `@[username]@[domain]`, or a URL, allow the GoToSocial
|
||||
// instance to resolve the search by making calls to remote instances (webfinger, ActivityPub, etc).
|
||||
// default: false
|
||||
// in: query
|
||||
// -
|
||||
// name: following
|
||||
// type: boolean
|
||||
// description: >-
|
||||
// If search type includes accounts, and search query is an arbitrary string, show only accounts
|
||||
// that the requesting account follows. If this is set to `true`, then the GoToSocial instance will
|
||||
// enhance the search by also searching within account notes, not just in usernames and display names.
|
||||
// default: false
|
||||
// in: query
|
||||
// -
|
||||
// name: exclude_unreviewed
|
||||
// type: boolean
|
||||
// description: >-
|
||||
// If searching for hashtags, exclude those not yet approved by instance admin.
|
||||
// Currently this parameter is unused.
|
||||
// default: false
|
||||
// in: query
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - read:search
|
||||
|
@ -74,93 +163,55 @@ func (m *Module) SearchGETHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
excludeUnreviewed := false
|
||||
excludeUnreviewedString := c.Query(ExcludeUnreviewedKey)
|
||||
if excludeUnreviewedString != "" {
|
||||
var err error
|
||||
excludeUnreviewed, err = strconv.ParseBool(excludeUnreviewedString)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error parsing %s: %s", ExcludeUnreviewedKey, err)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
query := c.Query(QueryKey)
|
||||
if query == "" {
|
||||
err := errors.New("query parameter q was empty")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
resolve := false
|
||||
resolveString := c.Query(ResolveKey)
|
||||
if resolveString != "" {
|
||||
var err error
|
||||
resolve, err = strconv.ParseBool(resolveString)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error parsing %s: %s", ResolveKey, err)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
offset, errWithCode := apiutil.ParseSearchOffset(c.Query(apiutil.SearchOffsetKey), 0, 10, 0)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
limit := 2
|
||||
limitString := c.Query(LimitKey)
|
||||
if limitString != "" {
|
||||
i, err := strconv.ParseInt(limitString, 10, 32)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error parsing %s: %s", LimitKey, err)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
limit = int(i)
|
||||
}
|
||||
if limit > 40 {
|
||||
limit = 40
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
query, errWithCode := apiutil.ParseSearchQuery(c.Query(apiutil.SearchQueryKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
offset := 0
|
||||
offsetString := c.Query(OffsetKey)
|
||||
if offsetString != "" {
|
||||
i, err := strconv.ParseInt(offsetString, 10, 32)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error parsing %s: %s", OffsetKey, err)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
offset = int(i)
|
||||
resolve, errWithCode := apiutil.ParseSearchResolve(c.Query(apiutil.SearchResolveKey), false)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
following := false
|
||||
followingString := c.Query(FollowingKey)
|
||||
if followingString != "" {
|
||||
var err error
|
||||
following, err = strconv.ParseBool(followingString)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error parsing %s: %s", FollowingKey, err)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
following, errWithCode := apiutil.ParseSearchFollowing(c.Query(apiutil.SearchFollowingKey), false)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
searchQuery := &apimodel.SearchQuery{
|
||||
AccountID: c.Query(AccountIDKey),
|
||||
MaxID: c.Query(MaxIDKey),
|
||||
MinID: c.Query(MinIDKey),
|
||||
Type: c.Query(TypeKey),
|
||||
ExcludeUnreviewed: excludeUnreviewed,
|
||||
Query: query,
|
||||
Resolve: resolve,
|
||||
excludeUnreviewed, errWithCode := apiutil.ParseSearchExcludeUnreviewed(c.Query(apiutil.SearchExcludeUnreviewedKey), false)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
searchRequest := &apimodel.SearchRequest{
|
||||
MaxID: c.Query(apiutil.MaxIDKey),
|
||||
MinID: c.Query(apiutil.MinIDKey),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Query: query,
|
||||
QueryType: c.Query(apiutil.SearchTypeKey),
|
||||
Resolve: resolve,
|
||||
Following: following,
|
||||
ExcludeUnreviewed: excludeUnreviewed,
|
||||
}
|
||||
|
||||
results, errWithCode := m.processor.SearchGet(c.Request.Context(), authed, searchQuery)
|
||||
results, errWithCode := m.processor.Search().Get(c.Request.Context(), authed.Account, searchRequest)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -25,34 +25,162 @@
|
|||
)
|
||||
|
||||
const (
|
||||
/* Common keys */
|
||||
|
||||
LimitKey = "limit"
|
||||
LocalKey = "local"
|
||||
MaxIDKey = "max_id"
|
||||
MinIDKey = "min_id"
|
||||
|
||||
/* Search keys */
|
||||
|
||||
SearchExcludeUnreviewedKey = "exclude_unreviewed"
|
||||
SearchFollowingKey = "following"
|
||||
SearchLookupKey = "acct"
|
||||
SearchOffsetKey = "offset"
|
||||
SearchQueryKey = "q"
|
||||
SearchResolveKey = "resolve"
|
||||
SearchTypeKey = "type"
|
||||
)
|
||||
|
||||
func ParseLimit(limit string, defaultLimit int) (int, gtserror.WithCode) {
|
||||
if limit == "" {
|
||||
return defaultLimit, nil
|
||||
// parseError returns gtserror.WithCode set to 400 Bad Request, to indicate
|
||||
// to the caller that a key was set to a value that could not be parsed.
|
||||
func parseError(key string, value, defaultValue any, err error) gtserror.WithCode {
|
||||
err = fmt.Errorf("error parsing key %s with value %s as %T: %w", key, value, defaultValue, err)
|
||||
return gtserror.NewErrorBadRequest(err, err.Error())
|
||||
}
|
||||
|
||||
func requiredError(key string) gtserror.WithCode {
|
||||
err := fmt.Errorf("required key %s was not set or had empty value", key)
|
||||
return gtserror.NewErrorBadRequest(err, err.Error())
|
||||
}
|
||||
|
||||
/*
|
||||
Parse functions for *OPTIONAL* parameters with default values.
|
||||
*/
|
||||
|
||||
func ParseLimit(value string, defaultValue int, max, min int) (int, gtserror.WithCode) {
|
||||
key := LimitKey
|
||||
|
||||
if value == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
i, err := strconv.Atoi(limit)
|
||||
i, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error parsing %s: %w", LimitKey, err)
|
||||
return 0, gtserror.NewErrorBadRequest(err, err.Error())
|
||||
return defaultValue, parseError(key, value, defaultValue, err)
|
||||
}
|
||||
|
||||
if i > max {
|
||||
i = max
|
||||
} else if i < min {
|
||||
i = min
|
||||
}
|
||||
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func ParseLocal(local string, defaultLocal bool) (bool, gtserror.WithCode) {
|
||||
if local == "" {
|
||||
return defaultLocal, nil
|
||||
func ParseLocal(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||
key := LimitKey
|
||||
|
||||
if value == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
i, err := strconv.ParseBool(local)
|
||||
i, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error parsing %s: %w", LocalKey, err)
|
||||
return false, gtserror.NewErrorBadRequest(err, err.Error())
|
||||
return defaultValue, parseError(key, value, defaultValue, err)
|
||||
}
|
||||
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func ParseSearchExcludeUnreviewed(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||
key := SearchExcludeUnreviewedKey
|
||||
|
||||
if value == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
i, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return defaultValue, parseError(key, value, defaultValue, err)
|
||||
}
|
||||
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func ParseSearchFollowing(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||
key := SearchFollowingKey
|
||||
|
||||
if value == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
i, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return defaultValue, parseError(key, value, defaultValue, err)
|
||||
}
|
||||
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func ParseSearchOffset(value string, defaultValue int, max, min int) (int, gtserror.WithCode) {
|
||||
key := SearchOffsetKey
|
||||
|
||||
if value == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
i, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return defaultValue, parseError(key, value, defaultValue, err)
|
||||
}
|
||||
|
||||
if i > max {
|
||||
i = max
|
||||
} else if i < min {
|
||||
i = min
|
||||
}
|
||||
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func ParseSearchResolve(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||
key := SearchResolveKey
|
||||
|
||||
if value == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
i, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return defaultValue, parseError(key, value, defaultValue, err)
|
||||
}
|
||||
|
||||
return i, nil
|
||||
}
|
||||
|
||||
/*
|
||||
Parse functions for *REQUIRED* parameters.
|
||||
*/
|
||||
|
||||
func ParseSearchLookup(value string) (string, gtserror.WithCode) {
|
||||
key := SearchLookupKey
|
||||
|
||||
if value == "" {
|
||||
return "", requiredError(key)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func ParseSearchQuery(value string) (string, gtserror.WithCode) {
|
||||
key := SearchQueryKey
|
||||
|
||||
if value == "" {
|
||||
return "", requiredError(key)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
422
internal/db/bundb/search.go
Normal file
422
internal/db/bundb/search.go
Normal file
|
@ -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
|
||||
}
|
82
internal/db/bundb/search_test.go
Normal file
82
internal/db/bundb/search_test.go
Normal file
|
@ -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))
|
||||
}
|
|
@ -42,6 +42,7 @@ type DB interface {
|
|||
Notification
|
||||
Relationship
|
||||
Report
|
||||
Search
|
||||
Session
|
||||
Status
|
||||
StatusBookmark
|
||||
|
|
32
internal/db/search.go
Normal file
32
internal/db/search.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -32,6 +32,7 @@
|
|||
"github.com/superseriousbusiness/gotosocial/internal/processing/list"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/report"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/search"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/stream"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/timeline"
|
||||
|
@ -60,6 +61,7 @@ type Processor struct {
|
|||
list list.Processor
|
||||
media media.Processor
|
||||
report report.Processor
|
||||
search search.Processor
|
||||
status status.Processor
|
||||
stream stream.Processor
|
||||
timeline timeline.Processor
|
||||
|
@ -90,6 +92,10 @@ func (p *Processor) Report() *report.Processor {
|
|||
return &p.report
|
||||
}
|
||||
|
||||
func (p *Processor) Search() *search.Processor {
|
||||
return &p.search
|
||||
}
|
||||
|
||||
func (p *Processor) Status() *status.Processor {
|
||||
return &p.status
|
||||
}
|
||||
|
@ -137,6 +143,7 @@ func NewProcessor(
|
|||
processor.media = media.New(state, tc, mediaManager, federator.TransportController())
|
||||
processor.report = report.New(state, tc)
|
||||
processor.timeline = timeline.New(state, tc, filter)
|
||||
processor.search = search.New(state, federator, tc, filter)
|
||||
processor.status = status.New(state, federator, tc, filter, parseMentionFunc)
|
||||
processor.stream = stream.New(state, oauthServer)
|
||||
processor.user = user.New(state, emailSender)
|
||||
|
|
|
@ -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
|
||||
}
|
110
internal/processing/search/accounts.go
Normal file
110
internal/processing/search/accounts.go
Normal file
|
@ -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)
|
||||
}
|
696
internal/processing/search/get.go
Normal file
696
internal/processing/search/get.go
Normal file
|
@ -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
|
||||
}
|
114
internal/processing/search/lookup.go
Normal file
114
internal/processing/search/lookup.go
Normal file
|
@ -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
|
||||
}
|
42
internal/processing/search/search.go
Normal file
42
internal/processing/search/search.go
Normal file
|
@ -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,
|
||||
}
|
||||
}
|
138
internal/processing/search/util.go
Normal file
138
internal/processing/search/util.go
Normal file
|
@ -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
|
||||
}
|
Loading…
Reference in a new issue