mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 17:42:24 -05:00 
			
		
		
		
	* Add from: search operator * Fix whitespace in Swagger YAML comment * Move query parsing into its own method * Document search * Clarify post search scope
		
			
				
	
	
		
			924 lines
		
	
	
	
		
			26 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			924 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
 | |
| }
 |