GoToSocial/internal/processing/search/get.go

925 lines
26 KiB
Go

// 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"
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/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/text"
"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
fromAccountID = req.AccountID
// Include instance accounts in the first
// parts of this search. This will be
// changed to 'false' when doing text
// search in the database in the latter
// parts of this function.
includeInstanceAccounts = true
// Assume caller doesn't want to see
// blocked accounts. This will change
// to 'true' if caller is searching
// for a specific account.
includeBlockedAccounts = false
)
// 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},
{"fromAccountID", fromAccountID},
}...).
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, nil, // No results.
req.APIv1,
includeInstanceAccounts,
includeBlockedAccounts,
)
}
var (
foundStatuses = make([]*gtsmodel.Status, 0, limit)
foundAccounts = make([]*gtsmodel.Account, 0, limit)
foundTags = make([]*gtsmodel.Tag, 0, limit)
appendStatus = func(s *gtsmodel.Status) { foundStatuses = append(foundStatuses, s) }
appendAccount = func(a *gtsmodel.Account) { foundAccounts = append(foundAccounts, a) }
appendTag = func(t *gtsmodel.Tag) { foundTags = append(foundTags, t) }
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.
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
}
}
// See if we have something that looks like a namestring.
username, domain, err := util.ExtractNamestringParts(queryC)
if err == nil {
// We managed to parse query as a namestring.
// If domain was set, this is a very specific
// search for a particular account, so show
// that account to the caller even if it's an
// instance account and/or even if they have
// it blocked. They might be looking for it
// to unblock it again!
domainSet := (domain != "")
includeInstanceAccounts = domainSet
includeBlockedAccounts = domainSet
err = p.accountsByUsernameDomain(
ctx,
account,
maxID,
minID,
limit,
offset,
username,
domain,
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)
}
// Namestrings are a pretty unique format, so
// it's very unlikely that the caller was
// searching for anything except an account.
// As such, return early without falling
// through to broader search.
return p.packageSearchResult(
ctx,
account,
foundAccounts,
foundStatuses,
foundTags,
req.APIv1,
includeInstanceAccounts,
includeBlockedAccounts,
)
}
}
// Check if we're searching by a known URI scheme.
// (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).
uri, err := url.Parse(query)
if err == nil && (uri.Scheme == "https" || uri.Scheme == "http") {
// URI is pretty specific so we can safely assume
// caller wants to include blocked accounts too.
includeBlockedAccounts = true
if err := p.byURI(
ctx,
account,
uri,
queryType,
resolve,
appendAccount,
appendStatus,
); err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("error searching by URI: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// This was a URI, so at this point just return
// whatever we have. You can't search hashtags by
// URI, and shouldn't do full-text with a URI either.
return p.packageSearchResult(
ctx,
account,
foundAccounts,
foundStatuses,
foundTags,
req.APIv1,
includeInstanceAccounts,
includeBlockedAccounts,
)
}
// If query looks like a hashtag (ie., starts
// with '#'), then search for tags.
//
// Since '#' is a very unique prefix and isn't
// shared among account or status searches, we
// can save a bit of time by searching for this
// now, and bailing quickly if we get no results,
// or we're not allowed to include hashtags in
// search results.
//
// We know that none of the subsequent searches
// would show any good results either, and those
// searches are *much* more expensive.
keepLooking, err := p.hashtag(
ctx,
maxID,
minID,
limit,
offset,
query,
queryType,
appendTag,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("error searching for hashtag: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if !keepLooking {
// Return whatever we have.
return p.packageSearchResult(
ctx,
account,
foundAccounts,
foundStatuses,
foundTags,
req.APIv1,
includeInstanceAccounts,
includeBlockedAccounts,
)
}
// As a last resort, search for accounts and
// statuses using the query as arbitrary text.
//
// At this point we no longer want to include
// instance accounts in the results, since searching
// for something like 'mastodon', for example, will
// include a million instance/service accounts that
// have 'mastodon' in the domain, and therefore in
// the username, making the search results useless.
includeInstanceAccounts = false
if err := p.byText(
ctx,
account,
maxID,
minID,
limit,
offset,
query,
queryType,
following,
fromAccountID,
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,
foundTags,
req.APIv1,
includeInstanceAccounts,
includeBlockedAccounts,
)
}
// accountsByUsernameDomain searches for accounts using
// the provided username and domain. If domain is not set,
// 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) accountsByUsernameDomain(
ctx context.Context,
requestingAccount *gtsmodel.Account,
maxID string,
minID string,
limit int,
offset int,
username string,
domain string,
resolve bool,
following bool,
appendAccount func(*gtsmodel.Account),
) error {
if domain == "" {
// No domain set. That means the query looked
// like '@someone' which is not an exact search,
// but is still a username search. Look for any
// usernames that start with the query string.
return p.accountsByText(
ctx,
requestingAccount.ID,
maxID,
minID,
limit,
offset,
// Add @ prefix back in to indicate
// to search function that we want
// an account by its username.
"@"+username,
following,
appendAccount,
)
}
// 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.
if !gtserror.IsUnretrievable(err) && !gtserror.IsWrongType(err) {
err = gtserror.Newf("error looking up @%s@%s as account: %w", username, domain, err)
return gtserror.NewErrorInternalError(err)
}
} else {
appendAccount(foundAccount)
}
return 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)
} else if blocked {
// Don't search on blocked domain.
err = gtserror.New("domain blocked")
return nil, gtserror.SetUnretrievable(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, gtserror.SetUnretrievable(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,
uri *url.URL,
queryType string,
resolve bool,
appendAccount func(*gtsmodel.Account),
appendStatus func(*gtsmodel.Status),
) error {
blocked, err := p.state.DB.IsURIBlocked(ctx, uri)
if err != nil {
err = gtserror.Newf("error checking domain block: %w", err)
return gtserror.NewErrorInternalError(err)
}
if blocked {
// Don't search for
// blocked domains.
return 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.
switch {
case gtserror.IsUnretrievable(err),
gtserror.IsWrongType(err):
log.Debugf(ctx,
"semi-expected error type looking up %s as account: %v",
uri, err,
)
default:
err = gtserror.Newf("error looking up %s as account: %w", uri, err)
return gtserror.NewErrorInternalError(err)
}
} else {
// Hit! Return early since it's extremely unlikely
// a status and an account will have the same URL.
appendAccount(foundAccount)
return 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.
switch {
case gtserror.IsUnretrievable(err),
gtserror.IsWrongType(err),
gtserror.NotPermitted(err):
log.Debugf(ctx,
"semi-expected error type looking up %s as status: %v",
uri, err,
)
default:
err = gtserror.Newf("error looking up %s as status: %w", uri, err)
return gtserror.NewErrorInternalError(err)
}
} else {
// Hit! Return early since it's extremely unlikely
// a status and an account will have the same URL.
appendStatus(foundStatus)
return nil
}
}
// No errors, but no hits
// either; that's fine.
return 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, gtserror.SetUnretrievable(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, gtserror.SetUnretrievable(err)
}
func (p *Processor) hashtag(
ctx context.Context,
maxID string,
minID string,
limit int,
offset int,
query string,
queryType string,
appendTag func(*gtsmodel.Tag),
) (bool, error) {
if query[0] != '#' {
// Query doesn't look like a hashtag,
// but if we're being instructed to
// look explicitly *only* for hashtags,
// let's be generous and assume caller
// just left out the hash prefix.
if queryType != queryTypeHashtags {
// Nope, search isn't explicitly
// for hashtags, keep looking.
return true, nil
}
// Search is explicitly for
// tags, let this one through.
} else if !includeHashtags(queryType) {
// Query looks like a hashtag,
// but we're not meant to include
// hashtags in the results.
//
// Indicate to caller they should
// stop looking, since they're not
// going to get results for this by
// looking in any other way.
return false, nil
}
// Query looks like a hashtag, and we're allowed
// to search for hashtags.
//
// Ensure this is a valid tag for our instance.
normalized, ok := text.NormalizeHashtag(query)
if !ok {
// Couldn't normalize/not a
// valid hashtag after all.
// Caller should stop looking.
return false, nil
}
// Search for tags starting with the normalized string.
tags, err := p.state.DB.SearchForTags(
ctx,
normalized,
maxID,
minID,
limit,
offset,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf(
"error checking database for tags using text %s: %w",
normalized, err,
)
return false, err
}
// Return whatever we got.
for _, tag := range tags {
appendTag(tag)
}
return false, nil
}
// 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,
fromAccountID string,
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,
fromAccountID,
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,
fromAccountID string,
appendStatus func(*gtsmodel.Status),
) error {
parsed, err := p.parseQuery(ctx, query)
if err != nil {
return err
}
query = parsed.query
// If the owning account for statuses was not provided as the account_id query parameter,
// it may still have been provided as a search operator in the query string.
if fromAccountID == "" {
fromAccountID = parsed.fromAccountID
}
statuses, err := p.state.DB.SearchForStatuses(
ctx,
requestingAccountID,
query,
fromAccountID,
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
}
// parsedQuery represents the results of parsing the search operator terms within a query.
type parsedQuery struct {
// query is the original search query text with operator terms removed.
query string
// fromAccountID is the account from a successfully resolved `from:` operator, if present.
fromAccountID string
}
// parseQuery parses query text and handles any search operator terms present.
func (p *Processor) parseQuery(ctx context.Context, query string) (parsed parsedQuery, err error) {
queryPartSeparator := " "
queryParts := strings.Split(query, queryPartSeparator)
nonOperatorQueryParts := make([]string, 0, len(queryParts))
for _, queryPart := range queryParts {
if arg, hasPrefix := strings.CutPrefix(queryPart, "from:"); hasPrefix {
parsed.fromAccountID, err = p.parseFromOperatorArg(ctx, arg)
if err != nil {
return
}
} else {
nonOperatorQueryParts = append(nonOperatorQueryParts, queryPart)
}
}
parsed.query = strings.Join(nonOperatorQueryParts, queryPartSeparator)
return
}
// parseFromOperatorArg attempts to parse the from: operator's argument as an account name,
// and returns the account ID if possible. Allows specifying an account name with or without a leading @.
func (p *Processor) parseFromOperatorArg(ctx context.Context, namestring string) (string, error) {
if namestring == "" {
return "", gtserror.New(
"the 'from:' search operator requires an account name, but it wasn't provided",
)
}
if namestring[0] != '@' {
namestring = "@" + namestring
}
username, domain, err := util.ExtractNamestringParts(namestring)
if err != nil {
return "", gtserror.Newf(
"the 'from:' search operator couldn't parse its argument as an account name: %w",
err,
)
}
account, err := p.state.DB.GetAccountByUsernameDomain(gtscontext.SetBarebones(ctx), username, domain)
if err != nil {
return "", gtserror.Newf(
"the 'from:' search operator couldn't find the requested account name: %w",
err,
)
}
return account.ID, nil
}