[feature] Add partial text search for accounts + statuses (#1836)

This commit is contained in:
tobi 2023-06-21 18:26:40 +02:00 committed by GitHub
parent fab64a20b0
commit 831ae09f8b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 3834 additions and 669 deletions

View file

@ -3111,6 +3111,38 @@ paths:
summary: Delete your account.
tags:
- accounts
/api/v1/accounts/lookup:
get:
operationId: accountLookupGet
parameters:
- description: The username or Webfinger address to lookup.
in: query
name: acct
required: true
type: string
produces:
- application/json
responses:
"200":
description: Result of the lookup.
schema:
$ref: '#/definitions/account'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- read:accounts
summary: Quickly lookup a username to see if it is available, skipping WebFinger resolution.
tags:
- accounts
/api/v1/accounts/relationships:
get:
operationId: accountRelationships
@ -3147,6 +3179,68 @@ paths:
summary: See your account's relationships with the given account IDs.
tags:
- accounts
/api/v1/accounts/search:
get:
operationId: accountSearchGet
parameters:
- default: 40
description: Number of results to try to return.
in: query
maximum: 80
minimum: 1
name: limit
type: integer
- default: 0
description: Page number of results to return (starts at 0). This parameter is currently not used, offsets over 0 will always return 0 results.
in: query
maximum: 10
minimum: 0
name: offset
type: integer
- description: |-
Query string to search for. This can be in the following forms:
- `@[username]` -- search for an account with the given username on any domain. Can return multiple results.
- `@[username]@[domain]` -- search for a remote account with exact username and domain. Will only ever return 1 result at most.
- any arbitrary string -- search for accounts containing the given string in their username or display name. Can return multiple results.
in: query
name: q
required: true
type: string
- default: false
description: If query is for `@[username]@[domain]`, or a URL, allow the GoToSocial instance to resolve the search by making calls to remote instances (webfinger, ActivityPub, etc).
in: query
name: resolve
type: boolean
- default: false
description: Show only accounts that the requesting account follows. If this is set to `true`, then the GoToSocial instance will enhance the search by also searching within account notes, not just in usernames and display names.
in: query
name: following
type: boolean
produces:
- application/json
responses:
"200":
description: Results of the search.
schema:
items:
$ref: '#/definitions/account'
type: array
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- read:accounts
summary: Search for accounts by username and/or display name.
tags:
- accounts
/api/v1/accounts/update_credentials:
patch:
consumes:
@ -5278,81 +5372,66 @@ paths:
description: If statuses are in the result, they will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
operationId: searchGet
parameters:
- description: If type is `statuses`, then statuses returned will be authored only by this account.
in: query
name: account_id
type: string
x-go-name: AccountID
- description: |-
Return results *older* than this id.
The entry with this ID will not be included in the search results.
- description: Return only items *OLDER* than the given max ID. The item with the specified ID will not be included in the response. Currently only used if 'type' is set to a specific type.
in: query
name: max_id
type: string
x-go-name: MaxID
- description: |-
Return results *newer* than this id.
The entry with this ID will not be included in the search results.
- description: Return only items *immediately newer* than the given min ID. The item with the specified ID will not be included in the response. Currently only used if 'type' is set to a specific type.
in: query
name: min_id
type: string
x-go-name: MinID
- description: |-
Type of the search query to perform.
Must be one of: `accounts`, `hashtags`, `statuses`.
in: query
name: type
required: true
type: string
x-go-name: Type
- default: false
description: Filter out tags that haven't been reviewed and approved by an instance admin.
in: query
name: exclude_unreviewed
type: boolean
x-go-name: ExcludeUnreviewed
- description: |-
String to use as a search query.
For accounts, this should be in the format `@someaccount@some.instance.com`, or the format `https://some.instance.com/@someaccount`
For a status, this can be in the format: `https://some.instance.com/@someaccount/SOME_ID_OF_A_STATUS`
in: query
name: q
required: true
type: string
x-go-name: Query
- default: false
description: Attempt to resolve the query by performing a remote webfinger lookup, if the query includes a remote host.
in: query
name: resolve
type: boolean
x-go-name: Resolve
- default: 20
description: Maximum number of results to load, per type.
format: int64
description: Number of each type of item to return.
in: query
maximum: 40
minimum: 1
name: limit
type: integer
x-go-name: Limit
- default: 0
description: Offset for paginating search results.
format: int64
description: Page number of results to return (starts at 0). This parameter is currently not used, page by selecting a specific query type and using maxID and minID instead.
in: query
maximum: 10
minimum: 0
name: offset
type: integer
x-go-name: Offset
- description: |-
Query string to search for. This can be in the following forms:
- `@[username]` -- search for an account with the given username on any domain. Can return multiple results.
- @[username]@[domain]` -- search for a remote account with exact username and domain. Will only ever return 1 result at most.
- `https://example.org/some/arbitrary/url` -- search for an account OR a status with the given URL. Will only ever return 1 result at most.
- any arbitrary string -- search for accounts or statuses containing the given string. Can return multiple results.
in: query
name: q
required: true
type: string
- description: |-
Type of item to return. One of:
- `` -- empty string; return any/all results.
- `accounts` -- return account(s).
- `statuses` -- return status(es).
- `hashtags` -- return hashtag(s).
If `type` is specified, paging can be performed using max_id and min_id parameters.
If `type` is not specified, see the `offset` parameter for paging.
in: query
name: type
type: string
- default: false
description: Only include accounts that the searching account is following.
description: If searching query is for `@[username]@[domain]`, or a URL, allow the GoToSocial instance to resolve the search by making calls to remote instances (webfinger, ActivityPub, etc).
in: query
name: resolve
type: boolean
- default: false
description: If search type includes accounts, and search query is an arbitrary string, show only accounts that the requesting account follows. If this is set to `true`, then the GoToSocial instance will enhance the search by also searching within account notes, not just in usernames and display names.
in: query
name: following
type: boolean
x-go-name: Following
- default: false
description: If searching for hashtags, exclude those not yet approved by instance admin. Currently this parameter is unused.
in: query
name: exclude_unreviewed
type: boolean
produces:
- application/json
responses:
"200":
description: Results of the search.

View file

@ -44,7 +44,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandler() {
}
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeleteAccountPath, w.FormDataContentType())
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeletePath, w.FormDataContentType())
// call the handler
suite.accountsModule.AccountDeletePOSTHandler(ctx)
@ -66,7 +66,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandlerWrongPassword()
}
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeleteAccountPath, w.FormDataContentType())
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeletePath, w.FormDataContentType())
// call the handler
suite.accountsModule.AccountDeletePOSTHandler(ctx)
@ -86,7 +86,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandlerNoPassword() {
}
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeleteAccountPath, w.FormDataContentType())
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeletePath, w.FormDataContentType())
// call the handler
suite.accountsModule.AccountDeletePOSTHandler(ctx)

