[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
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

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