mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-22 11:46:40 +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.
|
summary: Delete your account.
|
||||||
tags:
|
tags:
|
||||||
- accounts
|
- 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:
|
/api/v1/accounts/relationships:
|
||||||
get:
|
get:
|
||||||
operationId: accountRelationships
|
operationId: accountRelationships
|
||||||
|
@ -3147,6 +3179,68 @@ paths:
|
||||||
summary: See your account's relationships with the given account IDs.
|
summary: See your account's relationships with the given account IDs.
|
||||||
tags:
|
tags:
|
||||||
- accounts
|
- 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:
|
/api/v1/accounts/update_credentials:
|
||||||
patch:
|
patch:
|
||||||
consumes:
|
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).
|
description: If statuses are in the result, they will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
|
||||||
operationId: searchGet
|
operationId: searchGet
|
||||||
parameters:
|
parameters:
|
||||||
- description: If type is `statuses`, then statuses returned will be authored only by this account.
|
- 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: 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.
|
|
||||||
in: query
|
in: query
|
||||||
name: max_id
|
name: max_id
|
||||||
type: string
|
type: string
|
||||||
x-go-name: MaxID
|
- 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.
|
||||||
- description: |-
|
|
||||||
Return results *newer* than this id.
|
|
||||||
|
|
||||||
The entry with this ID will not be included in the search results.
|
|
||||||
in: query
|
in: query
|
||||||
name: min_id
|
name: min_id
|
||||||
type: string
|
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
|
- default: 20
|
||||||
description: Maximum number of results to load, per type.
|
description: Number of each type of item to return.
|
||||||
format: int64
|
|
||||||
in: query
|
in: query
|
||||||
maximum: 40
|
maximum: 40
|
||||||
minimum: 1
|
minimum: 1
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
x-go-name: Limit
|
|
||||||
- default: 0
|
- default: 0
|
||||||
description: Offset for paginating search results.
|
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.
|
||||||
format: int64
|
|
||||||
in: query
|
in: query
|
||||||
|
maximum: 10
|
||||||
|
minimum: 0
|
||||||
name: offset
|
name: offset
|
||||||
type: integer
|
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
|
- 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
|
in: query
|
||||||
name: following
|
name: following
|
||||||
type: boolean
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Results of the search.
|
description: Results of the search.
|
||||||
|
|
|
@ -44,7 +44,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandler() {
|
||||||
}
|
}
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
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
|
// call the handler
|
||||||
suite.accountsModule.AccountDeletePOSTHandler(ctx)
|
suite.accountsModule.AccountDeletePOSTHandler(ctx)
|
||||||
|
@ -66,7 +66,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandlerWrongPassword()
|
||||||
}
|
}
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
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
|
// call the handler
|
||||||
suite.accountsModule.AccountDeletePOSTHandler(ctx)
|
suite.accountsModule.AccountDeletePOSTHandler(ctx)
|
||||||
|
@ -86,7 +86,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandlerNoPassword() {
|
||||||
}
|
}
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
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
|
// call the handler
|
||||||
suite.accountsModule.AccountDeletePOSTHandler(ctx)
|
suite.accountsModule.AccountDeletePOSTHandler(ctx)
|
||||||
|
|
|
@ -25,53 +25,33 @@
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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"
|
ExcludeReblogsKey = "exclude_reblogs"
|
||||||
// PinnedKey is for specifying whether to include pinned statuses in a list of returned statuses by an account.
|
ExcludeRepliesKey = "exclude_replies"
|
||||||
PinnedKey = "pinned"
|
LimitKey = "limit"
|
||||||
// MaxIDKey is for specifying the maximum ID of the status to retrieve.
|
MaxIDKey = "max_id"
|
||||||
MaxIDKey = "max_id"
|
MinIDKey = "min_id"
|
||||||
// MinIDKey is for specifying the minimum ID of the status to retrieve.
|
OnlyMediaKey = "only_media"
|
||||||
MinIDKey = "min_id"
|
OnlyPublicKey = "only_public"
|
||||||
// OnlyMediaKey is for specifying that only statuses with media should be returned in a list of returned statuses by an account.
|
PinnedKey = "pinned"
|
||||||
OnlyMediaKey = "only_media"
|
|
||||||
// OnlyPublicKey is for specifying that only statuses with visibility public should be returned in a list of returned statuses by account.
|
|
||||||
OnlyPublicKey = "only_public"
|
|
||||||
|
|
||||||
// IDKey is the key to use for retrieving account ID in requests
|
BasePath = "/v1/accounts"
|
||||||
IDKey = "id"
|
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
|
|
||||||
BasePathWithID = BasePath + "/:" + IDKey
|
BasePathWithID = BasePath + "/:" + IDKey
|
||||||
// VerifyPath is for verifying account credentials
|
|
||||||
VerifyPath = BasePath + "/verify_credentials"
|
BlockPath = BasePathWithID + "/block"
|
||||||
// UpdateCredentialsPath is for updating account credentials
|
DeletePath = BasePath + "/delete"
|
||||||
UpdateCredentialsPath = BasePath + "/update_credentials"
|
FollowersPath = BasePathWithID + "/followers"
|
||||||
// GetStatusesPath is for showing an account's statuses
|
FollowingPath = BasePathWithID + "/following"
|
||||||
GetStatusesPath = BasePathWithID + "/statuses"
|
FollowPath = BasePathWithID + "/follow"
|
||||||
// GetFollowersPath is for showing an account's followers
|
ListsPath = BasePathWithID + "/lists"
|
||||||
GetFollowersPath = BasePathWithID + "/followers"
|
LookupPath = BasePath + "/lookup"
|
||||||
// GetFollowingPath is for showing account's that an account follows.
|
RelationshipsPath = BasePath + "/relationships"
|
||||||
GetFollowingPath = BasePathWithID + "/following"
|
SearchPath = BasePath + "/search"
|
||||||
// GetRelationshipsPath is for showing an account's relationship with other accounts
|
StatusesPath = BasePathWithID + "/statuses"
|
||||||
GetRelationshipsPath = BasePath + "/relationships"
|
UnblockPath = BasePathWithID + "/unblock"
|
||||||
// FollowPath is for POSTing new follows to, and updating existing follows
|
UnfollowPath = BasePathWithID + "/unfollow"
|
||||||
FollowPath = BasePathWithID + "/follow"
|
UpdatePath = BasePath + "/update_credentials"
|
||||||
// UnfollowPath is for POSTing an unfollow
|
VerifyPath = BasePath + "/verify_credentials"
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Module struct {
|
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)
|
attachHandler(http.MethodGet, BasePathWithID, m.AccountGETHandler)
|
||||||
|
|
||||||
// delete account
|
// delete account
|
||||||
attachHandler(http.MethodPost, DeleteAccountPath, m.AccountDeletePOSTHandler)
|
attachHandler(http.MethodPost, DeletePath, m.AccountDeletePOSTHandler)
|
||||||
|
|
||||||
// verify account
|
// verify account
|
||||||
attachHandler(http.MethodGet, VerifyPath, m.AccountVerifyGETHandler)
|
attachHandler(http.MethodGet, VerifyPath, m.AccountVerifyGETHandler)
|
||||||
|
|
||||||
// modify account
|
// modify account
|
||||||
attachHandler(http.MethodPatch, UpdateCredentialsPath, m.AccountUpdateCredentialsPATCHHandler)
|
attachHandler(http.MethodPatch, UpdatePath, m.AccountUpdateCredentialsPATCHHandler)
|
||||||
|
|
||||||
// get account's statuses
|
// get account's statuses
|
||||||
attachHandler(http.MethodGet, GetStatusesPath, m.AccountStatusesGETHandler)
|
attachHandler(http.MethodGet, StatusesPath, m.AccountStatusesGETHandler)
|
||||||
|
|
||||||
// get following or followers
|
// get following or followers
|
||||||
attachHandler(http.MethodGet, GetFollowersPath, m.AccountFollowersGETHandler)
|
attachHandler(http.MethodGet, FollowersPath, m.AccountFollowersGETHandler)
|
||||||
attachHandler(http.MethodGet, GetFollowingPath, m.AccountFollowingGETHandler)
|
attachHandler(http.MethodGet, FollowingPath, m.AccountFollowingGETHandler)
|
||||||
|
|
||||||
// get relationship with account
|
// get relationship with account
|
||||||
attachHandler(http.MethodGet, GetRelationshipsPath, m.AccountRelationshipsGETHandler)
|
attachHandler(http.MethodGet, RelationshipsPath, m.AccountRelationshipsGETHandler)
|
||||||
|
|
||||||
// follow or unfollow account
|
// follow or unfollow account
|
||||||
attachHandler(http.MethodPost, FollowPath, m.AccountFollowPOSTHandler)
|
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
|
// account lists
|
||||||
attachHandler(http.MethodGet, ListsPath, m.AccountListsGETHandler)
|
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) {
|
) (*apimodel.Account, error) {
|
||||||
// Initialize http test context.
|
// Initialize http test context.
|
||||||
recorder := httptest.NewRecorder()
|
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.
|
// Trigger the handler.
|
||||||
suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20)
|
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
|
|
|
@ -25,39 +25,8 @@
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// BasePathV1 is the base path for serving v1 of the search API, minus the 'api' prefix
|
BasePathV1 = "/v1/search" // Base path for serving v1 of the search API, minus the 'api' prefix.
|
||||||
BasePathV1 = "/v1/search"
|
BasePathV2 = "/v2/search" // Base path for serving v2 of the search API, minus the 'api' prefix.
|
||||||
|
|
||||||
// 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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Module struct {
|
type Module struct {
|
||||||
|
|
|
@ -18,10 +18,7 @@
|
||||||
package search
|
package search
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
@ -40,6 +37,98 @@
|
||||||
// tags:
|
// tags:
|
||||||
// - search
|
// - 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:
|
// security:
|
||||||
// - OAuth2 Bearer:
|
// - OAuth2 Bearer:
|
||||||
// - read:search
|
// - read:search
|
||||||
|
@ -74,93 +163,55 @@ func (m *Module) SearchGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
excludeUnreviewed := false
|
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
|
||||||
excludeUnreviewedString := c.Query(ExcludeUnreviewedKey)
|
if errWithCode != nil {
|
||||||
if excludeUnreviewedString != "" {
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve := false
|
offset, errWithCode := apiutil.ParseSearchOffset(c.Query(apiutil.SearchOffsetKey), 0, 10, 0)
|
||||||
resolveString := c.Query(ResolveKey)
|
if errWithCode != nil {
|
||||||
if resolveString != "" {
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
var err error
|
return
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := 2
|
query, errWithCode := apiutil.ParseSearchQuery(c.Query(apiutil.SearchQueryKey))
|
||||||
limitString := c.Query(LimitKey)
|
if errWithCode != nil {
|
||||||
if limitString != "" {
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
i, err := strconv.ParseInt(limitString, 10, 32)
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
offset := 0
|
resolve, errWithCode := apiutil.ParseSearchResolve(c.Query(apiutil.SearchResolveKey), false)
|
||||||
offsetString := c.Query(OffsetKey)
|
if errWithCode != nil {
|
||||||
if offsetString != "" {
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
i, err := strconv.ParseInt(offsetString, 10, 32)
|
return
|
||||||
if err != nil {
|
|
||||||
err := fmt.Errorf("error parsing %s: %s", OffsetKey, err)
|
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
offset = int(i)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
following := false
|
following, errWithCode := apiutil.ParseSearchFollowing(c.Query(apiutil.SearchFollowingKey), false)
|
||||||
followingString := c.Query(FollowingKey)
|
if errWithCode != nil {
|
||||||
if followingString != "" {
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
var err error
|
return
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
searchQuery := &apimodel.SearchQuery{
|
excludeUnreviewed, errWithCode := apiutil.ParseSearchExcludeUnreviewed(c.Query(apiutil.SearchExcludeUnreviewedKey), false)
|
||||||
AccountID: c.Query(AccountIDKey),
|
if errWithCode != nil {
|
||||||
MaxID: c.Query(MaxIDKey),
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
MinID: c.Query(MinIDKey),
|
return
|
||||||
Type: c.Query(TypeKey),
|
}
|
||||||
ExcludeUnreviewed: excludeUnreviewed,
|
|
||||||
Query: query,
|
searchRequest := &apimodel.SearchRequest{
|
||||||
Resolve: resolve,
|
MaxID: c.Query(apiutil.MaxIDKey),
|
||||||
|
MinID: c.Query(apiutil.MinIDKey),
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
|
Query: query,
|
||||||
|
QueryType: c.Query(apiutil.SearchTypeKey),
|
||||||
|
Resolve: resolve,
|
||||||
Following: following,
|
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 {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -118,7 +118,7 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20)
|
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
|
|
|
@ -125,7 +125,7 @@ func (m *Module) ListTimelineGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20)
|
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
|
|
|
@ -129,7 +129,7 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20)
|
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
|
|
|
@ -17,74 +17,24 @@
|
||||||
|
|
||||||
package model
|
package model
|
||||||
|
|
||||||
// SearchQuery models a search request.
|
// SearchRequest models a search request.
|
||||||
//
|
type SearchRequest struct {
|
||||||
// swagger:parameters searchGet
|
MaxID string
|
||||||
type SearchQuery struct {
|
MinID string
|
||||||
// If type is `statuses`, then statuses returned will be authored only by this account.
|
Limit int
|
||||||
//
|
Offset int
|
||||||
// in: query
|
Query string
|
||||||
AccountID string `json:"account_id"`
|
QueryType string
|
||||||
// Return results *older* than this id.
|
Resolve bool
|
||||||
//
|
Following bool
|
||||||
// The entry with this ID will not be included in the search results.
|
ExcludeUnreviewed bool
|
||||||
// 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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchResult models a search result.
|
// SearchResult models a search result.
|
||||||
//
|
//
|
||||||
// swagger:model searchResult
|
// swagger:model searchResult
|
||||||
type SearchResult struct {
|
type SearchResult struct {
|
||||||
Accounts []Account `json:"accounts"`
|
Accounts []*Account `json:"accounts"`
|
||||||
Statuses []Status `json:"statuses"`
|
Statuses []*Status `json:"statuses"`
|
||||||
Hashtags []Tag `json:"hashtags"`
|
Hashtags []*Tag `json:"hashtags"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,34 +25,162 @@
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
/* Common keys */
|
||||||
|
|
||||||
LimitKey = "limit"
|
LimitKey = "limit"
|
||||||
LocalKey = "local"
|
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) {
|
// parseError returns gtserror.WithCode set to 400 Bad Request, to indicate
|
||||||
if limit == "" {
|
// to the caller that a key was set to a value that could not be parsed.
|
||||||
return defaultLimit, nil
|
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 {
|
if err != nil {
|
||||||
err := fmt.Errorf("error parsing %s: %w", LimitKey, err)
|
return defaultValue, parseError(key, value, defaultValue, err)
|
||||||
return 0, gtserror.NewErrorBadRequest(err, err.Error())
|
}
|
||||||
|
|
||||||
|
if i > max {
|
||||||
|
i = max
|
||||||
|
} else if i < min {
|
||||||
|
i = min
|
||||||
}
|
}
|
||||||
|
|
||||||
return i, nil
|
return i, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseLocal(local string, defaultLocal bool) (bool, gtserror.WithCode) {
|
func ParseLocal(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||||
if local == "" {
|
key := LimitKey
|
||||||
return defaultLocal, nil
|
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
i, err := strconv.ParseBool(local)
|
i, err := strconv.ParseBool(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err := fmt.Errorf("error parsing %s: %w", LocalKey, err)
|
return defaultValue, parseError(key, value, defaultValue, err)
|
||||||
return false, gtserror.NewErrorBadRequest(err, err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return i, nil
|
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.Notification
|
||||||
db.Relationship
|
db.Relationship
|
||||||
db.Report
|
db.Report
|
||||||
|
db.Search
|
||||||
db.Session
|
db.Session
|
||||||
db.Status
|
db.Status
|
||||||
db.StatusBookmark
|
db.StatusBookmark
|
||||||
|
@ -204,6 +205,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
|
||||||
conn: conn,
|
conn: conn,
|
||||||
state: state,
|
state: state,
|
||||||
},
|
},
|
||||||
|
Search: &searchDB{
|
||||||
|
conn: conn,
|
||||||
|
state: state,
|
||||||
|
},
|
||||||
Session: &sessionDB{
|
Session: &sessionDB{
|
||||||
conn: conn,
|
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
|
Notification
|
||||||
Relationship
|
Relationship
|
||||||
Report
|
Report
|
||||||
|
Search
|
||||||
Session
|
Session
|
||||||
Status
|
Status
|
||||||
StatusBookmark
|
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 ||
|
return a.Username == a.Domain ||
|
||||||
a.FollowersURI == "" ||
|
a.FollowersURI == "" ||
|
||||||
a.FollowingURI == "" ||
|
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.
|
// 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/list"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing/media"
|
"github.com/superseriousbusiness/gotosocial/internal/processing/media"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing/report"
|
"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/status"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing/stream"
|
"github.com/superseriousbusiness/gotosocial/internal/processing/stream"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing/timeline"
|
"github.com/superseriousbusiness/gotosocial/internal/processing/timeline"
|
||||||
|
@ -60,6 +61,7 @@ type Processor struct {
|
||||||
list list.Processor
|
list list.Processor
|
||||||
media media.Processor
|
media media.Processor
|
||||||
report report.Processor
|
report report.Processor
|
||||||
|
search search.Processor
|
||||||
status status.Processor
|
status status.Processor
|
||||||
stream stream.Processor
|
stream stream.Processor
|
||||||
timeline timeline.Processor
|
timeline timeline.Processor
|
||||||
|
@ -90,6 +92,10 @@ func (p *Processor) Report() *report.Processor {
|
||||||
return &p.report
|
return &p.report
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Processor) Search() *search.Processor {
|
||||||
|
return &p.search
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Processor) Status() *status.Processor {
|
func (p *Processor) Status() *status.Processor {
|
||||||
return &p.status
|
return &p.status
|
||||||
}
|
}
|
||||||
|
@ -137,6 +143,7 @@ func NewProcessor(
|
||||||
processor.media = media.New(state, tc, mediaManager, federator.TransportController())
|
processor.media = media.New(state, tc, mediaManager, federator.TransportController())
|
||||||
processor.report = report.New(state, tc)
|
processor.report = report.New(state, tc)
|
||||||
processor.timeline = timeline.New(state, tc, filter)
|
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.status = status.New(state, federator, tc, filter, parseMentionFunc)
|
||||||
processor.stream = stream.New(state, oauthServer)
|
processor.stream = stream.New(state, oauthServer)
|
||||||
processor.user = user.New(state, emailSender)
|
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