View file

@ -25,53 +25,33 @@ import (
)
const (
// LimitKey is for setting the return amount limit for eg., requesting an account's statuses
LimitKey = "limit"
// ExcludeRepliesKey is for specifying whether to exclude replies in a list of returned statuses by an account.
ExcludeRepliesKey = "exclude_replies"
// ExcludeReblogsKey is for specifying whether to exclude reblogs in a list of returned statuses by an account.
ExcludeReblogsKey = "exclude_reblogs"
// PinnedKey is for specifying whether to include pinned statuses in a list of returned statuses by an account.
PinnedKey = "pinned"
// MaxIDKey is for specifying the maximum ID of the status to retrieve.
MaxIDKey = "max_id"
// MinIDKey is for specifying the minimum ID of the status to retrieve.
MinIDKey = "min_id"
// OnlyMediaKey is for specifying that only statuses with media should be returned in a list of returned statuses by an account.
OnlyMediaKey = "only_media"
// OnlyPublicKey is for specifying that only statuses with visibility public should be returned in a list of returned statuses by account.
OnlyPublicKey = "only_public"
ExcludeRepliesKey = "exclude_replies"
LimitKey = "limit"
MaxIDKey = "max_id"
MinIDKey = "min_id"
OnlyMediaKey = "only_media"
OnlyPublicKey = "only_public"
PinnedKey = "pinned"
// IDKey is the key to use for retrieving account ID in requests
IDKey = "id"
// BasePath is the base API path for this module, excluding the 'api' prefix
BasePath = "/v1/accounts"
// BasePathWithID is the base path for this module with the ID key
BasePath = "/v1/accounts"
IDKey = "id"
BasePathWithID = BasePath + "/:" + IDKey
// VerifyPath is for verifying account credentials
VerifyPath = BasePath + "/verify_credentials"
// UpdateCredentialsPath is for updating account credentials
UpdateCredentialsPath = BasePath + "/update_credentials"
// GetStatusesPath is for showing an account's statuses
GetStatusesPath = BasePathWithID + "/statuses"
// GetFollowersPath is for showing an account's followers
GetFollowersPath = BasePathWithID + "/followers"
// GetFollowingPath is for showing account's that an account follows.
GetFollowingPath = BasePathWithID + "/following"
// GetRelationshipsPath is for showing an account's relationship with other accounts
GetRelationshipsPath = BasePath + "/relationships"
// FollowPath is for POSTing new follows to, and updating existing follows
FollowPath = BasePathWithID + "/follow"
// UnfollowPath is for POSTing an unfollow
UnfollowPath = BasePathWithID + "/unfollow"
// BlockPath is for creating a block of an account
BlockPath = BasePathWithID + "/block"
// UnblockPath is for removing a block of an account
UnblockPath = BasePathWithID + "/unblock"
// DeleteAccountPath is for deleting one's account via the API
DeleteAccountPath = BasePath + "/delete"
// ListsPath is for seeing which lists an account is.
ListsPath = BasePathWithID + "/lists"
BlockPath = BasePathWithID + "/block"
DeletePath = BasePath + "/delete"
FollowersPath = BasePathWithID + "/followers"
FollowingPath = BasePathWithID + "/following"
FollowPath = BasePathWithID + "/follow"
ListsPath = BasePathWithID + "/lists"
LookupPath = BasePath + "/lookup"
RelationshipsPath = BasePath + "/relationships"
SearchPath = BasePath + "/search"
StatusesPath = BasePathWithID + "/statuses"
UnblockPath = BasePathWithID + "/unblock"
UnfollowPath = BasePathWithID + "/unfollow"
UpdatePath = BasePath + "/update_credentials"
VerifyPath = BasePath + "/verify_credentials"
)
type Module struct {
@ -92,23 +72,23 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
attachHandler(http.MethodGet, BasePathWithID, m.AccountGETHandler)
// delete account
attachHandler(http.MethodPost, DeleteAccountPath, m.AccountDeletePOSTHandler)
attachHandler(http.MethodPost, DeletePath, m.AccountDeletePOSTHandler)
// verify account
attachHandler(http.MethodGet, VerifyPath, m.AccountVerifyGETHandler)
// modify account
attachHandler(http.MethodPatch, UpdateCredentialsPath, m.AccountUpdateCredentialsPATCHHandler)
attachHandler(http.MethodPatch, UpdatePath, m.AccountUpdateCredentialsPATCHHandler)
// get account's statuses
attachHandler(http.MethodGet, GetStatusesPath, m.AccountStatusesGETHandler)
attachHandler(http.MethodGet, StatusesPath, m.AccountStatusesGETHandler)
// get following or followers
attachHandler(http.MethodGet, GetFollowersPath, m.AccountFollowersGETHandler)
attachHandler(http.MethodGet, GetFollowingPath, m.AccountFollowingGETHandler)
attachHandler(http.MethodGet, FollowersPath, m.AccountFollowersGETHandler)
attachHandler(http.MethodGet, FollowingPath, m.AccountFollowingGETHandler)
// get relationship with account
attachHandler(http.MethodGet, GetRelationshipsPath, m.AccountRelationshipsGETHandler)
attachHandler(http.MethodGet, RelationshipsPath, m.AccountRelationshipsGETHandler)
// follow or unfollow account
attachHandler(http.MethodPost, FollowPath, m.AccountFollowPOSTHandler)
@ -120,4 +100,8 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
// account lists
attachHandler(http.MethodGet, ListsPath, m.AccountListsGETHandler)
// search for accounts
attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler)
attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler)
}

