diff options
Diffstat (limited to 'internal/db/pg/pg.go')
-rw-r--r-- | internal/db/pg/pg.go | 1127 |
1 files changed, 1127 insertions, 0 deletions
diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go new file mode 100644 index 000000000..f8c2fdbe8 --- /dev/null +++ b/internal/db/pg/pg.go @@ -0,0 +1,1127 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + 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 pg + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "errors" + "fmt" + "net" + "net/mail" + "regexp" + "strings" + "time" + + "github.com/go-fed/activity/pub" + "github.com/go-pg/pg/extra/pgdebug" + "github.com/go-pg/pg/v10" + "github.com/go-pg/pg/v10/orm" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" + "golang.org/x/crypto/bcrypt" +) + +// postgresService satisfies the DB interface +type postgresService struct { + config *config.Config + conn *pg.DB + log *logrus.Logger + cancel context.CancelFunc + federationDB pub.Database +} + +// NewPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface. +// Under the hood, it uses https://github.com/go-pg/pg to create and maintain a database connection. +func NewPostgresService(ctx context.Context, c *config.Config, log *logrus.Logger) (db.DB, error) { + opts, err := derivePGOptions(c) + if err != nil { + return nil, fmt.Errorf("could not create postgres service: %s", err) + } + log.Debugf("using pg options: %+v", opts) + + // create a connection + pgCtx, cancel := context.WithCancel(ctx) + conn := pg.Connect(opts).WithContext(pgCtx) + + // this will break the logfmt format we normally log in, + // since we can't choose where pg outputs to and it defaults to + // stdout. So use this option with care! + if log.GetLevel() >= logrus.TraceLevel { + conn.AddQueryHook(pgdebug.DebugHook{ + // Print all queries. + Verbose: true, + }) + } + + // actually *begin* the connection so that we can tell if the db is there and listening + if err := conn.Ping(ctx); err != nil { + cancel() + return nil, fmt.Errorf("db connection error: %s", err) + } + + // print out discovered postgres version + var version string + if _, err = conn.QueryOneContext(ctx, pg.Scan(&version), "SELECT version()"); err != nil { + cancel() + return nil, fmt.Errorf("db connection error: %s", err) + } + log.Infof("connected to postgres version: %s", version) + + ps := &postgresService{ + config: c, + conn: conn, + log: log, + cancel: cancel, + } + + federatingDB := federation.NewFederatingDB(ps, c, log) + ps.federationDB = federatingDB + + // we can confidently return this useable postgres service now + return ps, nil +} + +/* + HANDY STUFF +*/ + +// derivePGOptions takes an application config and returns either a ready-to-use *pg.Options +// with sensible defaults, or an error if it's not satisfied by the provided config. +func derivePGOptions(c *config.Config) (*pg.Options, error) { + if strings.ToUpper(c.DBConfig.Type) != db.DBTypePostgres { + return nil, fmt.Errorf("expected db type of %s but got %s", db.DBTypePostgres, c.DBConfig.Type) + } + + // validate port + if c.DBConfig.Port == 0 { + return nil, errors.New("no port set") + } + + // validate address + if c.DBConfig.Address == "" { + return nil, errors.New("no address set") + } + + ipv4Regex := regexp.MustCompile(`^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$`) + hostnameRegex := regexp.MustCompile(`^(?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,}$`) + if !hostnameRegex.MatchString(c.DBConfig.Address) && !ipv4Regex.MatchString(c.DBConfig.Address) && c.DBConfig.Address != "localhost" { + return nil, fmt.Errorf("address %s was neither an ipv4 address nor a valid hostname", c.DBConfig.Address) + } + + // validate username + if c.DBConfig.User == "" { + return nil, errors.New("no user set") + } + + // validate that there's a password + if c.DBConfig.Password == "" { + return nil, errors.New("no password set") + } + + // validate database + if c.DBConfig.Database == "" { + return nil, errors.New("no database set") + } + + // We can rely on the pg library we're using to set + // sensible defaults for everything we don't set here. + options := &pg.Options{ + Addr: fmt.Sprintf("%s:%d", c.DBConfig.Address, c.DBConfig.Port), + User: c.DBConfig.User, + Password: c.DBConfig.Password, + Database: c.DBConfig.Database, + ApplicationName: c.ApplicationName, + } + + return options, nil +} + +/* + FEDERATION FUNCTIONALITY +*/ + +func (ps *postgresService) Federation() pub.Database { + return ps.federationDB +} + +/* + BASIC DB FUNCTIONALITY +*/ + +func (ps *postgresService) CreateTable(i interface{}) error { + return ps.conn.Model(i).CreateTable(&orm.CreateTableOptions{ + IfNotExists: true, + }) +} + +func (ps *postgresService) DropTable(i interface{}) error { + return ps.conn.Model(i).DropTable(&orm.DropTableOptions{ + IfExists: true, + }) +} + +func (ps *postgresService) Stop(ctx context.Context) error { + ps.log.Info("closing db connection") + if err := ps.conn.Close(); err != nil { + // only cancel if there's a problem closing the db + ps.cancel() + return err + } + return nil +} + +func (ps *postgresService) IsHealthy(ctx context.Context) error { + return ps.conn.Ping(ctx) +} + +func (ps *postgresService) CreateSchema(ctx context.Context) error { + models := []interface{}{ + (*gtsmodel.Account)(nil), + (*gtsmodel.Status)(nil), + (*gtsmodel.User)(nil), + } + ps.log.Info("creating db schema") + + for _, model := range models { + err := ps.conn.Model(model).CreateTable(&orm.CreateTableOptions{ + IfNotExists: true, + }) + if err != nil { + return err + } + } + + ps.log.Info("db schema created") + return nil +} + +func (ps *postgresService) GetByID(id string, i interface{}) error { + if err := ps.conn.Model(i).Where("id = ?", id).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + + } + return nil +} + +func (ps *postgresService) GetWhere(key string, value interface{}, i interface{}) error { + if err := ps.conn.Model(i).Where("? = ?", pg.Safe(key), value).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +// func (ps *postgresService) GetWhereMany(i interface{}, where ...model.Where) error { +// return nil +// } + +func (ps *postgresService) GetAll(i interface{}) error { + if err := ps.conn.Model(i).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) Put(i interface{}) error { + _, err := ps.conn.Model(i).Insert(i) + return err +} + +func (ps *postgresService) Upsert(i interface{}, conflictColumn string) error { + if _, err := ps.conn.Model(i).OnConflict(fmt.Sprintf("(%s) DO UPDATE", conflictColumn)).Insert(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) UpdateByID(id string, i interface{}) error { + if _, err := ps.conn.Model(i).Where("id = ?", id).OnConflict("(id) DO UPDATE").Insert(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) UpdateOneByID(id string, key string, value interface{}, i interface{}) error { + _, err := ps.conn.Model(i).Set("? = ?", pg.Safe(key), value).Where("id = ?", id).Update() + return err +} + +func (ps *postgresService) DeleteByID(id string, i interface{}) error { + if _, err := ps.conn.Model(i).Where("id = ?", id).Delete(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) DeleteWhere(key string, value interface{}, i interface{}) error { + if _, err := ps.conn.Model(i).Where("? = ?", pg.Safe(key), value).Delete(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +/* + HANDY SHORTCUTS +*/ + +func (ps *postgresService) AcceptFollowRequest(originAccountID string, targetAccountID string) error { + fr := >smodel.FollowRequest{} + if err := ps.conn.Model(fr).Where("account_id = ?", originAccountID).Where("target_account_id = ?", targetAccountID).Select(); err != nil { + if err == pg.ErrMultiRows { + return db.ErrNoEntries{} + } + return err + } + + follow := >smodel.Follow{ + AccountID: originAccountID, + TargetAccountID: targetAccountID, + URI: fr.URI, + } + + if _, err := ps.conn.Model(follow).Insert(); err != nil { + return err + } + + if _, err := ps.conn.Model(>smodel.FollowRequest{}).Where("account_id = ?", originAccountID).Where("target_account_id = ?", targetAccountID).Delete(); err != nil { + return err + } + + return nil +} + +func (ps *postgresService) CreateInstanceAccount() error { + username := ps.config.Host + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + ps.log.Errorf("error creating new rsa key: %s", err) + return err + } + + newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host) + a := >smodel.Account{ + Username: ps.config.Host, + DisplayName: username, + URL: newAccountURIs.UserURL, + PrivateKey: key, + PublicKey: &key.PublicKey, + PublicKeyURI: newAccountURIs.PublicKeyURI, + ActorType: gtsmodel.ActivityStreamsPerson, + URI: newAccountURIs.UserURI, + InboxURI: newAccountURIs.InboxURI, + OutboxURI: newAccountURIs.OutboxURI, + FollowersURI: newAccountURIs.FollowersURI, + FollowingURI: newAccountURIs.FollowingURI, + FeaturedCollectionURI: newAccountURIs.CollectionURI, + } + inserted, err := ps.conn.Model(a).Where("username = ?", username).SelectOrInsert() + if err != nil { + return err + } + if inserted { + ps.log.Infof("created instance account %s with id %s", username, a.ID) + } else { + ps.log.Infof("instance account %s already exists with id %s", username, a.ID) + } + return nil +} + +func (ps *postgresService) CreateInstanceInstance() error { + i := >smodel.Instance{ + Domain: ps.config.Host, + Title: ps.config.Host, + URI: fmt.Sprintf("%s://%s", ps.config.Protocol, ps.config.Host), + } + inserted, err := ps.conn.Model(i).Where("domain = ?", ps.config.Host).SelectOrInsert() + if err != nil { + return err + } + if inserted { + ps.log.Infof("created instance instance %s with id %s", ps.config.Host, i.ID) + } else { + ps.log.Infof("instance instance %s already exists with id %s", ps.config.Host, i.ID) + } + return nil +} + +func (ps *postgresService) GetAccountByUserID(userID string, account *gtsmodel.Account) error { + user := >smodel.User{ + ID: userID, + } + if err := ps.conn.Model(user).Where("id = ?", userID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + if err := ps.conn.Model(account).Where("id = ?", user.AccountID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetLocalAccountByUsername(username string, account *gtsmodel.Account) error { + if err := ps.conn.Model(account).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error { + if err := ps.conn.Model(followRequests).Where("target_account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return nil + } + return err + } + return nil +} + +func (ps *postgresService) GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error { + if err := ps.conn.Model(following).Where("account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return nil + } + return err + } + return nil +} + +func (ps *postgresService) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error { + if err := ps.conn.Model(followers).Where("target_account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return nil + } + return err + } + return nil +} + +func (ps *postgresService) GetFavesByAccountID(accountID string, faves *[]gtsmodel.StatusFave) error { + if err := ps.conn.Model(faves).Where("account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return nil + } + return err + } + return nil +} + +func (ps *postgresService) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error { + if err := ps.conn.Model(statuses).Where("account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error { + q := ps.conn.Model(statuses).Order("created_at DESC") + if limit != 0 { + q = q.Limit(limit) + } + if accountID != "" { + q = q.Where("account_id = ?", accountID) + } + if err := q.Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error { + if err := ps.conn.Model(status).Order("created_at DESC").Limit(1).Where("account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil + +} + +func (ps *postgresService) IsUsernameAvailable(username string) error { + // if no error we fail because it means we found something + // if error but it's not pg.ErrNoRows then we fail + // if err is pg.ErrNoRows we're good, we found nothing so continue + if err := ps.conn.Model(>smodel.Account{}).Where("username = ?", username).Where("domain = ?", nil).Select(); err == nil { + return fmt.Errorf("username %s already in use", username) + } else if err != pg.ErrNoRows { + return fmt.Errorf("db error: %s", err) + } + return nil +} + +func (ps *postgresService) IsEmailAvailable(email string) error { + // parse the domain from the email + m, err := mail.ParseAddress(email) + if err != nil { + return fmt.Errorf("error parsing email address %s: %s", email, err) + } + domain := strings.Split(m.Address, "@")[1] // domain will always be the second part after @ + + // check if the email domain is blocked + if err := ps.conn.Model(>smodel.EmailDomainBlock{}).Where("domain = ?", domain).Select(); err == nil { + // fail because we found something + return fmt.Errorf("email domain %s is blocked", domain) + } else if err != pg.ErrNoRows { + // fail because we got an unexpected error + return fmt.Errorf("db error: %s", err) + } + + // check if this email is associated with a user already + if err := ps.conn.Model(>smodel.User{}).Where("email = ?", email).WhereOr("unconfirmed_email = ?", email).Select(); err == nil { + // fail because we found something + return fmt.Errorf("email %s already in use", email) + } else if err != pg.ErrNoRows { + // fail because we got an unexpected error + return fmt.Errorf("db error: %s", err) + } + return nil +} + +func (ps *postgresService) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + ps.log.Errorf("error creating new rsa key: %s", err) + return nil, err + } + + newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host) + + a := >smodel.Account{ + Username: username, + DisplayName: username, + Reason: reason, + URL: newAccountURIs.UserURL, + PrivateKey: key, + PublicKey: &key.PublicKey, + PublicKeyURI: newAccountURIs.PublicKeyURI, + ActorType: gtsmodel.ActivityStreamsPerson, + URI: newAccountURIs.UserURI, + InboxURI: newAccountURIs.InboxURI, + OutboxURI: newAccountURIs.OutboxURI, + FollowersURI: newAccountURIs.FollowersURI, + FollowingURI: newAccountURIs.FollowingURI, + FeaturedCollectionURI: newAccountURIs.CollectionURI, + } + if _, err = ps.conn.Model(a).Insert(); err != nil { + return nil, err + } + + pw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("error hashing password: %s", err) + } + u := >smodel.User{ + AccountID: a.ID, + EncryptedPassword: string(pw), + SignUpIP: signUpIP, + Locale: locale, + UnconfirmedEmail: email, + CreatedByApplicationID: appID, + Approved: !requireApproval, // if we don't require moderator approval, just pre-approve the user + } + if _, err = ps.conn.Model(u).Insert(); err != nil { + return nil, err + } + + return u, nil +} + +func (ps *postgresService) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error { + if mediaAttachment.Avatar && mediaAttachment.Header { + return errors.New("one media attachment cannot be both header and avatar") + } + + var headerOrAVI string + if mediaAttachment.Avatar { + headerOrAVI = "avatar" + } else if mediaAttachment.Header { + headerOrAVI = "header" + } else { + return errors.New("given media attachment was neither a header nor an avatar") + } + + // TODO: there are probably more side effects here that need to be handled + if _, err := ps.conn.Model(mediaAttachment).OnConflict("(id) DO UPDATE").Insert(); err != nil { + return err + } + + if _, err := ps.conn.Model(>smodel.Account{}).Set(fmt.Sprintf("%s_media_attachment_id = ?", headerOrAVI), mediaAttachment.ID).Where("id = ?", accountID).Update(); err != nil { + return err + } + return nil +} + +func (ps *postgresService) GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error { + acct := >smodel.Account{} + if err := ps.conn.Model(acct).Where("id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + + if acct.HeaderMediaAttachmentID == "" { + return db.ErrNoEntries{} + } + + if err := ps.conn.Model(header).Where("id = ?", acct.HeaderMediaAttachmentID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error { + acct := >smodel.Account{} + if err := ps.conn.Model(acct).Where("id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + + if acct.AvatarMediaAttachmentID == "" { + return db.ErrNoEntries{} + } + + if err := ps.conn.Model(avatar).Where("id = ?", acct.AvatarMediaAttachmentID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) Blocked(account1 string, account2 string) (bool, error) { + // TODO: check domain blocks as well + var blocked bool + if err := ps.conn.Model(>smodel.Block{}). + Where("account_id = ?", account1).Where("target_account_id = ?", account2). + WhereOr("target_account_id = ?", account1).Where("account_id = ?", account2). + Select(); err != nil { + if err == pg.ErrNoRows { + blocked = false + return blocked, nil + } + return blocked, err + } + blocked = true + return blocked, nil +} + +func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, relevantAccounts *gtsmodel.RelevantAccounts) (bool, error) { + l := ps.log.WithField("func", "StatusVisible") + + // if target account is suspended then don't show the status + if !targetAccount.SuspendedAt.IsZero() { + l.Debug("target account suspended at is not zero") + return false, nil + } + + // if the target user doesn't exist (anymore) then the status also shouldn't be visible + targetUser := >smodel.User{} + if err := ps.conn.Model(targetUser).Where("account_id = ?", targetAccount.ID).Select(); err != nil { + l.Debug("target user could not be selected") + if err == pg.ErrNoRows { + return false, db.ErrNoEntries{} + } + return false, err + } + + // if target user is disabled, not yet approved, or not confirmed then don't show the status + // (although in the latter two cases it's unlikely they posted a status yet anyway, but you never know!) + if targetUser.Disabled || !targetUser.Approved || targetUser.ConfirmedAt.IsZero() { + l.Debug("target user is disabled, not approved, or not confirmed") + return false, nil + } + + // If requesting account is nil, that means whoever requested the status didn't auth, or their auth failed. + // In this case, we can still serve the status if it's public, otherwise we definitely shouldn't. + if requestingAccount == nil { + + if targetStatus.Visibility == gtsmodel.VisibilityPublic { + return true, nil + } + l.Debug("requesting account is nil but the target status isn't public") + return false, nil + } + + // if requesting account is suspended then don't show the status -- although they probably shouldn't have gotten + // this far (ie., been authed) in the first place: this is just for safety. + if !requestingAccount.SuspendedAt.IsZero() { + l.Debug("requesting account is suspended") + return false, nil + } + + // check if we have a local account -- if so we can check the user for that account in the DB + if requestingAccount.Domain == "" { + requestingUser := >smodel.User{} + if err := ps.conn.Model(requestingUser).Where("account_id = ?", requestingAccount.ID).Select(); err != nil { + // if the requesting account is local but doesn't have a corresponding user in the db this is a problem + if err == pg.ErrNoRows { + l.Debug("requesting account is local but there's no corresponding user") + return false, nil + } + l.Debugf("requesting account is local but there was an error getting the corresponding user: %s", err) + return false, err + } + // okay, user exists, so make sure it has full privileges/is confirmed/approved + if requestingUser.Disabled || !requestingUser.Approved || requestingUser.ConfirmedAt.IsZero() { + l.Debug("requesting account is local but corresponding user is either disabled, not approved, or not confirmed") + return false, nil + } + } + + // if the target status belongs to the requesting account, they should always be able to view it at this point + if targetStatus.AccountID == requestingAccount.ID { + return true, nil + } + + // At this point we have a populated targetAccount, targetStatus, and requestingAccount, so we can check for blocks and whathaveyou + // First check if a block exists directly between the target account (which authored the status) and the requesting account. + if blocked, err := ps.Blocked(targetAccount.ID, requestingAccount.ID); err != nil { + l.Debugf("something went wrong figuring out if the accounts have a block: %s", err) + return false, err + } else if blocked { + // don't allow the status to be viewed if a block exists in *either* direction between these two accounts, no creepy stalking please + l.Debug("a block exists between requesting account and target account") + return false, nil + } + + // check other accounts mentioned/boosted by/replied to by the status, if they exist + if relevantAccounts != nil { + // status replies to account id + if relevantAccounts.ReplyToAccount != nil { + if blocked, err := ps.Blocked(relevantAccounts.ReplyToAccount.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + + // status boosts accounts id + if relevantAccounts.BoostedAccount != nil { + if blocked, err := ps.Blocked(relevantAccounts.BoostedAccount.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + + // status boosts a reply to account id + if relevantAccounts.BoostedReplyToAccount != nil { + if blocked, err := ps.Blocked(relevantAccounts.BoostedReplyToAccount.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + + // status mentions accounts + for _, a := range relevantAccounts.MentionedAccounts { + if blocked, err := ps.Blocked(a.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + } + + // at this point we know neither account blocks the other, or another account mentioned or otherwise referred to in the status + // that means it's now just a matter of checking the visibility settings of the status itself + switch targetStatus.Visibility { + case gtsmodel.VisibilityPublic, gtsmodel.VisibilityUnlocked: + // no problem here, just return OK + return true, nil + case gtsmodel.VisibilityFollowersOnly: + // check one-way follow + follows, err := ps.Follows(requestingAccount, targetAccount) + if err != nil { + return false, err + } + if !follows { + return false, nil + } + return true, nil + case gtsmodel.VisibilityMutualsOnly: + // check mutual follow + mutuals, err := ps.Mutuals(requestingAccount, targetAccount) + if err != nil { + return false, err + } + if !mutuals { + return false, nil + } + return true, nil + case gtsmodel.VisibilityDirect: + // make sure the requesting account is mentioned in the status + for _, menchie := range targetStatus.Mentions { + if menchie == requestingAccount.ID { + return true, nil // yep it's mentioned! + } + } + return false, nil // it's not mentioned -_- + } + + return false, errors.New("reached the end of StatusVisible with no result") +} + +func (ps *postgresService) Follows(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) { + return ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", sourceAccount.ID).Where("target_account_id = ?", targetAccount.ID).Exists() +} + +func (ps *postgresService) Mutuals(account1 *gtsmodel.Account, account2 *gtsmodel.Account) (bool, error) { + // make sure account 1 follows account 2 + f1, err := ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", account1.ID).Where("target_account_id = ?", account2.ID).Exists() + if err != nil { + if err == pg.ErrNoRows { + return false, nil + } + return false, err + } + + // make sure account 2 follows account 1 + f2, err := ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", account2.ID).Where("target_account_id = ?", account1.ID).Exists() + if err != nil { + if err == pg.ErrNoRows { + return false, nil + } + return false, err + } + + return f1 && f2, nil +} + +func (ps *postgresService) PullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) (*gtsmodel.RelevantAccounts, error) { + accounts := >smodel.RelevantAccounts{ + MentionedAccounts: []*gtsmodel.Account{}, + } + + // get the replied to account from the status and add it to the pile + if targetStatus.InReplyToAccountID != "" { + repliedToAccount := >smodel.Account{} + if err := ps.conn.Model(repliedToAccount).Where("id = ?", targetStatus.InReplyToAccountID).Select(); err != nil { + return accounts, err + } + accounts.ReplyToAccount = repliedToAccount + } + + // get the boosted account from the status and add it to the pile + if targetStatus.BoostOfID != "" { + // retrieve the boosted status first + boostedStatus := >smodel.Status{} + if err := ps.conn.Model(boostedStatus).Where("id = ?", targetStatus.BoostOfID).Select(); err != nil { + return accounts, err + } + boostedAccount := >smodel.Account{} + if err := ps.conn.Model(boostedAccount).Where("id = ?", boostedStatus.AccountID).Select(); err != nil { + return accounts, err + } + accounts.BoostedAccount = boostedAccount + + // the boosted status might be a reply to another account so we should get that too + if boostedStatus.InReplyToAccountID != "" { + boostedStatusRepliedToAccount := >smodel.Account{} + if err := ps.conn.Model(boostedStatusRepliedToAccount).Where("id = ?", boostedStatus.InReplyToAccountID).Select(); err != nil { + return accounts, err + } + accounts.BoostedReplyToAccount = boostedStatusRepliedToAccount + } + } + + // now get all accounts with IDs that are mentioned in the status + for _, mentionedAccountID := range targetStatus.Mentions { + mentionedAccount := >smodel.Account{} + if err := ps.conn.Model(mentionedAccount).Where("id = ?", mentionedAccountID).Select(); err != nil { + return accounts, err + } + accounts.MentionedAccounts = append(accounts.MentionedAccounts, mentionedAccount) + } + + return accounts, nil +} + +func (ps *postgresService) GetReplyCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(>smodel.Status{}).Where("in_reply_to_id = ?", status.ID).Count() +} + +func (ps *postgresService) GetReblogCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Count() +} + +func (ps *postgresService) GetFaveCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Count() +} + +func (ps *postgresService) StatusFavedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusRebloggedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusMutedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.StatusMute{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusBookmarkedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.StatusBookmark{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusPinnedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.StatusPin{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { + // first check if a fave already exists, we can just return if so + existingFave := >smodel.StatusFave{} + err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() + if err == nil { + // fave already exists so just return nothing at all + return nil, nil + } + + // an error occurred so it might exist or not, we don't know + if err != pg.ErrNoRows { + return nil, err + } + + // it doesn't exist so create it + newFave := >smodel.StatusFave{ + AccountID: accountID, + TargetAccountID: status.AccountID, + StatusID: status.ID, + } + if _, err = ps.conn.Model(newFave).Insert(); err != nil { + return nil, err + } + + return newFave, nil +} + +func (ps *postgresService) UnfaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { + // if a fave doesn't exist, we don't need to do anything + existingFave := >smodel.StatusFave{} + err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() + // the fave doesn't exist so return nothing at all + if err == pg.ErrNoRows { + return nil, nil + } + + // an error occurred so it might exist or not, we don't know + if err != nil && err != pg.ErrNoRows { + return nil, err + } + + // the fave exists so remove it + if _, err = ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Delete(); err != nil { + return nil, err + } + + return existingFave, nil +} + +func (ps *postgresService) WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error) { + accounts := []*gtsmodel.Account{} + + faves := []*gtsmodel.StatusFave{} + if err := ps.conn.Model(&faves).Where("status_id = ?", status.ID).Select(); err != nil { + if err == pg.ErrNoRows { + return accounts, nil // no rows just means nobody has faved this status, so that's fine + } + return nil, err // an actual error has occurred + } + + for _, f := range faves { + acc := >smodel.Account{} + if err := ps.conn.Model(acc).Where("id = ?", f.AccountID).Select(); err != nil { + if err == pg.ErrNoRows { + continue // the account doesn't exist for some reason??? but this isn't the place to worry about that so just skip it + } + return nil, err // an actual error has occurred + } + accounts = append(accounts, acc) + } + return accounts, nil +} + +/* + CONVERSION FUNCTIONS +*/ + +func (ps *postgresService) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) { + menchies := []*gtsmodel.Mention{} + for _, a := range targetAccounts { + // A mentioned account looks like "@test@example.org" or just "@test" for a local account + // -- we can guarantee this from the regex that targetAccounts should have been derived from. + // But we still need to do a bit of fiddling to get what we need here -- the username and domain (if given). + + // 1. trim off the first @ + t := strings.TrimPrefix(a, "@") + + // 2. split the username and domain + s := strings.Split(t, "@") + + // 3. if it's length 1 it's a local account, length 2 means remote, anything else means something is wrong + var local bool + switch len(s) { + case 1: + local = true + case 2: + local = false + default: + return nil, fmt.Errorf("mentioned account format '%s' was not valid", a) + } + + var username, domain string + username = s[0] + if !local { + domain = s[1] + } + + // 4. check we now have a proper username and domain + if username == "" || (!local && domain == "") { + return nil, fmt.Errorf("username or domain for '%s' was nil", a) + } + + // okay we're good now, we can start pulling accounts out of the database + mentionedAccount := >smodel.Account{} + var err error + if local { + // local user -- should have a null domain + err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select() + } else { + // remote user -- should have domain defined + err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? = ?", pg.Ident("domain"), domain).Select() + } + + if err != nil { + if err == pg.ErrNoRows { + // no result found for this username/domain so just don't include it as a mencho and carry on about our business + ps.log.Debugf("no account found with username '%s' and domain '%s', skipping it", username, domain) + continue + } + // a serious error has happened so bail + return nil, fmt.Errorf("error getting account with username '%s' and domain '%s': %s", username, domain, err) + } + + // id, createdAt and updatedAt will be populated by the db, so we have everything we need! + menchies = append(menchies, >smodel.Mention{ + StatusID: statusID, + OriginAccountID: originAccountID, + TargetAccountID: mentionedAccount.ID, + }) + } + return menchies, nil +} + +func (ps *postgresService) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) { + newTags := []*gtsmodel.Tag{} + for _, t := range tags { + tag := >smodel.Tag{} + // we can use selectorinsert here to create the new tag if it doesn't exist already + // inserted will be true if this is a new tag we just created + if err := ps.conn.Model(tag).Where("name = ?", t).Select(); err != nil { + if err == pg.ErrNoRows { + // tag doesn't exist yet so populate it + tag.ID = uuid.NewString() + tag.Name = t + tag.FirstSeenFromAccountID = originAccountID + tag.CreatedAt = time.Now() + tag.UpdatedAt = time.Now() + tag.Useable = true + tag.Listable = true + } else { + return nil, fmt.Errorf("error getting tag with name %s: %s", t, err) + } + } + + // bail already if the tag isn't useable + if !tag.Useable { + continue + } + tag.LastStatusAt = time.Now() + newTags = append(newTags, tag) + } + return newTags, nil +} + +func (ps *postgresService) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) { + newEmojis := []*gtsmodel.Emoji{} + for _, e := range emojis { + emoji := >smodel.Emoji{} + err := ps.conn.Model(emoji).Where("shortcode = ?", e).Where("visible_in_picker = true").Where("disabled = false").Select() + if err != nil { + if err == pg.ErrNoRows { + // no result found for this username/domain so just don't include it as an emoji and carry on about our business + ps.log.Debugf("no emoji found with shortcode %s, skipping it", e) + continue + } + // a serious error has happened so bail + return nil, fmt.Errorf("error getting emoji with shortcode %s: %s", e, err) + } + newEmojis = append(newEmojis, emoji) + } + return newEmojis, nil +} |