From c4a08292ee44bc731ff90bad18a3f37e5ee8ef22 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Mon, 26 Sep 2022 11:56:01 +0200 Subject: [PATCH] [feature] Show + federate emojis in accounts (#837) * Start adding account emoji * get emojis serialized + deserialized nicely * update tests * set / retrieve emojis on accounts * show account emojis in web view * fetch emojis from db based on ids * fix typo in test * lint * fix pg migration * update tests * update emoji checking logic * update comment * clarify comments + add some spacing * tidy up loops a lil (thanks kim) --- internal/ap/interfaces.go | 1 + .../api/client/account/accountupdate_test.go | 14 +- internal/api/s2s/user/inboxpost_test.go | 30 ++- internal/cache/account.go | 2 + internal/db/account.go | 3 + internal/db/bundb/account.go | 60 +++++- internal/db/bundb/account_test.go | 59 +++++- internal/db/bundb/bundb.go | 17 +- .../20220916122701_emojis_in_accounts.go | 69 ++++++ internal/federation/dereferencing/account.go | 193 ++++++++++++++--- .../federation/dereferencing/account_test.go | 200 ++++++++++++++++++ .../dereferencing/dereferencer_test.go | 2 + internal/federation/dereferencing/emoji.go | 58 +++++ internal/federation/dereferencing/status.go | 59 +----- internal/federation/federatingdb/update.go | 11 +- internal/gtsmodel/account.go | 10 + internal/processing/account/delete.go | 2 + internal/processing/account/update.go | 28 +++ internal/processing/fromfederator.go | 10 +- internal/typeutils/astointernal.go | 7 + internal/typeutils/converter_test.go | 2 + internal/typeutils/internaltoas.go | 33 ++- internal/typeutils/internaltoas_test.go | 32 ++- internal/typeutils/internaltofrontend.go | 25 ++- internal/typeutils/internaltofrontend_test.go | 30 +++ testrig/db.go | 1 + testrig/media/kip-original.gif | Bin 0 -> 1428 bytes testrig/media/kip-static.png | Bin 0 -> 802 bytes testrig/media/yell-original.png | Bin 0 -> 10889 bytes testrig/media/yell-static.png | Bin 0 -> 10808 bytes testrig/testmodels.go | 82 +++++++ testrig/transportcontroller.go | 15 ++ web/template/profile.tmpl | 4 +- web/template/status.tmpl | 2 +- 34 files changed, 934 insertions(+), 127 deletions(-) create mode 100644 internal/db/bundb/migrations/20220916122701_emojis_in_accounts.go create mode 100644 testrig/media/kip-original.gif create mode 100644 testrig/media/kip-static.png create mode 100644 testrig/media/yell-original.png create mode 100644 testrig/media/yell-static.png diff --git a/internal/ap/interfaces.go b/internal/ap/interfaces.go index 803eda640..05e030d68 100644 --- a/internal/ap/interfaces.go +++ b/internal/ap/interfaces.go @@ -41,6 +41,7 @@ type Accountable interface { WithFeatured WithManuallyApprovesFollowers WithEndpoints + WithTag } // Statusable represents the minimum activitypub interface for representing a 'status'. diff --git a/internal/api/client/account/accountupdate_test.go b/internal/api/client/account/accountupdate_test.go index d59cd02a5..259bb69e9 100644 --- a/internal/api/client/account/accountupdate_test.go +++ b/internal/api/client/account/accountupdate_test.go @@ -200,7 +200,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerGet func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerTwoFields() { // set up the request // we're updating the note of zork, and setting locked to true - newBio := "this is my new bio read it and weep" + newBio := "this is my new bio read it and weep :rainbow:" requestBody, w, err := testrig.CreateMultipartFormData( "", "", map[string]string{ @@ -235,9 +235,19 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerTwo // check the returned api model account // fields should be updated - suite.Equal("

this is my new bio read it and weep

", apimodelAccount.Note) + suite.Equal("

this is my new bio read it and weep :rainbow:

", apimodelAccount.Note) suite.Equal(newBio, apimodelAccount.Source.Note) suite.True(apimodelAccount.Locked) + suite.NotEmpty(apimodelAccount.Emojis) + suite.Equal(apimodelAccount.Emojis[0].Shortcode, "rainbow") + + // check the account in the database + dbZork, err := suite.db.GetAccountByID(context.Background(), apimodelAccount.ID) + suite.NoError(err) + suite.Equal(newBio, dbZork.NoteRaw) + suite.Equal("

this is my new bio read it and weep :rainbow:

", dbZork.Note) + suite.True(*dbZork.Locked) + suite.NotEmpty(dbZork.EmojiIDs) } func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerWithMedia() { diff --git a/internal/api/s2s/user/inboxpost_test.go b/internal/api/s2s/user/inboxpost_test.go index ff3ec47d3..7180fd2f9 100644 --- a/internal/api/s2s/user/inboxpost_test.go +++ b/internal/api/s2s/user/inboxpost_test.go @@ -237,6 +237,8 @@ func (suite *InboxPostTestSuite) TestPostUnblock() { func (suite *InboxPostTestSuite) TestPostUpdate() { updatedAccount := *suite.testAccounts["remote_account_1"] updatedAccount.DisplayName = "updated display name!" + testEmoji := testrig.NewTestEmojis()["rainbow"] + updatedAccount.Emojis = []*gtsmodel.Emoji{testEmoji} asAccount, err := suite.tc.AccountToAS(context.Background(), &updatedAccount) suite.NoError(err) @@ -288,6 +290,15 @@ func (suite *InboxPostTestSuite) TestPostUpdate() { federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) emailSender := testrig.NewEmailSender("../../../../web/template/", nil) processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + if err := processor.Start(); err != nil { + panic(err) + } + defer func() { + if err := processor.Stop(); err != nil { + panic(err) + } + }() + userModule := user.New(processor).(*user.Module) // setup request @@ -322,11 +333,21 @@ func (suite *InboxPostTestSuite) TestPostUpdate() { suite.Equal(http.StatusOK, result.StatusCode) // account should be changed in the database now - dbUpdatedAccount, err := suite.db.GetAccountByID(context.Background(), updatedAccount.ID) - suite.NoError(err) + var dbUpdatedAccount *gtsmodel.Account - // displayName should be updated - suite.Equal("updated display name!", dbUpdatedAccount.DisplayName) + if !testrig.WaitFor(func() bool { + // displayName should be updated + dbUpdatedAccount, _ = suite.db.GetAccountByID(context.Background(), updatedAccount.ID) + return dbUpdatedAccount.DisplayName == "updated display name!" + }) { + suite.FailNow("timed out waiting for account update") + } + + // emojis should be updated + suite.Contains(dbUpdatedAccount.EmojiIDs, testEmoji.ID) + + // account should be freshly webfingered + suite.WithinDuration(time.Now(), dbUpdatedAccount.LastWebfingeredAt, 10*time.Second) // everything else should be the same as it was before suite.EqualValues(updatedAccount.Username, dbUpdatedAccount.Username) @@ -350,7 +371,6 @@ func (suite *InboxPostTestSuite) TestPostUpdate() { suite.EqualValues(updatedAccount.Language, dbUpdatedAccount.Language) suite.EqualValues(updatedAccount.URI, dbUpdatedAccount.URI) suite.EqualValues(updatedAccount.URL, dbUpdatedAccount.URL) - suite.EqualValues(updatedAccount.LastWebfingeredAt, dbUpdatedAccount.LastWebfingeredAt) suite.EqualValues(updatedAccount.InboxURI, dbUpdatedAccount.InboxURI) suite.EqualValues(updatedAccount.OutboxURI, dbUpdatedAccount.OutboxURI) suite.EqualValues(updatedAccount.FollowingURI, dbUpdatedAccount.FollowingURI) diff --git a/internal/cache/account.go b/internal/cache/account.go index f478c81d3..7e23c3194 100644 --- a/internal/cache/account.go +++ b/internal/cache/account.go @@ -116,6 +116,8 @@ func copyAccount(account *gtsmodel.Account) *gtsmodel.Account { HeaderMediaAttachment: nil, HeaderRemoteURL: account.HeaderRemoteURL, DisplayName: account.DisplayName, + EmojiIDs: account.EmojiIDs, + Emojis: nil, Fields: account.Fields, Note: account.Note, NoteRaw: account.NoteRaw, diff --git a/internal/db/account.go b/internal/db/account.go index 5f1336872..351d6d01c 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -42,6 +42,9 @@ type Account interface { // GetAccountByPubkeyID returns one account with the given public key URI (ID), or an error if something goes wrong. GetAccountByPubkeyID(ctx context.Context, id string) (*gtsmodel.Account, Error) + // PutAccount puts one account in the database. + PutAccount(ctx context.Context, account *gtsmodel.Account) (*gtsmodel.Account, Error) + // UpdateAccount updates one account by ID. UpdateAccount(ctx context.Context, account *gtsmodel.Account) (*gtsmodel.Account, Error) diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 2105368d3..074804690 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -45,7 +45,8 @@ func (a *accountDB) newAccountQ(account *gtsmodel.Account) *bun.SelectQuery { NewSelect(). Model(account). Relation("AvatarMediaAttachment"). - Relation("HeaderMediaAttachment") + Relation("HeaderMediaAttachment"). + Relation("Emojis") } func (a *accountDB) GetAccountByID(ctx context.Context, id string) (*gtsmodel.Account, db.Error) { @@ -138,24 +139,61 @@ func (a *accountDB) getAccount(ctx context.Context, cacheGet func() (*gtsmodel.A return account, nil } +func (a *accountDB) PutAccount(ctx context.Context, account *gtsmodel.Account) (*gtsmodel.Account, db.Error) { + if err := a.conn.RunInTx(ctx, func(tx bun.Tx) error { + // create links between this account and any emojis it uses + for _, i := range account.EmojiIDs { + if _, err := tx.NewInsert().Model(>smodel.AccountToEmoji{ + AccountID: account.ID, + EmojiID: i, + }).Exec(ctx); err != nil { + return err + } + } + + // insert the account + _, err := tx.NewInsert().Model(account).Exec(ctx) + return err + }); err != nil { + return nil, a.conn.ProcessError(err) + } + + a.cache.Put(account) + return account, nil +} + func (a *accountDB) UpdateAccount(ctx context.Context, account *gtsmodel.Account) (*gtsmodel.Account, db.Error) { // Update the account's last-updated account.UpdatedAt = time.Now() - // Update the account model in the DB - _, err := a.conn. - NewUpdate(). - Model(account). - WherePK(). - Exec(ctx) - if err != nil { + if err := a.conn.RunInTx(ctx, func(tx bun.Tx) error { + // create links between this account and any emojis it uses + // first clear out any old emoji links + if _, err := tx.NewDelete(). + Model(&[]*gtsmodel.AccountToEmoji{}). + Where("account_id = ?", account.ID). + Exec(ctx); err != nil { + return err + } + + // now populate new emoji links + for _, i := range account.EmojiIDs { + if _, err := tx.NewInsert().Model(>smodel.AccountToEmoji{ + AccountID: account.ID, + EmojiID: i, + }).Exec(ctx); err != nil { + return err + } + } + + // update the account + _, err := tx.NewUpdate().Model(account).WherePK().Exec(ctx) + return err + }); err != nil { return nil, a.conn.ProcessError(err) } - // Place updated account in cache - // (this will replace existing, i.e. invalidating) a.cache.Put(account) - return account, nil } diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go index 3c19e84d9..1e6dc4436 100644 --- a/internal/db/bundb/account_test.go +++ b/internal/db/bundb/account_test.go @@ -27,7 +27,9 @@ "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/db/bundb" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" ) type AccountTestSuite struct { @@ -71,17 +73,70 @@ func (suite *AccountTestSuite) TestGetAccountByUsernameDomain() { } func (suite *AccountTestSuite) TestUpdateAccount() { + ctx := context.Background() + testAccount := suite.testAccounts["local_account_1"] testAccount.DisplayName = "new display name!" + testAccount.EmojiIDs = []string{"01GD36ZKWTKY3T1JJ24JR7KY1Q", "01GD36ZV904SHBHNAYV6DX5QEF"} - _, err := suite.db.UpdateAccount(context.Background(), testAccount) + _, err := suite.db.UpdateAccount(ctx, testAccount) suite.NoError(err) - updated, err := suite.db.GetAccountByID(context.Background(), testAccount.ID) + updated, err := suite.db.GetAccountByID(ctx, testAccount.ID) suite.NoError(err) suite.Equal("new display name!", updated.DisplayName) + suite.Equal([]string{"01GD36ZKWTKY3T1JJ24JR7KY1Q", "01GD36ZV904SHBHNAYV6DX5QEF"}, updated.EmojiIDs) suite.WithinDuration(time.Now(), updated.UpdatedAt, 5*time.Second) + + // get account without cache + make sure it's really in the db as desired + dbService, ok := suite.db.(*bundb.DBService) + if !ok { + panic("db was not *bundb.DBService") + } + + noCache := >smodel.Account{} + err = dbService.GetConn(). + NewSelect(). + Model(noCache). + Where("account.id = ?", bun.Ident(testAccount.ID)). + Relation("AvatarMediaAttachment"). + Relation("HeaderMediaAttachment"). + Relation("Emojis"). + Scan(ctx) + + suite.NoError(err) + suite.Equal("new display name!", noCache.DisplayName) + suite.Equal([]string{"01GD36ZKWTKY3T1JJ24JR7KY1Q", "01GD36ZV904SHBHNAYV6DX5QEF"}, noCache.EmojiIDs) + suite.WithinDuration(time.Now(), noCache.UpdatedAt, 5*time.Second) + suite.NotNil(noCache.AvatarMediaAttachment) + suite.NotNil(noCache.HeaderMediaAttachment) + + // update again to remove emoji associations + testAccount.EmojiIDs = []string{} + + _, err = suite.db.UpdateAccount(ctx, testAccount) + suite.NoError(err) + + updated, err = suite.db.GetAccountByID(ctx, testAccount.ID) + suite.NoError(err) + suite.Equal("new display name!", updated.DisplayName) + suite.Empty(updated.EmojiIDs) + suite.WithinDuration(time.Now(), updated.UpdatedAt, 5*time.Second) + + err = dbService.GetConn(). + NewSelect(). + Model(noCache). + Where("account.id = ?", bun.Ident(testAccount.ID)). + Relation("AvatarMediaAttachment"). + Relation("HeaderMediaAttachment"). + Relation("Emojis"). + Scan(ctx) + + suite.NoError(err) + suite.Equal("new display name!", noCache.DisplayName) + suite.Empty(noCache.EmojiIDs) + suite.WithinDuration(time.Now(), noCache.UpdatedAt, 5*time.Second) } func (suite *AccountTestSuite) TestInsertAccountWithDefaults() { diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index b944ae3ea..2fc65364f 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -67,12 +67,13 @@ ) var registerTables = []interface{}{ + >smodel.AccountToEmoji{}, >smodel.StatusToEmoji{}, >smodel.StatusToTag{}, } -// bunDBService satisfies the DB interface -type bunDBService struct { +// DBService satisfies the DB interface +type DBService struct { db.Account db.Admin db.Basic @@ -89,6 +90,12 @@ type bunDBService struct { conn *DBConn } +// GetConn returns the underlying bun connection. +// Should only be used in testing + exceptional circumstance. +func (dbService *DBService) GetConn() *DBConn { + return dbService.conn +} + func doMigration(ctx context.Context, db *bun.DB) error { migrator := migrate.NewMigrator(db, migrations.Migrations) @@ -177,7 +184,7 @@ func NewBunDBService(ctx context.Context) (db.DB, error) { // Prepare domain block cache blockCache := cache.NewDomainBlockCache() - ps := &bunDBService{ + ps := &DBService{ Account: accounts, Admin: &adminDB{ conn: conn, @@ -399,7 +406,7 @@ func tweakConnectionValues(sqldb *sql.DB) { CONVERSION FUNCTIONS */ -func (ps *bunDBService) TagStringsToTags(ctx context.Context, tags []string, originAccountID string) ([]*gtsmodel.Tag, error) { +func (dbService *DBService) TagStringsToTags(ctx context.Context, tags []string, originAccountID string) ([]*gtsmodel.Tag, error) { protocol := config.GetProtocol() host := config.GetHost() @@ -408,7 +415,7 @@ func (ps *bunDBService) TagStringsToTags(ctx context.Context, tags []string, ori 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.NewSelect().Model(tag).Where("LOWER(?) = LOWER(?)", bun.Ident("name"), t).Scan(ctx); err != nil { + if err := dbService.conn.NewSelect().Model(tag).Where("LOWER(?) = LOWER(?)", bun.Ident("name"), t).Scan(ctx); err != nil { if err == sql.ErrNoRows { // tag doesn't exist yet so populate it newID, err := id.NewRandomULID() diff --git a/internal/db/bundb/migrations/20220916122701_emojis_in_accounts.go b/internal/db/bundb/migrations/20220916122701_emojis_in_accounts.go new file mode 100644 index 000000000..91468a4c9 --- /dev/null +++ b/internal/db/bundb/migrations/20220916122701_emojis_in_accounts.go @@ -0,0 +1,69 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 . +*/ + +package migrations + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + q := tx.NewAddColumn().Model(>smodel.Account{}) + + switch tx.Dialect().Name() { + case dialect.PG: + q = q.ColumnExpr("? VARCHAR[]", bun.Ident("emojis")) + case dialect.SQLite: + q = q.ColumnExpr("? VARCHAR", bun.Ident("emojis")) + default: + log.Panic("db dialect was neither pg nor sqlite") + } + + if _, err := q.Exec(ctx); err != nil { + return err + } + + if _, err := tx. + NewCreateTable(). + Model(>smodel.AccountToEmoji{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go index 6a633a54a..41a8aa8a9 100644 --- a/internal/federation/dereferencing/account.go +++ b/internal/federation/dereferencing/account.go @@ -76,6 +76,11 @@ type GetRemoteAccountParams struct { // quickly fetch a remote account from the database or fail, and don't want to cause // http requests to go flying around. SkipResolve bool + // PartialAccount can be used if the GetRemoteAccount call results from a federated/ap + // account update. In this case, we will already have a partial representation of the account, + // derived from converting the AP representation to a gtsmodel representation. If this field + // is provided, then GetRemoteAccount will use this as a basis for building the full account. + PartialAccount *gtsmodel.Account } // GetRemoteAccount completely dereferences a remote account, converts it to a GtS model account, @@ -107,8 +112,16 @@ func (d *deref) GetRemoteAccount(ctx context.Context, params GetRemoteAccountPar skipResolve := params.SkipResolve // this first step checks if we have the - // account in the database somewhere already + // account in the database somewhere already, + // or if we've been provided it as a partial switch { + case params.PartialAccount != nil: + foundAccount = params.PartialAccount + if foundAccount.Domain == "" || foundAccount.Domain == config.GetHost() || foundAccount.Domain == config.GetAccountDomain() { + // this is actually a local account, + // make sure we don't try to resolve + skipResolve = true + } case params.RemoteAccountID != nil: uri := params.RemoteAccountID host := uri.Host @@ -163,7 +176,7 @@ func (d *deref) GetRemoteAccount(ctx context.Context, params GetRemoteAccountPar params.RemoteAccountHost = params.RemoteAccountID.Host // ... but we still need the username so we can do a finger for the accountDomain - // check if we had the account stored already and got it earlier + // check if we got the account earlier if foundAccount != nil { params.RemoteAccountUsername = foundAccount.Username } else { @@ -201,9 +214,10 @@ func (d *deref) GetRemoteAccount(ctx context.Context, params GetRemoteAccountPar // to save on remote calls, only webfinger if: // - we don't know the remote account ActivityPub ID yet OR // - we haven't found the account yet in some other way OR + // - we were passed a partial account in params OR // - we haven't webfingered the account for two days AND the account isn't an instance account var fingered time.Time - if params.RemoteAccountID == nil || foundAccount == nil || (foundAccount.LastWebfingeredAt.Before(time.Now().Add(webfingerInterval)) && !instanceAccount(foundAccount)) { + if params.RemoteAccountID == nil || foundAccount == nil || params.PartialAccount != nil || (foundAccount.LastWebfingeredAt.Before(time.Now().Add(webfingerInterval)) && !instanceAccount(foundAccount)) { accountDomain, params.RemoteAccountID, err = d.fingerRemoteAccount(ctx, params.RequestingUsername, params.RemoteAccountUsername, params.RemoteAccountHost) if err != nil { err = fmt.Errorf("GetRemoteAccount: error while fingering: %s", err) @@ -263,7 +277,7 @@ func (d *deref) GetRemoteAccount(ctx context.Context, params GetRemoteAccountPar foundAccount.LastWebfingeredAt = fingered foundAccount.UpdatedAt = time.Now() - err = d.db.Put(ctx, foundAccount) + foundAccount, err = d.db.PutAccount(ctx, foundAccount) if err != nil { err = fmt.Errorf("GetRemoteAccount: error putting new account: %s", err) return @@ -273,13 +287,10 @@ func (d *deref) GetRemoteAccount(ctx context.Context, params GetRemoteAccountPar } // we had the account already, but now we know the account domain, so update it if it's different + var accountDomainChanged bool if !strings.EqualFold(foundAccount.Domain, accountDomain) { + accountDomainChanged = true foundAccount.Domain = accountDomain - foundAccount, err = d.db.UpdateAccount(ctx, foundAccount) - if err != nil { - err = fmt.Errorf("GetRemoteAccount: error updating account: %s", err) - return - } } // if SharedInboxURI is nil, that means we don't know yet if this account has @@ -327,8 +338,7 @@ func (d *deref) GetRemoteAccount(ctx context.Context, params GetRemoteAccountPar foundAccount.LastWebfingeredAt = fingered } - if fieldsChanged || fingeredChanged || sharedInboxChanged { - foundAccount.UpdatedAt = time.Now() + if accountDomainChanged || sharedInboxChanged || fieldsChanged || fingeredChanged { foundAccount, err = d.db.UpdateAccount(ctx, foundAccount) if err != nil { return nil, fmt.Errorf("GetRemoteAccount: error updating remoteAccount: %s", err) @@ -423,15 +433,20 @@ func (d *deref) populateAccountFields(ctx context.Context, account *gtsmodel.Acc return false, fmt.Errorf("populateAccountFields: domain %s is blocked", accountURI.Host) } - t, err := d.transportController.NewTransportForUsername(ctx, requestingUsername) - if err != nil { - return false, fmt.Errorf("populateAccountFields: error getting transport for user: %s", err) - } + var changed bool // fetch the header and avatar - changed, err := d.fetchRemoteAccountMedia(ctx, account, t, blocking) - if err != nil { + if mediaChanged, err := d.fetchRemoteAccountMedia(ctx, account, requestingUsername, blocking); err != nil { return false, fmt.Errorf("populateAccountFields: error fetching header/avi for account: %s", err) + } else if mediaChanged { + changed = mediaChanged + } + + // fetch any emojis used in note, fields, display name, etc + if emojisChanged, err := d.fetchRemoteAccountEmojis(ctx, account, requestingUsername); err != nil { + return false, fmt.Errorf("populateAccountFields: error fetching emojis for account: %s", err) + } else if emojisChanged { + changed = emojisChanged } return changed, nil @@ -449,17 +464,11 @@ func (d *deref) populateAccountFields(ctx context.Context, account *gtsmodel.Acc // // If blocking is true, then the calls to the media manager made by this function will be blocking: // in other words, the function won't return until the header and the avatar have been fully processed. -func (d *deref) fetchRemoteAccountMedia(ctx context.Context, targetAccount *gtsmodel.Account, t transport.Transport, blocking bool) (bool, error) { - changed := false - - accountURI, err := url.Parse(targetAccount.URI) - if err != nil { - return changed, fmt.Errorf("fetchRemoteAccountMedia: couldn't parse account URI %s: %s", targetAccount.URI, err) - } - - if blocked, err := d.db.IsDomainBlocked(ctx, accountURI.Host); blocked || err != nil { - return changed, fmt.Errorf("fetchRemoteAccountMedia: domain %s is blocked", accountURI.Host) - } +func (d *deref) fetchRemoteAccountMedia(ctx context.Context, targetAccount *gtsmodel.Account, requestingUsername string, blocking bool) (bool, error) { + var ( + changed bool + t transport.Transport + ) if targetAccount.AvatarRemoteURL != "" && (targetAccount.AvatarMediaAttachmentID == "") { var processingMedia *media.ProcessingMedia @@ -479,6 +488,14 @@ func (d *deref) fetchRemoteAccountMedia(ctx context.Context, targetAccount *gtsm return changed, err } + if t == nil { + var err error + t, err = d.transportController.NewTransportForUsername(ctx, requestingUsername) + if err != nil { + return false, fmt.Errorf("fetchRemoteAccountMedia: error getting transport for user: %s", err) + } + } + data := func(innerCtx context.Context) (io.Reader, int, error) { return t.DereferenceMedia(innerCtx, avatarIRI) } @@ -537,6 +554,14 @@ func (d *deref) fetchRemoteAccountMedia(ctx context.Context, targetAccount *gtsm return changed, err } + if t == nil { + var err error + t, err = d.transportController.NewTransportForUsername(ctx, requestingUsername) + if err != nil { + return false, fmt.Errorf("fetchRemoteAccountMedia: error getting transport for user: %s", err) + } + } + data := func(innerCtx context.Context) (io.Reader, int, error) { return t.DereferenceMedia(innerCtx, headerIRI) } @@ -580,6 +605,118 @@ func (d *deref) fetchRemoteAccountMedia(ctx context.Context, targetAccount *gtsm return changed, nil } +func (d *deref) fetchRemoteAccountEmojis(ctx context.Context, targetAccount *gtsmodel.Account, requestingUsername string) (bool, error) { + maybeEmojis := targetAccount.Emojis + maybeEmojiIDs := targetAccount.EmojiIDs + + // It's possible that the account had emoji IDs set on it, but not Emojis + // themselves, depending on how it was fetched before being passed to us. + // + // If we only have IDs, fetch the emojis from the db. We know they're in + // there or else they wouldn't have IDs. + if len(maybeEmojiIDs) > len(maybeEmojis) { + maybeEmojis = []*gtsmodel.Emoji{} + for _, emojiID := range maybeEmojiIDs { + maybeEmoji, err := d.db.GetEmojiByID(ctx, emojiID) + if err != nil { + return false, err + } + maybeEmojis = append(maybeEmojis, maybeEmoji) + } + } + + // For all the maybe emojis we have, we either fetch them from the database + // (if we haven't already), or dereference them from the remote instance. + gotEmojis, err := d.populateEmojis(ctx, maybeEmojis, requestingUsername) + if err != nil { + return false, err + } + + // Extract the ID of each fetched or dereferenced emoji, so we can attach + // this to the account if necessary. + gotEmojiIDs := make([]string, 0, len(gotEmojis)) + for _, e := range gotEmojis { + gotEmojiIDs = append(gotEmojiIDs, e.ID) + } + + var ( + changed = false // have the emojis for this account changed? + maybeLen = len(maybeEmojis) + gotLen = len(gotEmojis) + ) + + // if the length of everything is zero, this is simple: + // nothing has changed and there's nothing to do + if maybeLen == 0 && gotLen == 0 { + return changed, nil + } + + // if the *amount* of emojis on the account has changed, then the got emojis + // are definitely different from the previous ones (if there were any) -- + // the account has either more or fewer emojis set on it now, so take the + // discovered emojis as the new correct ones. + if maybeLen != gotLen { + changed = true + targetAccount.Emojis = gotEmojis + targetAccount.EmojiIDs = gotEmojiIDs + return changed, nil + } + + // if the lengths are the same but not all of the slices are + // zero, something *might* have changed, so we have to check + + // 1. did we have emojis before that we don't have now? + for _, maybeEmoji := range maybeEmojis { + var stillPresent bool + + for _, gotEmoji := range gotEmojis { + if maybeEmoji.URI == gotEmoji.URI { + // the emoji we maybe had is still present now, + // so we can stop checking gotEmojis + stillPresent = true + break + } + } + + if !stillPresent { + // at least one maybeEmoji is no longer present in + // the got emojis, so we can stop checking now + changed = true + targetAccount.Emojis = gotEmojis + targetAccount.EmojiIDs = gotEmojiIDs + return changed, nil + } + } + + // 2. do we have emojis now that we didn't have before? + for _, gotEmoji := range gotEmojis { + var wasPresent bool + + for _, maybeEmoji := range maybeEmojis { + // check emoji IDs here as well, because unreferenced + // maybe emojis we didn't already have would not have + // had IDs set on them yet + if gotEmoji.URI == maybeEmoji.URI && gotEmoji.ID == maybeEmoji.ID { + // this got emoji was present already in the maybeEmoji, + // so we can stop checking through maybeEmojis + wasPresent = true + break + } + } + + if !wasPresent { + // at least one gotEmojis was not present in + // the maybeEmojis, so we can stop checking now + changed = true + targetAccount.Emojis = gotEmojis + targetAccount.EmojiIDs = gotEmojiIDs + return changed, nil + } + } + + return changed, nil +} + func lockAndLoad(ctx context.Context, lock *sync.Mutex, processing *media.ProcessingMedia, processingMap map[string]*media.ProcessingMedia, accountID string) error { // whatever happens, remove the in-process media from the map defer func() { diff --git a/internal/federation/dereferencing/account_test.go b/internal/federation/dereferencing/account_test.go index 4f1a83a96..aec612ac8 100644 --- a/internal/federation/dereferencing/account_test.go +++ b/internal/federation/dereferencing/account_test.go @@ -27,6 +27,7 @@ "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -195,6 +196,205 @@ func (suite *AccountTestSuite) TestDereferenceLocalAccountWithUnknownUserURI() { suite.Nil(fetchedAccount) } +func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithPartial() { + fetchingAccount := suite.testAccounts["local_account_1"] + + remoteAccount := suite.testAccounts["remote_account_1"] + remoteAccountPartial := >smodel.Account{ + ID: remoteAccount.ID, + ActorType: remoteAccount.ActorType, + Language: remoteAccount.Language, + CreatedAt: remoteAccount.CreatedAt, + UpdatedAt: remoteAccount.UpdatedAt, + Username: remoteAccount.Username, + Domain: remoteAccount.Domain, + DisplayName: remoteAccount.DisplayName, + URI: remoteAccount.URI, + InboxURI: remoteAccount.URI, + SharedInboxURI: remoteAccount.SharedInboxURI, + PublicKeyURI: remoteAccount.PublicKeyURI, + URL: remoteAccount.URL, + FollowingURI: remoteAccount.FollowingURI, + FollowersURI: remoteAccount.FollowersURI, + OutboxURI: remoteAccount.OutboxURI, + FeaturedCollectionURI: remoteAccount.FeaturedCollectionURI, + Emojis: []*gtsmodel.Emoji{ + // dereference an emoji we don't have stored yet + { + URI: "http://fossbros-anonymous.io/emoji/01GD5HCC2YECT012TK8PAGX4D1", + Shortcode: "kip_van_den_bos", + UpdatedAt: testrig.TimeMustParse("2022-09-13T12:13:12+02:00"), + ImageRemoteURL: "http://fossbros-anonymous.io/emoji/kip.gif", + Disabled: testrig.FalseBool(), + VisibleInPicker: testrig.FalseBool(), + Domain: "fossbros-anonymous.io", + }, + }, + } + + fetchedAccount, err := suite.dereferencer.GetRemoteAccount(context.Background(), dereferencing.GetRemoteAccountParams{ + RequestingUsername: fetchingAccount.Username, + RemoteAccountID: testrig.URLMustParse(remoteAccount.URI), + RemoteAccountHost: remoteAccount.Domain, + RemoteAccountUsername: remoteAccount.Username, + PartialAccount: remoteAccountPartial, + Blocking: true, + }) + suite.NoError(err) + suite.NotNil(fetchedAccount) + suite.NotNil(fetchedAccount.EmojiIDs) + suite.NotNil(fetchedAccount.Emojis) +} + +func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithPartial2() { + fetchingAccount := suite.testAccounts["local_account_1"] + + knownEmoji := suite.testEmojis["yell"] + + remoteAccount := suite.testAccounts["remote_account_1"] + remoteAccountPartial := >smodel.Account{ + ID: remoteAccount.ID, + ActorType: remoteAccount.ActorType, + Language: remoteAccount.Language, + CreatedAt: remoteAccount.CreatedAt, + UpdatedAt: remoteAccount.UpdatedAt, + Username: remoteAccount.Username, + Domain: remoteAccount.Domain, + DisplayName: remoteAccount.DisplayName, + URI: remoteAccount.URI, + InboxURI: remoteAccount.URI, + SharedInboxURI: remoteAccount.SharedInboxURI, + PublicKeyURI: remoteAccount.PublicKeyURI, + URL: remoteAccount.URL, + FollowingURI: remoteAccount.FollowingURI, + FollowersURI: remoteAccount.FollowersURI, + OutboxURI: remoteAccount.OutboxURI, + FeaturedCollectionURI: remoteAccount.FeaturedCollectionURI, + Emojis: []*gtsmodel.Emoji{ + // an emoji we already have + { + URI: knownEmoji.URI, + Shortcode: knownEmoji.Shortcode, + UpdatedAt: knownEmoji.CreatedAt, + ImageRemoteURL: knownEmoji.ImageRemoteURL, + Disabled: knownEmoji.Disabled, + VisibleInPicker: knownEmoji.VisibleInPicker, + }, + }, + } + + fetchedAccount, err := suite.dereferencer.GetRemoteAccount(context.Background(), dereferencing.GetRemoteAccountParams{ + RequestingUsername: fetchingAccount.Username, + RemoteAccountID: testrig.URLMustParse(remoteAccount.URI), + RemoteAccountHost: remoteAccount.Domain, + RemoteAccountUsername: remoteAccount.Username, + PartialAccount: remoteAccountPartial, + Blocking: true, + }) + suite.NoError(err) + suite.NotNil(fetchedAccount) + suite.NotNil(fetchedAccount.EmojiIDs) + suite.NotNil(fetchedAccount.Emojis) +} + +func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithPartial3() { + fetchingAccount := suite.testAccounts["local_account_1"] + + knownEmoji := suite.testEmojis["yell"] + + remoteAccount := suite.testAccounts["remote_account_1"] + remoteAccountPartial := >smodel.Account{ + ID: remoteAccount.ID, + ActorType: remoteAccount.ActorType, + Language: remoteAccount.Language, + CreatedAt: remoteAccount.CreatedAt, + UpdatedAt: remoteAccount.UpdatedAt, + Username: remoteAccount.Username, + Domain: remoteAccount.Domain, + DisplayName: remoteAccount.DisplayName, + URI: remoteAccount.URI, + InboxURI: remoteAccount.URI, + SharedInboxURI: remoteAccount.SharedInboxURI, + PublicKeyURI: remoteAccount.PublicKeyURI, + URL: remoteAccount.URL, + FollowingURI: remoteAccount.FollowingURI, + FollowersURI: remoteAccount.FollowersURI, + OutboxURI: remoteAccount.OutboxURI, + FeaturedCollectionURI: remoteAccount.FeaturedCollectionURI, + Emojis: []*gtsmodel.Emoji{ + // an emoji we already have + { + URI: knownEmoji.URI, + Shortcode: knownEmoji.Shortcode, + UpdatedAt: knownEmoji.CreatedAt, + ImageRemoteURL: knownEmoji.ImageRemoteURL, + Disabled: knownEmoji.Disabled, + VisibleInPicker: knownEmoji.VisibleInPicker, + }, + }, + } + + fetchedAccount, err := suite.dereferencer.GetRemoteAccount(context.Background(), dereferencing.GetRemoteAccountParams{ + RequestingUsername: fetchingAccount.Username, + RemoteAccountID: testrig.URLMustParse(remoteAccount.URI), + RemoteAccountHost: remoteAccount.Domain, + RemoteAccountUsername: remoteAccount.Username, + PartialAccount: remoteAccountPartial, + Blocking: true, + }) + suite.NoError(err) + suite.NotNil(fetchedAccount) + suite.NotNil(fetchedAccount.EmojiIDs) + suite.NotNil(fetchedAccount.Emojis) + suite.Equal(knownEmoji.URI, fetchedAccount.Emojis[0].URI) + + remoteAccountPartial2 := >smodel.Account{ + ID: remoteAccount.ID, + ActorType: remoteAccount.ActorType, + Language: remoteAccount.Language, + CreatedAt: remoteAccount.CreatedAt, + UpdatedAt: remoteAccount.UpdatedAt, + Username: remoteAccount.Username, + Domain: remoteAccount.Domain, + DisplayName: remoteAccount.DisplayName, + URI: remoteAccount.URI, + InboxURI: remoteAccount.URI, + SharedInboxURI: remoteAccount.SharedInboxURI, + PublicKeyURI: remoteAccount.PublicKeyURI, + URL: remoteAccount.URL, + FollowingURI: remoteAccount.FollowingURI, + FollowersURI: remoteAccount.FollowersURI, + OutboxURI: remoteAccount.OutboxURI, + FeaturedCollectionURI: remoteAccount.FeaturedCollectionURI, + Emojis: []*gtsmodel.Emoji{ + // dereference an emoji we don't have stored yet + { + URI: "http://fossbros-anonymous.io/emoji/01GD5HCC2YECT012TK8PAGX4D1", + Shortcode: "kip_van_den_bos", + UpdatedAt: testrig.TimeMustParse("2022-09-13T12:13:12+02:00"), + ImageRemoteURL: "http://fossbros-anonymous.io/emoji/kip.gif", + Disabled: testrig.FalseBool(), + VisibleInPicker: testrig.FalseBool(), + Domain: "fossbros-anonymous.io", + }, + }, + } + + fetchedAccount2, err := suite.dereferencer.GetRemoteAccount(context.Background(), dereferencing.GetRemoteAccountParams{ + RequestingUsername: fetchingAccount.Username, + RemoteAccountID: testrig.URLMustParse(remoteAccount.URI), + RemoteAccountHost: remoteAccount.Domain, + RemoteAccountUsername: remoteAccount.Username, + PartialAccount: remoteAccountPartial2, + Blocking: true, + }) + suite.NoError(err) + suite.NotNil(fetchedAccount2) + suite.NotNil(fetchedAccount2.EmojiIDs) + suite.NotNil(fetchedAccount2.Emojis) + suite.Equal("http://fossbros-anonymous.io/emoji/01GD5HCC2YECT012TK8PAGX4D1", fetchedAccount2.Emojis[0].URI) +} + func TestAccountTestSuite(t *testing.T) { suite.Run(t, new(AccountTestSuite)) } diff --git a/internal/federation/dereferencing/dereferencer_test.go b/internal/federation/dereferencing/dereferencer_test.go index c0343a6b8..1bf11d668 100644 --- a/internal/federation/dereferencing/dereferencer_test.go +++ b/internal/federation/dereferencing/dereferencer_test.go @@ -41,6 +41,7 @@ type DereferencerStandardTestSuite struct { testRemoteServices map[string]vocab.ActivityStreamsService testRemoteAttachments map[string]testrig.RemoteAttachmentFile testAccounts map[string]*gtsmodel.Account + testEmojis map[string]*gtsmodel.Emoji dereferencer dereferencing.Dereferencer } @@ -55,6 +56,7 @@ func (suite *DereferencerStandardTestSuite) SetupTest() { suite.testRemoteGroups = testrig.NewTestFediGroups() suite.testRemoteServices = testrig.NewTestFediServices() suite.testRemoteAttachments = testrig.NewTestFediAttachments("../../../testrig/media") + suite.testEmojis = testrig.NewTestEmojis() suite.db = testrig.NewTestDB() suite.storage = testrig.NewInMemoryStorage() diff --git a/internal/federation/dereferencing/emoji.go b/internal/federation/dereferencing/emoji.go index 49811b131..87d0bd515 100644 --- a/internal/federation/dereferencing/emoji.go +++ b/internal/federation/dereferencing/emoji.go @@ -24,6 +24,10 @@ "io" "net/url" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" ) @@ -49,3 +53,57 @@ func (d *deref) GetRemoteEmoji(ctx context.Context, requestingUsername string, r return processingMedia, nil } + +func (d *deref) populateEmojis(ctx context.Context, rawEmojis []*gtsmodel.Emoji, requestingUsername string) ([]*gtsmodel.Emoji, error) { + // At this point we should know: + // * the AP uri of the emoji + // * the domain of the emoji + // * the shortcode of the emoji + // * the remote URL of the image + // This should be enough to dereference the emoji + + gotEmojis := make([]*gtsmodel.Emoji, 0, len(rawEmojis)) + + for _, e := range rawEmojis { + var gotEmoji *gtsmodel.Emoji + var err error + + // check if we've already got this emoji in the db + if gotEmoji, err = d.db.GetEmojiByURI(ctx, e.URI); err != nil && err != db.ErrNoEntries { + log.Errorf("populateEmojis: error checking database for emoji %s: %s", e.URI, err) + continue + } + + if gotEmoji == nil { + // it's new! go get it! + newEmojiID, err := id.NewRandomULID() + if err != nil { + log.Errorf("populateEmojis: error generating id for remote emoji %s: %s", e.URI, err) + continue + } + + processingEmoji, err := d.GetRemoteEmoji(ctx, requestingUsername, e.ImageRemoteURL, e.Shortcode, newEmojiID, e.URI, &media.AdditionalEmojiInfo{ + Domain: &e.Domain, + ImageRemoteURL: &e.ImageRemoteURL, + ImageStaticRemoteURL: &e.ImageRemoteURL, + Disabled: e.Disabled, + VisibleInPicker: e.VisibleInPicker, + }) + + if err != nil { + log.Errorf("populateEmojis: couldn't get remote emoji %s: %s", e.URI, err) + continue + } + + if gotEmoji, err = processingEmoji.LoadEmoji(ctx); err != nil { + log.Errorf("populateEmojis: couldn't load remote emoji %s: %s", e.URI, err) + continue + } + } + + // if we get here, we either had the emoji already or we successfully fetched it + gotEmojis = append(gotEmojis, gotEmoji) + } + + return gotEmojis, nil +} diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index 645910d19..bfbc790d8 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -406,58 +406,17 @@ func (d *deref) populateStatusAttachments(ctx context.Context, status *gtsmodel. } func (d *deref) populateStatusEmojis(ctx context.Context, status *gtsmodel.Status, requestingUsername string) error { - // At this point we should know: - // * the AP uri of the emoji - // * the domain of the emoji - // * the shortcode of the emoji - // * the remote URL of the image - // This should be enough to dereference the emoji - - gotEmojis := make([]*gtsmodel.Emoji, 0, len(status.Emojis)) - emojiIDs := make([]string, 0, len(status.Emojis)) - - for _, e := range status.Emojis { - var gotEmoji *gtsmodel.Emoji - var err error - - // check if we've already got this emoji in the db - if gotEmoji, err = d.db.GetEmojiByURI(ctx, e.URI); err != nil && err != db.ErrNoEntries { - log.Errorf("populateStatusEmojis: error checking database for emoji %s: %s", e.URI, err) - continue - } - - if gotEmoji == nil { - // it's new! go get it! - newEmojiID, err := id.NewRandomULID() - if err != nil { - log.Errorf("populateStatusEmojis: error generating id for remote emoji %s: %s", e.URI, err) - continue - } - - processingEmoji, err := d.GetRemoteEmoji(ctx, requestingUsername, e.ImageRemoteURL, e.Shortcode, newEmojiID, e.URI, &media.AdditionalEmojiInfo{ - Domain: &e.Domain, - ImageRemoteURL: &e.ImageRemoteURL, - ImageStaticRemoteURL: &e.ImageRemoteURL, - Disabled: e.Disabled, - VisibleInPicker: e.VisibleInPicker, - }) - if err != nil { - log.Errorf("populateStatusEmojis: couldn't get remote emoji %s: %s", e.URI, err) - continue - } - - if gotEmoji, err = processingEmoji.LoadEmoji(ctx); err != nil { - log.Errorf("populateStatusEmojis: couldn't load remote emoji %s: %s", e.URI, err) - continue - } - } - - // if we get here, we either had the emoji already or we successfully fetched it - gotEmojis = append(gotEmojis, gotEmoji) - emojiIDs = append(emojiIDs, gotEmoji.ID) + emojis, err := d.populateEmojis(ctx, status.Emojis, requestingUsername) + if err != nil { + return err } - status.Emojis = gotEmojis + emojiIDs := make([]string, 0, len(emojis)) + for _, e := range emojis { + emojiIDs = append(emojiIDs, e.ID) + } + + status.Emojis = emojis status.EmojiIDs = emojiIDs return nil } diff --git a/internal/federation/federatingdb/update.go b/internal/federation/federatingdb/update.go index 599544e34..f3a04cbcc 100644 --- a/internal/federation/federatingdb/update.go +++ b/internal/federation/federatingdb/update.go @@ -121,7 +121,7 @@ func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error { return fmt.Errorf("UPDATE: error converting to account: %s", err) } - if updatedAcct.Domain == config.GetHost() { + if updatedAcct.Domain == config.GetHost() || updatedAcct.Domain == config.GetAccountDomain() { // no need to update local accounts // in fact, if we do this will break the shit out of things so do NOT return nil @@ -136,13 +136,8 @@ func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error { updatedAcct.ID = requestingAcct.ID updatedAcct.Language = requestingAcct.Language - // do the update - updatedAcct, err = f.db.UpdateAccount(ctx, updatedAcct) - if err != nil { - return fmt.Errorf("UPDATE: database error inserting updated account: %s", err) - } - - // pass to the processor for further processing of eg., avatar/header + // pass to the processor for further updating of eg., avatar/header, emojis + // the actual db insert/update will take place a bit later f.fedWorker.Queue(messages.FromFederator{ APObjectType: ap.ObjectProfile, APActivityType: ap.ActivityUpdate, diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go index 20405f9ac..ca5c74208 100644 --- a/internal/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -41,6 +41,8 @@ type Account struct { HeaderMediaAttachment *MediaAttachment `validate:"-" bun:"rel:belongs-to"` // MediaAttachment corresponding to headerMediaAttachmentID HeaderRemoteURL string `validate:"omitempty,url" bun:",nullzero"` // For a non-local account, where can the header be fetched? DisplayName string `validate:"-" bun:""` // DisplayName for this account. Can be empty, then just the Username will be used for display purposes. + EmojiIDs []string `validate:"dive,ulid" bun:"emojis,array"` // Database IDs of any emojis used in this account's bio, display name, etc + Emojis []*Emoji `validate:"-" bun:"attached_emojis,m2m:account_to_emojis"` // Emojis corresponding to emojiIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation Fields []Field `validate:"-"` // a key/value map of fields that this account has added to their profile Note string `validate:"-" bun:""` // A note that this account has on their profile (ie., the account's bio/description of themselves) NoteRaw string `validate:"-" bun:""` // The raw contents of .Note without conversion to HTML, only available when requester = target @@ -76,6 +78,14 @@ type Account struct { SuspensionOrigin string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // id of the database entry that caused this account to become suspended -- can be an account ID or a domain block ID } +// AccountToEmoji is an intermediate struct to facilitate the many2many relationship between an account and one or more emojis. +type AccountToEmoji struct { + AccountID string `validate:"ulid,required" bun:"type:CHAR(26),unique:accountemoji,nullzero,notnull"` + Account *Account `validate:"-" bun:"rel:belongs-to"` + EmojiID string `validate:"ulid,required" bun:"type:CHAR(26),unique:accountemoji,nullzero,notnull"` + Emoji *Emoji `validate:"-" bun:"rel:belongs-to"` +} + // Field represents a key value field on an account, for things like pronouns, website, etc. // VerifiedAt is optional, to be used only if Value is a URL to a webpage that contains the // username of the user. diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go index bf7f60d67..3a5a9c622 100644 --- a/internal/processing/account/delete.go +++ b/internal/processing/account/delete.go @@ -259,6 +259,8 @@ func (p *processor) Delete(ctx context.Context, account *gtsmodel.Account, origi account.HeaderMediaAttachmentID = "" account.HeaderRemoteURL = "" account.Reason = "" + account.Emojis = []*gtsmodel.Emoji{} + account.EmojiIDs = []string{} account.Fields = []gtsmodel.Field{} hideCollections := true account.HideCollections = &hideCollections diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index 47c4a2b4b..eddaeab27 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -27,6 +27,7 @@ "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/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -46,11 +47,14 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form account.Bot = form.Bot } + var updateEmojis bool + if form.DisplayName != nil { if err := validate.DisplayName(*form.DisplayName); err != nil { return nil, gtserror.NewErrorBadRequest(err) } account.DisplayName = text.SanitizePlaintext(*form.DisplayName) + updateEmojis = true } if form.Note != nil { @@ -69,6 +73,30 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form // Set updated HTML-ified note account.Note = note + updateEmojis = true + } + + if updateEmojis { + // account emojis -- treat the sanitized display name and raw + // note like one long text for the purposes of deriving emojis + accountEmojiShortcodes := util.DeriveEmojisFromText(account.DisplayName + "\n\n" + account.NoteRaw) + account.Emojis = make([]*gtsmodel.Emoji, 0, len(accountEmojiShortcodes)) + account.EmojiIDs = make([]string, 0, len(accountEmojiShortcodes)) + + for _, shortcode := range accountEmojiShortcodes { + emoji, err := p.db.GetEmojiByShortcodeDomain(ctx, shortcode, "") + if err != nil { + if err != db.ErrNoEntries { + log.Errorf("error getting local emoji with shortcode %s: %s", shortcode, err) + } + continue + } + + if *emoji.VisibleInPicker && !*emoji.Disabled { + account.Emojis = append(account.Emojis, emoji) + account.EmojiIDs = append(account.EmojiIDs, emoji.ID) + } + } } if form.Avatar != nil && form.Avatar.Size != 0 { diff --git a/internal/processing/fromfederator.go b/internal/processing/fromfederator.go index ad8273869..29d996502 100644 --- a/internal/processing/fromfederator.go +++ b/internal/processing/fromfederator.go @@ -369,10 +369,14 @@ func (p *processor) processUpdateAccountFromFederator(ctx context.Context, feder return err } + // further database updates occur inside getremoteaccount if _, err := p.federator.GetRemoteAccount(ctx, dereferencing.GetRemoteAccountParams{ - RequestingUsername: federatorMsg.ReceivingAccount.Username, - RemoteAccountID: incomingAccountURL, - Blocking: true, + RequestingUsername: federatorMsg.ReceivingAccount.Username, + RemoteAccountID: incomingAccountURL, + RemoteAccountHost: incomingAccount.Domain, + RemoteAccountUsername: incomingAccount.Username, + PartialAccount: incomingAccount, + Blocking: true, }); err != nil { return fmt.Errorf("error enriching updated account from federator: %s", err) } diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index b69bb247e..27464809b 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -88,6 +88,13 @@ func (c *converter) ASRepresentationToAccount(ctx context.Context, accountable a acct.DisplayName = displayName } + // account emojis (used in bio, display name, fields) + if emojis, err := ap.ExtractEmojis(accountable); err != nil { + log.Infof("ASRepresentationToAccount: error extracting account emojis: %s", err) + } else { + acct.Emojis = emojis + } + // TODO: fields aka attachment array // note aka summary diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go index 604888050..f56afcd9d 100644 --- a/internal/typeutils/converter_test.go +++ b/internal/typeutils/converter_test.go @@ -473,6 +473,7 @@ type TypeUtilsTestSuite struct { testAccounts map[string]*gtsmodel.Account testStatuses map[string]*gtsmodel.Status testPeople map[string]vocab.ActivityStreamsPerson + testEmojis map[string]*gtsmodel.Emoji typeconverter typeutils.TypeConverter } @@ -485,6 +486,7 @@ func (suite *TypeUtilsTestSuite) SetupSuite() { suite.testAccounts = testrig.NewTestAccounts() suite.testStatuses = testrig.NewTestStatuses() suite.testPeople = testrig.NewTestFediPeople() + suite.testEmojis = testrig.NewTestEmojis() suite.typeconverter = typeutils.NewConverter(suite.db) } diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index a678a970f..6194dba82 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -216,8 +216,33 @@ func (c *converter) AccountToAS(ctx context.Context, a *gtsmodel.Account) (vocab // set the public key property on the Person person.SetW3IDSecurityV1PublicKey(publicKeyProp) - // tag - // TODO: Any tags used in the summary of this profile + // tags + tagProp := streams.NewActivityStreamsTagProperty() + + // tag -- emojis + emojis := a.Emojis + if len(a.EmojiIDs) > len(emojis) { + emojis = []*gtsmodel.Emoji{} + for _, emojiID := range a.EmojiIDs { + emoji, err := c.db.GetEmojiByID(ctx, emojiID) + if err != nil { + return nil, fmt.Errorf("AccountToAS: error getting emoji %s from database: %s", emojiID, err) + } + emojis = append(emojis, emoji) + } + } + for _, emoji := range emojis { + asEmoji, err := c.EmojiToAS(ctx, emoji) + if err != nil { + return nil, fmt.Errorf("AccountToAS: error converting emoji to AS emoji: %s", err) + } + tagProp.AppendTootEmoji(asEmoji) + } + + // tag -- hashtags + // TODO + + person.SetActivityStreamsTag(tagProp) // attachment // Used for profile fields. @@ -477,11 +502,11 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A } } for _, emoji := range emojis { - asMention, err := c.EmojiToAS(ctx, emoji) + asEmoji, err := c.EmojiToAS(ctx, emoji) if err != nil { return nil, fmt.Errorf("StatusToAS: error converting emoji to AS emoji: %s", err) } - tagProp.AppendTootEmoji(asMention) + tagProp.AppendTootEmoji(asEmoji) } // tag -- hashtags diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index 83e235113..f2845be02 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -26,6 +26,7 @@ "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -34,7 +35,8 @@ type InternalToASTestSuite struct { } func (suite *InternalToASTestSuite) TestAccountToAS() { - testAccount := suite.testAccounts["local_account_1"] // take zork for this test + testAccount := >smodel.Account{} + *testAccount = *suite.testAccounts["local_account_1"] // take zork for this test asPerson, err := suite.typeconverter.AccountToAS(context.Background(), testAccount) suite.NoError(err) @@ -49,11 +51,33 @@ func (suite *InternalToASTestSuite) TestAccountToAS() { // this is necessary because the order of multiple 'context' entries is not determinate trimmed := strings.Split(string(bytes), "\"discoverable\"")[1] - suite.Equal(`:true,"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed) + suite.Equal(`:true,"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","tag":[],"type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed) +} + +func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() { + testAccount := >smodel.Account{} + *testAccount = *suite.testAccounts["local_account_1"] // take zork for this test + testAccount.Emojis = []*gtsmodel.Emoji{suite.testEmojis["rainbow"]} + + asPerson, err := suite.typeconverter.AccountToAS(context.Background(), testAccount) + suite.NoError(err) + + ser, err := streams.Serialize(asPerson) + suite.NoError(err) + + bytes, err := json.Marshal(ser) + suite.NoError(err) + + // trim off everything up to 'discoverable'; + // this is necessary because the order of multiple 'context' entries is not determinate + trimmed := strings.Split(string(bytes), "\"discoverable\"")[1] + + suite.Equal(`:true,"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","tag":{"icon":{"mediaType":"image/png","type":"Image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"},"id":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ","name":":rainbow:","type":"Emoji"},"type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed) } func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { - testAccount := suite.testAccounts["local_account_1"] // take zork for this test + testAccount := >smodel.Account{} + *testAccount = *suite.testAccounts["local_account_1"] // take zork for this test sharedInbox := "http://localhost:8080/sharedInbox" testAccount.SharedInboxURI = &sharedInbox @@ -70,7 +94,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { // this is necessary because the order of multiple 'context' entries is not determinate trimmed := strings.Split(string(bytes), "\"discoverable\"")[1] - suite.Equal(`:true,"endpoints":{"sharedInbox":"http://localhost:8080/sharedInbox"},"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed) + suite.Equal(`:true,"endpoints":{"sharedInbox":"http://localhost:8080/sharedInbox"},"featured":"http://localhost:8080/users/the_mighty_zork/collections/featured","followers":"http://localhost:8080/users/the_mighty_zork/followers","following":"http://localhost:8080/users/the_mighty_zork/following","icon":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg"},"id":"http://localhost:8080/users/the_mighty_zork","image":{"mediaType":"image/jpeg","type":"Image","url":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg"},"inbox":"http://localhost:8080/users/the_mighty_zork/inbox","manuallyApprovesFollowers":false,"name":"original zork (he/they)","outbox":"http://localhost:8080/users/the_mighty_zork/outbox","preferredUsername":"the_mighty_zork","publicKey":{"id":"http://localhost:8080/users/the_mighty_zork/main-key","owner":"http://localhost:8080/users/the_mighty_zork","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n"},"summary":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","tag":[],"type":"Person","url":"http://localhost:8080/@the_mighty_zork"}`, trimmed) } func (suite *InternalToASTestSuite) TestOutboxToASCollection() { diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 2f21f2d19..ca86a1284 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -159,8 +159,29 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A fields = append(fields, mField) } + // account emojis emojis := []model.Emoji{} - // TODO: account emojis + gtsEmojis := a.Emojis + if len(a.EmojiIDs) > len(gtsEmojis) { + gtsEmojis = []*gtsmodel.Emoji{} + for _, emojiID := range a.EmojiIDs { + emoji, err := c.db.GetEmojiByID(ctx, emojiID) + if err != nil { + return nil, fmt.Errorf("AccountToAPIAccountPublic: error getting emoji %s from database: %s", emojiID, err) + } + gtsEmojis = append(gtsEmojis, emoji) + } + } + for _, emoji := range gtsEmojis { + if *emoji.Disabled { + continue + } + apiEmoji, err := c.EmojiToAPIEmoji(ctx, emoji) + if err != nil { + return nil, fmt.Errorf("AccountToAPIAccountPublic: error converting emoji to api emoji: %s", err) + } + emojis = append(emojis, apiEmoji) + } var acct string if a.Domain != "" { @@ -194,7 +215,7 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A FollowingCount: followingCount, StatusesCount: statusesCount, LastStatusAt: lastStatusAt, - Emojis: emojis, // TODO: implement this + Emojis: emojis, Fields: fields, Suspended: suspended, CustomCSS: a.CustomCSS, diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index dc92260e1..6028344b4 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -43,6 +43,36 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontend() { suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[],"fields":[]}`, string(b)) } +func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() { + testAccount := suite.testAccounts["local_account_1"] // take zork for this test + testEmoji := suite.testEmojis["rainbow"] + + testAccount.Emojis = []*gtsmodel.Emoji{testEmoji} + + apiAccount, err := suite.typeconverter.AccountToAPIAccountPublic(context.Background(), testAccount) + suite.NoError(err) + suite.NotNil(apiAccount) + + b, err := json.Marshal(apiAccount) + suite.NoError(err) + suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"fields":[]}`, string(b)) +} + +func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiIDs() { + testAccount := suite.testAccounts["local_account_1"] // take zork for this test + testEmoji := suite.testEmojis["rainbow"] + + testAccount.EmojiIDs = []string{testEmoji.ID} + + apiAccount, err := suite.typeconverter.AccountToAPIAccountPublic(context.Background(), testAccount) + suite.NoError(err) + suite.NotNil(apiAccount) + + b, err := json.Marshal(apiAccount) + suite.NoError(err) + suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"fields":[]}`, string(b)) +} + func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() { testAccount := suite.testAccounts["local_account_1"] // take zork for this test apiAccount, err := suite.typeconverter.AccountToAPIAccountSensitive(context.Background(), testAccount) diff --git a/testrig/db.go b/testrig/db.go index ae3132835..72446e2bc 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -32,6 +32,7 @@ var testModels = []interface{}{ >smodel.Account{}, + >smodel.AccountToEmoji{}, >smodel.Application{}, >smodel.Block{}, >smodel.DomainBlock{}, diff --git a/testrig/media/kip-original.gif b/testrig/media/kip-original.gif new file mode 100644 index 0000000000000000000000000000000000000000..6e83746f620727d938d982df640a72301f3c8bf7 GIT binary patch literal 1428 zcmbu-`CAfp0LSq!mmC`4MJ8C{s6f%s9?{TxMDQwc9;j)~G|xQHvJ5c`6;0W2q-h>m zN|U^xwx*9;Iu(znJu(j-rPZT&%sneQ?9h&9Pd3|suxFp2-ameRcm)Lcdmc#K16+Xw z57yS!GLw?@l1vth)fVepc$lS9sq}jNHAx{Jg&Lp%S3JJQ6;(*X>j!$0e0@Lt$B{po zhWPO#IP6eAHx~vB0stU?fTJ-01L#`~KHmU{%@zl#R!2#1G-J0Yr@SWLd{7H4NL3@m zQhx4Oi!AGQfE{3@Zz? zdYB>S2D&?OB+OA^s2`)U4Nnp`=SzpNW6=0DP5(^WG{2ct7$teS*V(*j|5;%drMl}8*RJYL>k8?9O2XWJ=C9-M ztYOD00})$&(5K0lVP8|!Z+_iFrYrs#Oaq%goV3se>dt$$<>EytQhp^4CZAgh?`%8{ zo~{SX!;&xOTnoJ4H_JWVO)$*r+{nHz^YmX(`KHx(!^$>ZLKiA(bIEMu1zld$g4gzb zY<-*!kuWd5d*bD3@lj*)vBi;j?(mZNN?-P3ACK*9)|i9VXKt**J$e(xyGOo;w5eW7 zAX(@NN=D{+A-;e^YGK3EDx#k^G(IV-K?d>B{2di+70Np*53P5~2U^ zBzOjcdf~zr#1fV(t_&^9N)~@nN<}4aF%+OpK$8iQ=Ms;ha;QkfXNf8v3@L-~3S>a! z;DJ+YNxU9|yB#}NfR4XK+mcw&vn8<$nZG4b1L7)`PP85rdq%~$s)$SE8SlZ2f+loV zg6072`g7&zgz-s2{xz;z03mgc9+8i=joloMobMf)iWOSLcI zMJu#wV)G1l#or=Yoo=zK!?PdpYn&t;%qApR2 zk~*}U_5?-PbAI_Mv^(KN%kOoFxd+sx$3x@?tJO87o^*GTXEekxMa6dPz{wh>Lb0^-CEJLjHRI>HLx4u9<8wf2X$(CyCurQs(I zM8xpnmLgfHjgmyyXME@_rk34Y=&LLlzByKTqqdf59B+WXow+n)Io(Q_NKqu89XE4R z`XKQ!YPOqL)ZURBgjM!KLo=(|r%ktSn~aR}5gwi4gT1tk5-NxCqh1;&Q3B^B_l>}> zGR&)eq{vo;o_|ajIDOG<-C>p5-uHg=qN7$*OWd}e%gMuJ2O?wB=m;y!cb8MeqNvzn M?Zq{3JQR5S1FpYs8~^|S literal 0 HcmV?d00001 diff --git a/testrig/media/kip-static.png b/testrig/media/kip-static.png new file mode 100644 index 0000000000000000000000000000000000000000..1ba29668789698dc2c6b06b83fd668086f0b48ca GIT binary patch literal 802 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 B$A$m^ literal 0 HcmV?d00001 diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 98b23721e..f53022fd8 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -952,6 +952,28 @@ func NewTestEmojis() map[string]*gtsmodel.Emoji { VisibleInPicker: TrueBool(), CategoryID: "", }, + "yell": { + ID: "01GD5KP5CQEE1R3X43Y1EHS2CW", + Shortcode: "yell", + Domain: "fossbros-anonymous.io", + CreatedAt: TimeMustParse("2020-03-18T13:12:00+01:00"), + UpdatedAt: TimeMustParse("2020-03-18T13:12:00+01:00"), + ImageRemoteURL: "http://fossbros-anonymous.io/emoji/yell.gif", + ImageStaticRemoteURL: "", + ImageURL: "http://localhost:8080/fileserver/01GD5KR15NHTY8FZ01CD4D08XP/emoji/original/01GD5KP5CQEE1R3X43Y1EHS2CW.png", + ImagePath: "/tmp/gotosocial/01GD5KR15NHTY8FZ01CD4D08XP/emoji/original/01GD5KP5CQEE1R3X43Y1EHS2CW.png", + ImageStaticURL: "http://localhost:8080/fileserver/01GD5KR15NHTY8FZ01CD4D08XP/emoji/static/01GD5KP5CQEE1R3X43Y1EHS2CW.png", + ImageStaticPath: "/tmp/gotosocial/01GD5KR15NHTY8FZ01CD4D08XP/emoji/static/01GD5KP5CQEE1R3X43Y1EHS2CW.png", + ImageContentType: "image/png", + ImageStaticContentType: "image/png", + ImageFileSize: 10889, + ImageStaticFileSize: 10808, + ImageUpdatedAt: TimeMustParse("2020-03-18T13:12:00+01:00"), + Disabled: FalseBool(), + URI: "http://fossbros-anonymous.io/emoji/01GD5KP5CQEE1R3X43Y1EHS2CW", + VisibleInPicker: FalseBool(), + CategoryID: "", + }, } } @@ -1045,6 +1067,10 @@ func newTestStoredEmoji() map[string]filenames { Original: "rainbow-original.png", Static: "rainbow-static.png", }, + "yell": { + Original: "yell-original.png", + Static: "yell-static.png", + }, } } @@ -1941,6 +1967,22 @@ func NewTestFediServices() map[string]vocab.ActivityStreamsService { } } +func NewTestFediEmojis() map[string]vocab.TootEmoji { + return map[string]vocab.TootEmoji{ + "http://fossbros-anonymous.io/emoji/01GD5HCC2YECT012TK8PAGX4D1": newAPEmoji( + URLMustParse("http://fossbros-anonymous.io/emoji/01GD5HCC2YECT012TK8PAGX4D1"), + "kip_van_den_bos", + TimeMustParse("2022-09-13T12:13:12+02:00"), + newAPImage( + URLMustParse("http://fossbros-anonymous.io/emoji/kip.gif"), + "image/gif", + "", + "", + ), + ), + } +} + // RemoteAttachmentFile mimics a remote (federated) attachment type RemoteAttachmentFile struct { Data []byte @@ -1968,6 +2010,16 @@ func NewTestFediAttachments(relativePath string) map[string]RemoteAttachmentFile panic(err) } + kipBytes, err := os.ReadFile(fmt.Sprintf("%s/kip-original.gif", relativePath)) + if err != nil { + panic(err) + } + + yellBytes, err := os.ReadFile(fmt.Sprintf("%s/yell-original.png", relativePath)) + if err != nil { + panic(err) + } + return map[string]RemoteAttachmentFile{ "https://s3-us-west-2.amazonaws.com/plushcity/media_attachments/files/106/867/380/219/163/828/original/88e8758c5f011439.jpg": { Data: beeBytes, @@ -1985,6 +2037,14 @@ func NewTestFediAttachments(relativePath string) map[string]RemoteAttachmentFile Data: peglinBytes, ContentType: "image/gif", }, + "http://fossbros-anonymous.io/emoji/kip.gif": { + Data: kipBytes, + ContentType: "image/gif", + }, + "http://fossbros-anonymous.io/emoji/yell.gif": { + Data: yellBytes, + ContentType: "image/png", + }, } } @@ -2857,6 +2917,28 @@ func newAPImage(url *url.URL, mediaType string, imageDescription string, blurhas return image } +func newAPEmoji(id *url.URL, name string, updated time.Time, image vocab.ActivityStreamsImage) vocab.TootEmoji { + emoji := streams.NewTootEmoji() + + idProp := streams.NewJSONLDIdProperty() + idProp.SetIRI(id) + emoji.SetJSONLDId(idProp) + + nameProp := streams.NewActivityStreamsNameProperty() + nameProp.AppendXMLSchemaString(`:` + strings.Trim(name, ":") + `:`) + emoji.SetActivityStreamsName(nameProp) + + updatedProp := streams.NewActivityStreamsUpdatedProperty() + updatedProp.Set(updated) + emoji.SetActivityStreamsUpdated(updatedProp) + + iconProp := streams.NewActivityStreamsIconProperty() + iconProp.AppendActivityStreamsImage(image) + emoji.SetActivityStreamsIcon(iconProp) + + return emoji +} + // NewAPNote returns a new activity streams note for the given parameters func NewAPNote( noteID *url.URL, diff --git a/testrig/transportcontroller.go b/testrig/transportcontroller.go index 68f03398d..70f2f0c61 100644 --- a/testrig/transportcontroller.go +++ b/testrig/transportcontroller.go @@ -64,6 +64,7 @@ type MockHTTPClient struct { testRemoteGroups map[string]vocab.ActivityStreamsGroup testRemoteServices map[string]vocab.ActivityStreamsService testRemoteAttachments map[string]RemoteAttachmentFile + testRemoteEmojis map[string]vocab.TootEmoji SentMessages sync.Map } @@ -90,6 +91,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat mockHTTPClient.testRemoteGroups = NewTestFediGroups() mockHTTPClient.testRemoteServices = NewTestFediServices() mockHTTPClient.testRemoteAttachments = NewTestFediAttachments(relativeMediaPath) + mockHTTPClient.testRemoteEmojis = NewTestFediEmojis() mockHTTPClient.do = func(req *http.Request) (*http.Response, error) { responseCode := http.StatusNotFound @@ -173,6 +175,19 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat responseBytes = serviceJSON responseContentType = applicationActivityJSON responseContentLength = len(serviceJSON) + } else if emoji, ok := mockHTTPClient.testRemoteEmojis[req.URL.String()]; ok { + emojiI, err := streams.Serialize(emoji) + if err != nil { + panic(err) + } + emojiJSON, err := json.Marshal(emojiI) + if err != nil { + panic(err) + } + responseCode = http.StatusOK + responseBytes = emojiJSON + responseContentType = applicationActivityJSON + responseContentLength = len(emojiJSON) } else if attachment, ok := mockHTTPClient.testRemoteAttachments[req.URL.String()]; ok { responseCode = http.StatusOK responseBytes = attachment.Data diff --git a/web/template/profile.tmpl b/web/template/profile.tmpl index 22f192c06..9838e5b30 100644 --- a/web/template/profile.tmpl +++ b/web/template/profile.tmpl @@ -12,12 +12,12 @@
{{if .account.DisplayName}}{{.account.DisplayName}}{{else}}{{.account.Username}}{{end}}'s avatar -
{{if .account.DisplayName}}{{.account.DisplayName}}{{else}}{{.account.Username}}{{end}}
+
{{if .account.DisplayName}}{{emojify .account.Emojis (escape .account.DisplayName)}}{{else}}{{.account.Username}}{{end}}
@{{.account.Username}}@{{.instance.AccountDomain}}
- {{ if .account.Note }}{{ .account.Note | noescape }}{{else}}This GoToSocial user hasn't written a bio yet!{{end}} + {{ if .account.Note }}{{emojify .account.Emojis (noescape .account.Note)}}{{else}}This GoToSocial user hasn't written a bio yet!{{end}}
diff --git a/web/template/status.tmpl b/web/template/status.tmpl index 16f724a94..c3b243445 100644 --- a/web/template/status.tmpl +++ b/web/template/status.tmpl @@ -1,6 +1,6 @@