From 22ac4607a1c283a719eea95844e07513b8a67570 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Thu, 27 Jul 2023 01:30:39 -0700 Subject: [PATCH] [feature] Support setting private notes on accounts (#1982) * Support setting private notes on accounts * Reformat comment whitespace * Add missing license headers * Use apiutil.ParseID * Rename Note model and cache to AccountNote * Update golden cache config in test/envparsing.sh * Rename gtsmodel/note.go to gtsmodel/accountnote.go * Update AccountNote uniqueness constraint name Now has same prefix as other indexes on this table. --------- Co-authored-by: tobi <31960611+tsmethurst@users.noreply.github.com> --- docs/api/swagger.yaml | 39 +++++++ internal/api/client/accounts/accounts.go | 4 + internal/api/client/accounts/note.go | 108 ++++++++++++++++++ internal/api/model/account.go | 8 ++ internal/cache/gts.go | 26 ++++- internal/config/config.go | 4 + internal/config/defaults.go | 4 + internal/config/helpers.gen.go | 75 ++++++++++++ internal/db/bundb/bundb_test.go | 2 + .../20230711214815_account_notes.go | 62 ++++++++++ internal/db/bundb/relationship.go | 13 +++ internal/db/bundb/relationship_note.go | 99 ++++++++++++++++ internal/db/bundb/relationship_test.go | 47 ++++++++ internal/db/relationship.go | 6 + internal/gtsmodel/accountnote.go | 32 ++++++ internal/processing/account/note.go | 48 ++++++++ test/envparsing.sh | 3 + testrig/db.go | 7 ++ testrig/testmodels.go | 12 ++ 19 files changed, 597 insertions(+), 2 deletions(-) create mode 100644 internal/api/client/accounts/note.go create mode 100644 internal/db/bundb/migrations/20230711214815_account_notes.go create mode 100644 internal/db/bundb/relationship_note.go create mode 100644 internal/gtsmodel/accountnote.go create mode 100644 internal/processing/account/note.go diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index eb9ec82ee..db1d136b8 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -2944,6 +2944,45 @@ paths: summary: See all lists of yours that contain requested account. tags: - accounts + /api/v1/accounts/{id}/note: + post: + consumes: + - multipart/form-data + operationId: accountNote + parameters: + - description: The id of the account for which to set a note. + in: path + name: id + required: true + type: string + - default: "" + description: The text of the note. Omit this parameter or send an empty string to clear the note. + in: formData + name: comment + type: string + produces: + - application/json + responses: + "200": + description: Your relationship to the account. + schema: + $ref: '#/definitions/accountRelationship' + "400": + description: bad request + "401": + description: unauthorized + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - write:accounts + summary: Set a private note for an account with the given id. + tags: + - accounts /api/v1/accounts/{id}/statuses: get: description: The statuses will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). diff --git a/internal/api/client/accounts/accounts.go b/internal/api/client/accounts/accounts.go index 9bb13231d..d57748d46 100644 --- a/internal/api/client/accounts/accounts.go +++ b/internal/api/client/accounts/accounts.go @@ -45,6 +45,7 @@ const ( FollowPath = BasePathWithID + "/follow" ListsPath = BasePathWithID + "/lists" LookupPath = BasePath + "/lookup" + NotePath = BasePathWithID + "/note" RelationshipsPath = BasePath + "/relationships" SearchPath = BasePath + "/search" StatusesPath = BasePathWithID + "/statuses" @@ -101,6 +102,9 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H // account lists attachHandler(http.MethodGet, ListsPath, m.AccountListsGETHandler) + // account note + attachHandler(http.MethodPost, NotePath, m.AccountNotePOSTHandler) + // search for accounts attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler) attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler) diff --git a/internal/api/client/accounts/note.go b/internal/api/client/accounts/note.go new file mode 100644 index 000000000..9a0667875 --- /dev/null +++ b/internal/api/client/accounts/note.go @@ -0,0 +1,108 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package accounts + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountNotePOSTHandler swagger:operation POST /api/v1/accounts/{id}/note accountNote +// +// Set a private note for an account with the given id. +// +// --- +// tags: +// - accounts +// +// consumes: +// - multipart/form-data +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the account for which to set a note. +// in: path +// required: true +// - +// name: comment +// type: string +// description: The text of the note. Omit this parameter or send an empty string to clear the note. +// in: formData +// default: "" +// +// security: +// - OAuth2 Bearer: +// - write:accounts +// +// responses: +// '200': +// description: Your relationship to the account. +// schema: +// "$ref": "#/definitions/accountRelationship" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountNotePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + targetAcctID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + form := &apimodel.AccountNoteRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + relationship, errWithCode := m.processor.Account().PutNote(c.Request.Context(), authed.Account, targetAcctID, form.Comment) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, relationship) +} diff --git a/internal/api/model/account.go b/internal/api/model/account.go index 31615d26b..a8851ddcb 100644 --- a/internal/api/model/account.go +++ b/internal/api/model/account.go @@ -231,3 +231,11 @@ const ( AccountRoleAdmin AccountRoleName = "admin" // Instance admin AccountRoleUnknown AccountRoleName = "" // We don't know / remote account ) + +// AccountNoteRequest models a request to update the private note for an account. +// +// swagger:ignore +type AccountNoteRequest struct { + // Comment to use for the note text. + Comment string `form:"comment" json:"comment" xml:"comment"` +} diff --git a/internal/cache/gts.go b/internal/cache/gts.go index 8082a9fdf..81c6e9f9e 100644 --- a/internal/cache/gts.go +++ b/internal/cache/gts.go @@ -26,8 +26,9 @@ import ( ) type GTSCaches struct { - account *result.Cache[*gtsmodel.Account] - block *result.Cache[*gtsmodel.Block] + account *result.Cache[*gtsmodel.Account] + accountNote *result.Cache[*gtsmodel.AccountNote] + block *result.Cache[*gtsmodel.Block] // TODO: maybe should be moved out of here since it's // not actually doing anything with gtsmodel.DomainBlock. domainBlock *domain.BlockCache @@ -54,6 +55,7 @@ type GTSCaches struct { // NOTE: the cache MUST NOT be in use anywhere, this is not thread-safe. func (c *GTSCaches) Init() { c.initAccount() + c.initAccountNote() c.initBlock() c.initDomainBlock() c.initEmoji() @@ -77,6 +79,7 @@ func (c *GTSCaches) Init() { // Start will attempt to start all of the gtsmodel caches, or panic. func (c *GTSCaches) Start() { tryStart(c.account, config.GetCacheGTSAccountSweepFreq()) + tryStart(c.accountNote, config.GetCacheGTSAccountNoteSweepFreq()) tryStart(c.block, config.GetCacheGTSBlockSweepFreq()) tryStart(c.emoji, config.GetCacheGTSEmojiSweepFreq()) tryStart(c.emojiCategory, config.GetCacheGTSEmojiCategorySweepFreq()) @@ -104,6 +107,7 @@ func (c *GTSCaches) Start() { // Stop will attempt to stop all of the gtsmodel caches, or panic. func (c *GTSCaches) Stop() { tryStop(c.account, config.GetCacheGTSAccountSweepFreq()) + tryStop(c.accountNote, config.GetCacheGTSAccountNoteSweepFreq()) tryStop(c.block, config.GetCacheGTSBlockSweepFreq()) tryStop(c.emoji, config.GetCacheGTSEmojiSweepFreq()) tryStop(c.emojiCategory, config.GetCacheGTSEmojiCategorySweepFreq()) @@ -128,6 +132,11 @@ func (c *GTSCaches) Account() *result.Cache[*gtsmodel.Account] { return c.account } +// AccountNote provides access to the gtsmodel Note database cache. +func (c *GTSCaches) AccountNote() *result.Cache[*gtsmodel.AccountNote] { + return c.accountNote +} + // Block provides access to the gtsmodel Block (account) database cache. func (c *GTSCaches) Block() *result.Cache[*gtsmodel.Block] { return c.block @@ -238,6 +247,19 @@ func (c *GTSCaches) initAccount() { c.account.IgnoreErrors(ignoreErrors) } +func (c *GTSCaches) initAccountNote() { + c.accountNote = result.New([]result.Lookup{ + {Name: "ID"}, + {Name: "AccountID.TargetAccountID"}, + }, func(n1 *gtsmodel.AccountNote) *gtsmodel.AccountNote { + n2 := new(gtsmodel.AccountNote) + *n2 = *n1 + return n2 + }, config.GetCacheGTSAccountNoteMaxSize()) + c.accountNote.SetTTL(config.GetCacheGTSAccountNoteTTL(), true) + c.accountNote.IgnoreErrors(ignoreErrors) +} + func (c *GTSCaches) initBlock() { c.block = result.New([]result.Lookup{ {Name: "ID"}, diff --git a/internal/config/config.go b/internal/config/config.go index cb158cdad..a5b843e3c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -186,6 +186,10 @@ type GTSCacheConfiguration struct { AccountTTL time.Duration `name:"account-ttl"` AccountSweepFreq time.Duration `name:"account-sweep-freq"` + AccountNoteMaxSize int `name:"account-note-max-size"` + AccountNoteTTL time.Duration `name:"account-note-ttl"` + AccountNoteSweepFreq time.Duration `name:"account-note-sweep-freq"` + BlockMaxSize int `name:"block-max-size"` BlockTTL time.Duration `name:"block-ttl"` BlockSweepFreq time.Duration `name:"block-sweep-freq"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index e3f53adaf..d48eb2598 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -131,6 +131,10 @@ var Defaults = Configuration{ AccountTTL: time.Minute * 30, AccountSweepFreq: time.Minute, + AccountNoteMaxSize: 1000, + AccountNoteTTL: time.Minute * 30, + AccountNoteSweepFreq: time.Minute, + BlockMaxSize: 1000, BlockTTL: time.Minute * 30, BlockSweepFreq: time.Minute, diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index ceb115bba..a3a394b2d 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -2474,6 +2474,81 @@ func GetCacheGTSAccountSweepFreq() time.Duration { return global.GetCacheGTSAcco // SetCacheGTSAccountSweepFreq safely sets the value for global configuration 'Cache.GTS.AccountSweepFreq' field func SetCacheGTSAccountSweepFreq(v time.Duration) { global.SetCacheGTSAccountSweepFreq(v) } +// GetCacheGTSAccountNoteMaxSize safely fetches the Configuration value for state's 'Cache.GTS.AccountNoteMaxSize' field +func (st *ConfigState) GetCacheGTSAccountNoteMaxSize() (v int) { + st.mutex.RLock() + v = st.config.Cache.GTS.AccountNoteMaxSize + st.mutex.RUnlock() + return +} + +// SetCacheGTSAccountNoteMaxSize safely sets the Configuration value for state's 'Cache.GTS.AccountNoteMaxSize' field +func (st *ConfigState) SetCacheGTSAccountNoteMaxSize(v int) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.GTS.AccountNoteMaxSize = v + st.reloadToViper() +} + +// CacheGTSAccountNoteMaxSizeFlag returns the flag name for the 'Cache.GTS.AccountNoteMaxSize' field +func CacheGTSAccountNoteMaxSizeFlag() string { return "cache-gts-account-note-max-size" } + +// GetCacheGTSAccountNoteMaxSize safely fetches the value for global configuration 'Cache.GTS.AccountNoteMaxSize' field +func GetCacheGTSAccountNoteMaxSize() int { return global.GetCacheGTSAccountNoteMaxSize() } + +// SetCacheGTSAccountNoteMaxSize safely sets the value for global configuration 'Cache.GTS.AccountNoteMaxSize' field +func SetCacheGTSAccountNoteMaxSize(v int) { global.SetCacheGTSAccountNoteMaxSize(v) } + +// GetCacheGTSAccountNoteTTL safely fetches the Configuration value for state's 'Cache.GTS.AccountNoteTTL' field +func (st *ConfigState) GetCacheGTSAccountNoteTTL() (v time.Duration) { + st.mutex.RLock() + v = st.config.Cache.GTS.AccountNoteTTL + st.mutex.RUnlock() + return +} + +// SetCacheGTSAccountNoteTTL safely sets the Configuration value for state's 'Cache.GTS.AccountNoteTTL' field +func (st *ConfigState) SetCacheGTSAccountNoteTTL(v time.Duration) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.GTS.AccountNoteTTL = v + st.reloadToViper() +} + +// CacheGTSAccountNoteTTLFlag returns the flag name for the 'Cache.GTS.AccountNoteTTL' field +func CacheGTSAccountNoteTTLFlag() string { return "cache-gts-account-note-ttl" } + +// GetCacheGTSAccountNoteTTL safely fetches the value for global configuration 'Cache.GTS.AccountNoteTTL' field +func GetCacheGTSAccountNoteTTL() time.Duration { return global.GetCacheGTSAccountNoteTTL() } + +// SetCacheGTSAccountNoteTTL safely sets the value for global configuration 'Cache.GTS.AccountNoteTTL' field +func SetCacheGTSAccountNoteTTL(v time.Duration) { global.SetCacheGTSAccountNoteTTL(v) } + +// GetCacheGTSAccountNoteSweepFreq safely fetches the Configuration value for state's 'Cache.GTS.AccountNoteSweepFreq' field +func (st *ConfigState) GetCacheGTSAccountNoteSweepFreq() (v time.Duration) { + st.mutex.RLock() + v = st.config.Cache.GTS.AccountNoteSweepFreq + st.mutex.RUnlock() + return +} + +// SetCacheGTSAccountNoteSweepFreq safely sets the Configuration value for state's 'Cache.GTS.AccountNoteSweepFreq' field +func (st *ConfigState) SetCacheGTSAccountNoteSweepFreq(v time.Duration) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.GTS.AccountNoteSweepFreq = v + st.reloadToViper() +} + +// CacheGTSAccountNoteSweepFreqFlag returns the flag name for the 'Cache.GTS.AccountNoteSweepFreq' field +func CacheGTSAccountNoteSweepFreqFlag() string { return "cache-gts-account-note-sweep-freq" } + +// GetCacheGTSAccountNoteSweepFreq safely fetches the value for global configuration 'Cache.GTS.AccountNoteSweepFreq' field +func GetCacheGTSAccountNoteSweepFreq() time.Duration { return global.GetCacheGTSAccountNoteSweepFreq() } + +// SetCacheGTSAccountNoteSweepFreq safely sets the value for global configuration 'Cache.GTS.AccountNoteSweepFreq' field +func SetCacheGTSAccountNoteSweepFreq(v time.Duration) { global.SetCacheGTSAccountNoteSweepFreq(v) } + // GetCacheGTSBlockMaxSize safely fetches the Configuration value for state's 'Cache.GTS.BlockMaxSize' field func (st *ConfigState) GetCacheGTSBlockMaxSize() (v int) { st.mutex.RLock() diff --git a/internal/db/bundb/bundb_test.go b/internal/db/bundb/bundb_test.go index 84e11447a..d54578795 100644 --- a/internal/db/bundb/bundb_test.go +++ b/internal/db/bundb/bundb_test.go @@ -49,6 +49,7 @@ type BunDBStandardTestSuite struct { testFaves map[string]*gtsmodel.StatusFave testLists map[string]*gtsmodel.List testListEntries map[string]*gtsmodel.ListEntry + testAccountNotes map[string]*gtsmodel.AccountNote } func (suite *BunDBStandardTestSuite) SetupSuite() { @@ -68,6 +69,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() { suite.testFaves = testrig.NewTestFaves() suite.testLists = testrig.NewTestLists() suite.testListEntries = testrig.NewTestListEntries() + suite.testAccountNotes = testrig.NewTestAccountNotes() } func (suite *BunDBStandardTestSuite) SetupTest() { diff --git a/internal/db/bundb/migrations/20230711214815_account_notes.go b/internal/db/bundb/migrations/20230711214815_account_notes.go new file mode 100644 index 000000000..49d60745b --- /dev/null +++ b/internal/db/bundb/migrations/20230711214815_account_notes.go @@ -0,0 +1,62 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migrations + +import ( + "context" + + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + // Account note table. + if _, err := tx. + NewCreateTable(). + Model(>smodel.AccountNote{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + // Add IDs index to the account note table. + if _, err := tx. + NewCreateIndex(). + Model(>smodel.AccountNote{}). + Index("account_notes_account_id_target_account_id_idx"). + Column("account_id", "target_account_id"). + 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/db/bundb/relationship.go b/internal/db/bundb/relationship.go index c865f8aad..eddd73b49 100644 --- a/internal/db/bundb/relationship.go +++ b/internal/db/bundb/relationship.go @@ -85,6 +85,19 @@ func (r *relationshipDB) GetRelationship(ctx context.Context, requestingAccount return nil, fmt.Errorf("GetRelationship: error checking blockedBy: %w", err) } + // retrieve a note by the requesting account on the target account, if there is one + note, err := r.GetNote( + gtscontext.SetBarebones(ctx), + requestingAccount, + targetAccount, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, fmt.Errorf("GetRelationship: error fetching note: %w", err) + } + if note != nil { + rel.Note = note.Comment + } + return &rel, nil } diff --git a/internal/db/bundb/relationship_note.go b/internal/db/bundb/relationship_note.go new file mode 100644 index 000000000..97e740bcd --- /dev/null +++ b/internal/db/bundb/relationship_note.go @@ -0,0 +1,99 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package bundb + +import ( + "context" + "fmt" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func (r *relationshipDB) GetNote(ctx context.Context, sourceAccountID string, targetAccountID string) (*gtsmodel.AccountNote, error) { + return r.getNote( + ctx, + "AccountID.TargetAccountID", + func(note *gtsmodel.AccountNote) error { + return r.conn.NewSelect().Model(note). + Where("? = ?", bun.Ident("account_id"), sourceAccountID). + Where("? = ?", bun.Ident("target_account_id"), targetAccountID). + Scan(ctx) + }, + sourceAccountID, + targetAccountID, + ) +} + +func (r *relationshipDB) getNote(ctx context.Context, lookup string, dbQuery func(*gtsmodel.AccountNote) error, keyParts ...any) (*gtsmodel.AccountNote, error) { + // Fetch note from cache with loader callback + note, err := r.state.Caches.GTS.AccountNote().Load(lookup, func() (*gtsmodel.AccountNote, error) { + var note gtsmodel.AccountNote + + // Not cached! Perform database query + if err := dbQuery(¬e); err != nil { + return nil, r.conn.ProcessError(err) + } + + return ¬e, nil + }, keyParts...) + if err != nil { + // already processed + return nil, err + } + + if gtscontext.Barebones(ctx) { + // Only a barebones model was requested. + return note, nil + } + + // Set the note source account + note.Account, err = r.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + note.AccountID, + ) + if err != nil { + return nil, fmt.Errorf("error getting note source account: %w", err) + } + + // Set the note target account + note.TargetAccount, err = r.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + note.TargetAccountID, + ) + if err != nil { + return nil, fmt.Errorf("error getting note target account: %w", err) + } + + return note, nil +} + +func (r *relationshipDB) PutNote(ctx context.Context, note *gtsmodel.AccountNote) error { + note.UpdatedAt = time.Now() + return r.state.Caches.GTS.AccountNote().Store(note, func() error { + _, err := r.conn. + NewInsert(). + Model(note). + On("CONFLICT (?, ?) DO UPDATE", bun.Ident("account_id"), bun.Ident("target_account_id")). + Set("? = ?, ? = ?", bun.Ident("updated_at"), note.UpdatedAt, bun.Ident("comment"), note.Comment). + Exec(ctx) + return r.conn.ProcessError(err) + }) +} diff --git a/internal/db/bundb/relationship_test.go b/internal/db/bundb/relationship_test.go index d3f4a31d1..cf2df5144 100644 --- a/internal/db/bundb/relationship_test.go +++ b/internal/db/bundb/relationship_test.go @@ -912,6 +912,53 @@ func (suite *RelationshipTestSuite) TestUpdateFollow() { suite.True(relationship.Notifying) } +func (suite *RelationshipTestSuite) TestGetNote() { + ctx := context.Background() + + // Retrieve a fixture note + account1 := suite.testAccounts["local_account_1"].ID + account2 := suite.testAccounts["local_account_2"].ID + expectedNote := suite.testAccountNotes["local_account_2_note_on_1"] + note, err := suite.db.GetNote(ctx, account2, account1) + suite.NoError(err) + suite.NotNil(note) + suite.Equal(expectedNote.ID, note.ID) + suite.Equal(expectedNote.Comment, note.Comment) +} + +func (suite *RelationshipTestSuite) TestPutNote() { + ctx := context.Background() + + // put a note in + account1 := suite.testAccounts["local_account_1"].ID + account2 := suite.testAccounts["local_account_2"].ID + err := suite.db.PutNote(ctx, >smodel.AccountNote{ + ID: "01H539R2NA0M83JX15Y5RWKE97", + AccountID: account1, + TargetAccountID: account2, + Comment: "foo", + }) + suite.NoError(err) + + // make sure the note is in the db + note, err := suite.db.GetNote(ctx, account1, account2) + suite.NoError(err) + suite.NotNil(note) + suite.Equal("01H539R2NA0M83JX15Y5RWKE97", note.ID) + suite.Equal("foo", note.Comment) + + // update the note + note.Comment = "bar" + err = suite.db.PutNote(ctx, note) + suite.NoError(err) + + // make sure the comment changes + note, err = suite.db.GetNote(ctx, account1, account2) + suite.NoError(err) + suite.NotNil(note) + suite.Equal("bar", note.Comment) +} + func TestRelationshipTestSuite(t *testing.T) { suite.Run(t, new(RelationshipTestSuite)) } diff --git a/internal/db/relationship.go b/internal/db/relationship.go index f8866a545..e19aee646 100644 --- a/internal/db/relationship.go +++ b/internal/db/relationship.go @@ -165,4 +165,10 @@ type Relationship interface { // CountAccountFollowerRequests returns number of follow requests originating from the given account. CountAccountFollowRequesting(ctx context.Context, accountID string) (int, error) + + // GetNote gets a private note from a source account on a target account, if it exists. + GetNote(ctx context.Context, sourceAccountID string, targetAccountID string) (*gtsmodel.AccountNote, error) + + // PutNote creates or updates a private note. + PutNote(ctx context.Context, note *gtsmodel.AccountNote) error } diff --git a/internal/gtsmodel/accountnote.go b/internal/gtsmodel/accountnote.go new file mode 100644 index 000000000..239ed6ce9 --- /dev/null +++ b/internal/gtsmodel/accountnote.go @@ -0,0 +1,32 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package gtsmodel + +import "time" + +// AccountNote stores a private note from a local account related to any account. +type AccountNote struct { + ID string `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + AccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:account_notes_account_id_target_account_id_uniq,notnull,nullzero"` // ID of the local account that created the note + Account *Account `validate:"-" bun:"rel:belongs-to"` // Account corresponding to accountID + TargetAccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:account_notes_account_id_target_account_id_uniq,notnull,nullzero"` // Who is the target of this note? + TargetAccount *Account `validate:"-" bun:"rel:belongs-to"` // Account corresponding to targetAccountID + Comment string `validate:"-" bun:""` // The text of the note. +} diff --git a/internal/processing/account/note.go b/internal/processing/account/note.go new file mode 100644 index 000000000..7606c1a91 --- /dev/null +++ b/internal/processing/account/note.go @@ -0,0 +1,48 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package account + +import ( + "context" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +// PutNote updates the requesting account's private note on the target account. +func (p *Processor) PutNote(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, comment string) (*apimodel.Relationship, gtserror.WithCode) { + targetAccount, errWithCode := p.Get(ctx, requestingAccount, targetAccountID) + if errWithCode != nil { + return nil, errWithCode + } + + note := >smodel.AccountNote{ + ID: id.NewULID(), + AccountID: requestingAccount.ID, + TargetAccountID: targetAccount.ID, + Comment: comment, + } + err := p.state.DB.PutNote(ctx, note) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return p.RelationshipGet(ctx, requestingAccount, targetAccount.ID) +} diff --git a/test/envparsing.sh b/test/envparsing.sh index a2ebd6e6e..9e5491005 100755 --- a/test/envparsing.sh +++ b/test/envparsing.sh @@ -20,6 +20,9 @@ EXPECT=$(cat <<"EOF" "cache": { "gts": { "account-max-size": 99, + "account-note-max-size": 1000, + "account-note-sweep-freq": 60000000000, + "account-note-ttl": 1800000000000, "account-sweep-freq": 1000000000, "account-ttl": 10800000000000, "block-max-size": 1000, diff --git a/testrig/db.go b/testrig/db.go index c169669d7..eb8a23f42 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -60,6 +60,7 @@ var testModels = []interface{}{ >smodel.EmojiCategory{}, >smodel.Tombstone{}, >smodel.Report{}, + >smodel.AccountNote{}, } // NewTestDB returns a new initialized, empty database for testing. @@ -280,6 +281,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) { } } + for _, v := range NewTestAccountNotes() { + if err := db.Put(ctx, v); err != nil { + log.Panic(nil, err) + } + } + if err := db.CreateInstanceAccount(ctx); err != nil { log.Panic(nil, err) } diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 837869c83..b22b6089c 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -1893,6 +1893,18 @@ func NewTestFaves() map[string]*gtsmodel.StatusFave { } } +// NewTestAccountNotes returns some account notes for use in testing. +func NewTestAccountNotes() map[string]*gtsmodel.AccountNote { + return map[string]*gtsmodel.AccountNote{ + "local_account_2_note_on_1": { + ID: "01H53TM628GNC4ZDNRGQGPK8S0", + AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", + TargetAccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", + Comment: "extremely average poster", + }, + } +} + // NewTestNotifications returns some notifications for use in testing. func NewTestNotifications() map[string]*gtsmodel.Notification { return map[string]*gtsmodel.Notification{