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{