View file

@ -76,7 +76,7 @@ func (suite *AccountUpdateTestSuite) updateAccount(
) (*apimodel.Account, error) {
// Initialize http test context.
recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, accounts.UpdateCredentialsPath, contentType)
ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, accounts.UpdatePath, contentType)
// Trigger the handler.
suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx)

View 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)
}

View 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)
}

View 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))
}

View file

@ -129,7 +129,7 @@ func (m *Module) ListAccountsGETHandler(c *gin.Context) {
return
}
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20)
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -25,39 +25,8 @@ import (
)
const (
// BasePathV1 is the base path for serving v1 of the search API, minus the 'api' prefix
BasePathV1 = "/v1/search"
// BasePathV2 is the base path for serving v2 of the search API, minus the 'api' prefix
BasePathV2 = "/v2/search"
// AccountIDKey -- If provided, statuses returned will be authored only by this account
AccountIDKey = "account_id"
// MaxIDKey -- Return results older than this id
MaxIDKey = "max_id"
// MinIDKey -- Return results immediately newer than this id
MinIDKey = "min_id"
// TypeKey -- Enum(accounts, hashtags, statuses)
TypeKey = "type"
// ExcludeUnreviewedKey -- Filter out unreviewed tags? Defaults to false. Use true when trying to find trending tags.
ExcludeUnreviewedKey = "exclude_unreviewed"
// QueryKey -- The search query
QueryKey = "q"
// ResolveKey -- Attempt WebFinger lookup. Defaults to false.
ResolveKey = "resolve"
// LimitKey -- Maximum number of results to load, per type. Defaults to 20. Max 40.
LimitKey = "limit"
// OffsetKey -- Offset in search results. Used for pagination. Defaults to 0.
OffsetKey = "offset"
// FollowingKey -- Only include accounts that the user is following. Defaults to false.
FollowingKey = "following"
// TypeAccounts --
TypeAccounts = "accounts"
// TypeHashtags --
TypeHashtags = "hashtags"
// TypeStatuses --
TypeStatuses = "statuses"
BasePathV1 = "/v1/search" // Base path for serving v1 of the search API, minus the 'api' prefix.
BasePathV2 = "/v2/search" // Base path for serving v2 of the search API, minus the 'api' prefix.
)
type Module struct {

View file

@ -18,10 +18,7 @@
package search
import (
"errors"
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
@ -40,6 +37,98 @@ import (
// tags:
// - search
//
// produces:
// - application/json
//
// parameters:
// -
// name: max_id
// type: string
// description: >-
// Return only items *OLDER* than the given max ID.
// The item with the specified ID will not be included in the response.
// Currently only used if 'type' is set to a specific type.
// in: query
// required: false
// -
// name: min_id
// type: string
// description: >-
// Return only items *immediately newer* than the given min ID.
// The item with the specified ID will not be included in the response.
// Currently only used if 'type' is set to a specific type.
// in: query
// required: false
// -
// name: limit
// type: integer
// description: Number of each type of item to return.
// default: 20
// maximum: 40
// minimum: 1
// in: query
// required: false
// -
// name: offset
// type: integer
// description: >-
// Page number of results to return (starts at 0).
// This parameter is currently not used, page by selecting
// a specific query type and using maxID and minID instead.
// default: 0
// maximum: 10
// minimum: 0
// in: query
// required: false
// -
// name: q
// type: string
// description: |-
// Query string to search for. This can be in the following forms:
// - `@[username]` -- search for an account with the given username on any domain. Can return multiple results.
// - @[username]@[domain]` -- search for a remote account with exact username and domain. Will only ever return 1 result at most.
// - `https://example.org/some/arbitrary/url` -- search for an account OR a status with the given URL. Will only ever return 1 result at most.
// - any arbitrary string -- search for accounts or statuses containing the given string. Can return multiple results.
// in: query
// required: true
// -
// name: type
// type: string
// description: |-
// Type of item to return. One of:
// - `` -- empty string; return any/all results.
// - `accounts` -- return account(s).
// - `statuses` -- return status(es).
// - `hashtags` -- return hashtag(s).
// If `type` is specified, paging can be performed using max_id and min_id parameters.
// If `type` is not specified, see the `offset` parameter for paging.
// in: query
// -
// name: resolve
// type: boolean
// description: >-
// If searching query is for `@[username]@[domain]`, or a URL, allow the GoToSocial
// instance to resolve the search by making calls to remote instances (webfinger, ActivityPub, etc).
// default: false
// in: query
// -
// name: following
// type: boolean
// description: >-
// If search type includes accounts, and search query is an arbitrary string, show only accounts
// that the requesting account follows. If this is set to `true`, then the GoToSocial instance will
// enhance the search by also searching within account notes, not just in usernames and display names.
// default: false
// in: query
// -
// name: exclude_unreviewed
// type: boolean
// description: >-
// If searching for hashtags, exclude those not yet approved by instance admin.
// Currently this parameter is unused.
// default: false
// in: query
//
// security:
// - OAuth2 Bearer:
// - read:search
@ -74,93 +163,55 @@ func (m *Module) SearchGETHandler(c *gin.Context) {
return
}
excludeUnreviewed := false
excludeUnreviewedString := c.Query(ExcludeUnreviewedKey)
if excludeUnreviewedString != "" {
var err error
excludeUnreviewed, err = strconv.ParseBool(excludeUnreviewedString)
if err != nil {
err := fmt.Errorf("error parsing %s: %s", ExcludeUnreviewedKey, err)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
}
query := c.Query(QueryKey)
if query == "" {
err := errors.New("query parameter q was empty")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
resolve := false
resolveString := c.Query(ResolveKey)
if resolveString != "" {
var err error
resolve, err = strconv.ParseBool(resolveString)
if err != nil {
err := fmt.Errorf("error parsing %s: %s", ResolveKey, err)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
offset, errWithCode := apiutil.ParseSearchOffset(c.Query(apiutil.SearchOffsetKey), 0, 10, 0)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
limit := 2
limitString := c.Query(LimitKey)
if limitString != "" {
i, err := strconv.ParseInt(limitString, 10, 32)
if err != nil {
err := fmt.Errorf("error parsing %s: %s", LimitKey, err)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
limit = int(i)
}
if limit > 40 {
limit = 40
}
if limit < 1 {
limit = 1
query, errWithCode := apiutil.ParseSearchQuery(c.Query(apiutil.SearchQueryKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
offset := 0
offsetString := c.Query(OffsetKey)
if offsetString != "" {
i, err := strconv.ParseInt(offsetString, 10, 32)
if err != nil {
err := fmt.Errorf("error parsing %s: %s", OffsetKey, err)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
offset = int(i)
resolve, errWithCode := apiutil.ParseSearchResolve(c.Query(apiutil.SearchResolveKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
following := false
followingString := c.Query(FollowingKey)
if followingString != "" {
var err error
following, err = strconv.ParseBool(followingString)
if err != nil {
err := fmt.Errorf("error parsing %s: %s", FollowingKey, err)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
following, errWithCode := apiutil.ParseSearchFollowing(c.Query(apiutil.SearchFollowingKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
searchQuery := &apimodel.SearchQuery{
AccountID: c.Query(AccountIDKey),
MaxID: c.Query(MaxIDKey),
MinID: c.Query(MinIDKey),
Type: c.Query(TypeKey),
ExcludeUnreviewed: excludeUnreviewed,
Query: query,
Resolve: resolve,
excludeUnreviewed, errWithCode := apiutil.ParseSearchExcludeUnreviewed(c.Query(apiutil.SearchExcludeUnreviewedKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
searchRequest := &apimodel.SearchRequest{
MaxID: c.Query(apiutil.MaxIDKey),
MinID: c.Query(apiutil.MinIDKey),
Limit: limit,
Offset: offset,
Query: query,
QueryType: c.Query(apiutil.SearchTypeKey),
Resolve: resolve,
Following: following,
ExcludeUnreviewed: excludeUnreviewed,
}
results, errWithCode := m.processor.SearchGet(c.Request.Context(), authed, searchQuery)
results, errWithCode := m.processor.Search().Get(c.Request.Context(), authed.Account, searchRequest)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

File diff suppressed because it is too large Load diff

View file

@ -118,7 +118,7 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) {
return
}
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20)
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -125,7 +125,7 @@ func (m *Module) ListTimelineGETHandler(c *gin.Context) {
return
}
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20)
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -129,7 +129,7 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) {
return
}
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20)
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -17,74 +17,24 @@
package model
// SearchQuery models a search request.
//
// swagger:parameters searchGet
type SearchQuery struct {
// If type is `statuses`, then statuses returned will be authored only by this account.
//
// in: query
AccountID string `json:"account_id"`
// Return results *older* than this id.
//
// The entry with this ID will not be included in the search results.
// in: query
MaxID string `json:"max_id"`
// Return results *newer* than this id.
//
// The entry with this ID will not be included in the search results.
// in: query
MinID string `json:"min_id"`
// Type of the search query to perform.
//
// Must be one of: `accounts`, `hashtags`, `statuses`.
//
// enum:
// - accounts
// - hashtags
// - statuses
// required: true
// in: query
Type string `json:"type"`
// Filter out tags that haven't been reviewed and approved by an instance admin.
//
// default: false
// in: query
ExcludeUnreviewed bool `json:"exclude_unreviewed"`
// String to use as a search query.
//
// For accounts, this should be in the format `@someaccount@some.instance.com`, or the format `https://some.instance.com/@someaccount`
//
// For a status, this can be in the format: `https://some.instance.com/@someaccount/SOME_ID_OF_A_STATUS`
//
// required: true
// in: query
Query string `json:"q"`
// Attempt to resolve the query by performing a remote webfinger lookup, if the query includes a remote host.
// default: false
Resolve bool `json:"resolve"`
// Maximum number of results to load, per type.
// default: 20
// minimum: 1
// maximum: 40
// in: query
Limit int `json:"limit"`
// Offset for paginating search results.
//
// default: 0
// in: query
Offset int `json:"offset"`
// Only include accounts that the searching account is following.
// default: false
// in: query
Following bool `json:"following"`
// SearchRequest models a search request.
type SearchRequest struct {
MaxID string
MinID string
Limit int
Offset int
Query string
QueryType string
Resolve bool
Following bool
ExcludeUnreviewed bool
}
// SearchResult models a search result.
//
// swagger:model searchResult
type SearchResult struct {
Accounts []Account `json:"accounts"`
Statuses []Status `json:"statuses"`
Hashtags []Tag `json:"hashtags"`
Accounts []*Account `json:"accounts"`
Statuses []*Status `json:"statuses"`
Hashtags []*Tag `json:"hashtags"`
}

View file

@ -25,34 +25,162 @@ import (
)
const (
/* Common keys */
LimitKey = "limit"
LocalKey = "local"
MaxIDKey = "max_id"
MinIDKey = "min_id"
/* Search keys */
SearchExcludeUnreviewedKey = "exclude_unreviewed"
SearchFollowingKey = "following"
SearchLookupKey = "acct"
SearchOffsetKey = "offset"
SearchQueryKey = "q"
SearchResolveKey = "resolve"
SearchTypeKey = "type"
)
func ParseLimit(limit string, defaultLimit int) (int, gtserror.WithCode) {
if limit == "" {
return defaultLimit, nil
// parseError returns gtserror.WithCode set to 400 Bad Request, to indicate
// to the caller that a key was set to a value that could not be parsed.
func parseError(key string, value, defaultValue any, err error) gtserror.WithCode {
err = fmt.Errorf("error parsing key %s with value %s as %T: %w", key, value, defaultValue, err)
return gtserror.NewErrorBadRequest(err, err.Error())
}
func requiredError(key string) gtserror.WithCode {
err := fmt.Errorf("required key %s was not set or had empty value", key)
return gtserror.NewErrorBadRequest(err, err.Error())
}
/*
Parse functions for *OPTIONAL* parameters with default values.
*/
func ParseLimit(value string, defaultValue int, max, min int) (int, gtserror.WithCode) {
key := LimitKey
if value == "" {
return defaultValue, nil
}
i, err := strconv.Atoi(limit)
i, err := strconv.Atoi(value)
if err != nil {
err := fmt.Errorf("error parsing %s: %w", LimitKey, err)
return 0, gtserror.NewErrorBadRequest(err, err.Error())
return defaultValue, parseError(key, value, defaultValue, err)
}
if i > max {
i = max
} else if i < min {
i = min
}
return i, nil
}
func ParseLocal(local string, defaultLocal bool) (bool, gtserror.WithCode) {
if local == "" {
return defaultLocal, nil
func ParseLocal(value string, defaultValue bool) (bool, gtserror.WithCode) {
key := LimitKey
if value == "" {
return defaultValue, nil
}
i, err := strconv.ParseBool(local)
i, err := strconv.ParseBool(value)
if err != nil {
err := fmt.Errorf("error parsing %s: %w", LocalKey, err)
return false, gtserror.NewErrorBadRequest(err, err.Error())
return defaultValue, parseError(key, value, defaultValue, err)
}
return i, nil
}
func ParseSearchExcludeUnreviewed(value string, defaultValue bool) (bool, gtserror.WithCode) {
key := SearchExcludeUnreviewedKey
if value == "" {
return defaultValue, nil
}
i, err := strconv.ParseBool(value)
if err != nil {
return defaultValue, parseError(key, value, defaultValue, err)
}
return i, nil
}
func ParseSearchFollowing(value string, defaultValue bool) (bool, gtserror.WithCode) {
key := SearchFollowingKey
if value == "" {
return defaultValue, nil
}
i, err := strconv.ParseBool(value)
if err != nil {
return defaultValue, parseError(key, value, defaultValue, err)
}
return i, nil
}
func ParseSearchOffset(value string, defaultValue int, max, min int) (int, gtserror.WithCode) {
key := SearchOffsetKey
if value == "" {
return defaultValue, nil
}
i, err := strconv.Atoi(value)
if err != nil {
return defaultValue, parseError(key, value, defaultValue, err)
}
if i > max {
i = max
} else if i < min {
i = min
}
return i, nil
}
func ParseSearchResolve(value string, defaultValue bool) (bool, gtserror.WithCode) {
key := SearchResolveKey
if value == "" {
return defaultValue, nil
}
i, err := strconv.ParseBool(value)
if err != nil {
return defaultValue, parseError(key, value, defaultValue, err)
}
return i, nil
}
/*
Parse functions for *REQUIRED* parameters.
*/
func ParseSearchLookup(value string) (string, gtserror.WithCode) {
key := SearchLookupKey
if value == "" {
return "", requiredError(key)
}
return value, nil
}
func ParseSearchQuery(value string) (string, gtserror.WithCode) {
key := SearchQueryKey
if value == "" {
return "", requiredError(key)
}
return value, nil
}

View file

@ -71,6 +71,7 @@ type DBService struct {
db.Notification
db.Relationship
db.Report
db.Search
db.Session
db.Status
db.StatusBookmark
@ -204,6 +205,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
conn: conn,
state: state,
},
Search: &searchDB{
conn: conn,
state: state,
},
Session: &sessionDB{
conn: conn,
},

View file

@ -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
View 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
}

View 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))
}

View file

@ -42,6 +42,7 @@ type DB interface {
Notification
Relationship
Report
Search
Session
Status
StatusBookmark

32
internal/db/search.go Normal file
View 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)
}

View file

@ -104,7 +104,8 @@ func (a *Account) IsInstance() bool {
return a.Username == a.Domain ||
a.FollowersURI == "" ||
a.FollowingURI == "" ||
(a.Username == "internal.fetch" && strings.Contains(a.Note, "internal service actor"))
(a.Username == "internal.fetch" && strings.Contains(a.Note, "internal service actor")) ||
a.Username == "instance.actor" // <- misskey
}
// EmojisPopulated returns whether emojis are populated according to current EmojiIDs.

View file

@ -32,6 +32,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/processing/list"
"github.com/superseriousbusiness/gotosocial/internal/processing/media"
"github.com/superseriousbusiness/gotosocial/internal/processing/report"
"github.com/superseriousbusiness/gotosocial/internal/processing/search"
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
"github.com/superseriousbusiness/gotosocial/internal/processing/stream"
"github.com/superseriousbusiness/gotosocial/internal/processing/timeline"
@ -60,6 +61,7 @@ type Processor struct {
list list.Processor
media media.Processor
report report.Processor
search search.Processor
status status.Processor
stream stream.Processor
timeline timeline.Processor
@ -90,6 +92,10 @@ func (p *Processor) Report() *report.Processor {
return &p.report
}
func (p *Processor) Search() *search.Processor {
return &p.search
}
func (p *Processor) Status() *status.Processor {
return &p.status
}
@ -137,6 +143,7 @@ func NewProcessor(
processor.media = media.New(state, tc, mediaManager, federator.TransportController())
processor.report = report.New(state, tc)
processor.timeline = timeline.New(state, tc, filter)
processor.search = search.New(state, federator, tc, filter)
processor.status = status.New(state, federator, tc, filter, parseMentionFunc)
processor.stream = stream.New(state, oauthServer)
processor.user = user.New(state, emailSender)

View file

@ -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
}

View 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)
}

View 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
}

View 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
}

View 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,
}
}

View 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
}