mirror of
1
Fork 0

[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>
This commit is contained in:
Vyr Cossont 2023-07-27 01:30:39 -07:00 committed by GitHub
parent 5f3e095717
commit 22ac4607a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 597 additions and 2 deletions

View File

@ -2944,6 +2944,45 @@ paths:
summary: See all lists of yours that contain requested account. summary: See all lists of yours that contain requested account.
tags: tags:
- accounts - 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: /api/v1/accounts/{id}/statuses:
get: get:
description: The statuses will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). description: The statuses will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).

View File

@ -45,6 +45,7 @@ const (
FollowPath = BasePathWithID + "/follow" FollowPath = BasePathWithID + "/follow"
ListsPath = BasePathWithID + "/lists" ListsPath = BasePathWithID + "/lists"
LookupPath = BasePath + "/lookup" LookupPath = BasePath + "/lookup"
NotePath = BasePathWithID + "/note"
RelationshipsPath = BasePath + "/relationships" RelationshipsPath = BasePath + "/relationships"
SearchPath = BasePath + "/search" SearchPath = BasePath + "/search"
StatusesPath = BasePathWithID + "/statuses" StatusesPath = BasePathWithID + "/statuses"
@ -101,6 +102,9 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
// account lists // account lists
attachHandler(http.MethodGet, ListsPath, m.AccountListsGETHandler) attachHandler(http.MethodGet, ListsPath, m.AccountListsGETHandler)
// account note
attachHandler(http.MethodPost, NotePath, m.AccountNotePOSTHandler)
// search for accounts // search for accounts
attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler) attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler)
attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler) attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler)

View File

@ -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 <http://www.gnu.org/licenses/>.
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)
}

View File

@ -231,3 +231,11 @@ const (
AccountRoleAdmin AccountRoleName = "admin" // Instance admin AccountRoleAdmin AccountRoleName = "admin" // Instance admin
AccountRoleUnknown AccountRoleName = "" // We don't know / remote account 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"`
}

22
internal/cache/gts.go vendored
View File

@ -27,6 +27,7 @@ import (
type GTSCaches struct { type GTSCaches struct {
account *result.Cache[*gtsmodel.Account] account *result.Cache[*gtsmodel.Account]
accountNote *result.Cache[*gtsmodel.AccountNote]
block *result.Cache[*gtsmodel.Block] block *result.Cache[*gtsmodel.Block]
// TODO: maybe should be moved out of here since it's // TODO: maybe should be moved out of here since it's
// not actually doing anything with gtsmodel.DomainBlock. // not actually doing anything with gtsmodel.DomainBlock.
@ -54,6 +55,7 @@ type GTSCaches struct {
// NOTE: the cache MUST NOT be in use anywhere, this is not thread-safe. // NOTE: the cache MUST NOT be in use anywhere, this is not thread-safe.
func (c *GTSCaches) Init() { func (c *GTSCaches) Init() {
c.initAccount() c.initAccount()
c.initAccountNote()
c.initBlock() c.initBlock()
c.initDomainBlock() c.initDomainBlock()
c.initEmoji() c.initEmoji()
@ -77,6 +79,7 @@ func (c *GTSCaches) Init() {
// Start will attempt to start all of the gtsmodel caches, or panic. // Start will attempt to start all of the gtsmodel caches, or panic.
func (c *GTSCaches) Start() { func (c *GTSCaches) Start() {
tryStart(c.account, config.GetCacheGTSAccountSweepFreq()) tryStart(c.account, config.GetCacheGTSAccountSweepFreq())
tryStart(c.accountNote, config.GetCacheGTSAccountNoteSweepFreq())
tryStart(c.block, config.GetCacheGTSBlockSweepFreq()) tryStart(c.block, config.GetCacheGTSBlockSweepFreq())
tryStart(c.emoji, config.GetCacheGTSEmojiSweepFreq()) tryStart(c.emoji, config.GetCacheGTSEmojiSweepFreq())
tryStart(c.emojiCategory, config.GetCacheGTSEmojiCategorySweepFreq()) 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. // Stop will attempt to stop all of the gtsmodel caches, or panic.
func (c *GTSCaches) Stop() { func (c *GTSCaches) Stop() {
tryStop(c.account, config.GetCacheGTSAccountSweepFreq()) tryStop(c.account, config.GetCacheGTSAccountSweepFreq())
tryStop(c.accountNote, config.GetCacheGTSAccountNoteSweepFreq())
tryStop(c.block, config.GetCacheGTSBlockSweepFreq()) tryStop(c.block, config.GetCacheGTSBlockSweepFreq())
tryStop(c.emoji, config.GetCacheGTSEmojiSweepFreq()) tryStop(c.emoji, config.GetCacheGTSEmojiSweepFreq())
tryStop(c.emojiCategory, config.GetCacheGTSEmojiCategorySweepFreq()) tryStop(c.emojiCategory, config.GetCacheGTSEmojiCategorySweepFreq())
@ -128,6 +132,11 @@ func (c *GTSCaches) Account() *result.Cache[*gtsmodel.Account] {
return c.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. // Block provides access to the gtsmodel Block (account) database cache.
func (c *GTSCaches) Block() *result.Cache[*gtsmodel.Block] { func (c *GTSCaches) Block() *result.Cache[*gtsmodel.Block] {
return c.block return c.block
@ -238,6 +247,19 @@ func (c *GTSCaches) initAccount() {
c.account.IgnoreErrors(ignoreErrors) 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() { func (c *GTSCaches) initBlock() {
c.block = result.New([]result.Lookup{ c.block = result.New([]result.Lookup{
{Name: "ID"}, {Name: "ID"},

View File

@ -186,6 +186,10 @@ type GTSCacheConfiguration struct {
AccountTTL time.Duration `name:"account-ttl"` AccountTTL time.Duration `name:"account-ttl"`
AccountSweepFreq time.Duration `name:"account-sweep-freq"` 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"` BlockMaxSize int `name:"block-max-size"`
BlockTTL time.Duration `name:"block-ttl"` BlockTTL time.Duration `name:"block-ttl"`
BlockSweepFreq time.Duration `name:"block-sweep-freq"` BlockSweepFreq time.Duration `name:"block-sweep-freq"`

View File

@ -131,6 +131,10 @@ var Defaults = Configuration{
AccountTTL: time.Minute * 30, AccountTTL: time.Minute * 30,
AccountSweepFreq: time.Minute, AccountSweepFreq: time.Minute,
AccountNoteMaxSize: 1000,
AccountNoteTTL: time.Minute * 30,
AccountNoteSweepFreq: time.Minute,
BlockMaxSize: 1000, BlockMaxSize: 1000,
BlockTTL: time.Minute * 30, BlockTTL: time.Minute * 30,
BlockSweepFreq: time.Minute, BlockSweepFreq: time.Minute,

View File

@ -2474,6 +2474,81 @@ func GetCacheGTSAccountSweepFreq() time.Duration { return global.GetCacheGTSAcco
// SetCacheGTSAccountSweepFreq safely sets the value for global configuration 'Cache.GTS.AccountSweepFreq' field // SetCacheGTSAccountSweepFreq safely sets the value for global configuration 'Cache.GTS.AccountSweepFreq' field
func SetCacheGTSAccountSweepFreq(v time.Duration) { global.SetCacheGTSAccountSweepFreq(v) } 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 // GetCacheGTSBlockMaxSize safely fetches the Configuration value for state's 'Cache.GTS.BlockMaxSize' field
func (st *ConfigState) GetCacheGTSBlockMaxSize() (v int) { func (st *ConfigState) GetCacheGTSBlockMaxSize() (v int) {
st.mutex.RLock() st.mutex.RLock()

View File

@ -49,6 +49,7 @@ type BunDBStandardTestSuite struct {
testFaves map[string]*gtsmodel.StatusFave testFaves map[string]*gtsmodel.StatusFave
testLists map[string]*gtsmodel.List testLists map[string]*gtsmodel.List
testListEntries map[string]*gtsmodel.ListEntry testListEntries map[string]*gtsmodel.ListEntry
testAccountNotes map[string]*gtsmodel.AccountNote
} }
func (suite *BunDBStandardTestSuite) SetupSuite() { func (suite *BunDBStandardTestSuite) SetupSuite() {
@ -68,6 +69,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() {
suite.testFaves = testrig.NewTestFaves() suite.testFaves = testrig.NewTestFaves()
suite.testLists = testrig.NewTestLists() suite.testLists = testrig.NewTestLists()
suite.testListEntries = testrig.NewTestListEntries() suite.testListEntries = testrig.NewTestListEntries()
suite.testAccountNotes = testrig.NewTestAccountNotes()
} }
func (suite *BunDBStandardTestSuite) SetupTest() { func (suite *BunDBStandardTestSuite) SetupTest() {

View File

@ -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 <http://www.gnu.org/licenses/>.
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(&gtsmodel.AccountNote{}).
IfNotExists().
Exec(ctx); err != nil {
return err
}
// Add IDs index to the account note table.
if _, err := tx.
NewCreateIndex().
Model(&gtsmodel.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)
}
}

View File

@ -85,6 +85,19 @@ func (r *relationshipDB) GetRelationship(ctx context.Context, requestingAccount
return nil, fmt.Errorf("GetRelationship: error checking blockedBy: %w", err) 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 return &rel, nil
} }

View File

@ -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 <http://www.gnu.org/licenses/>.
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(&note); err != nil {
return nil, r.conn.ProcessError(err)
}
return &note, 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)
})
}

View File

@ -912,6 +912,53 @@ func (suite *RelationshipTestSuite) TestUpdateFollow() {
suite.True(relationship.Notifying) 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, &gtsmodel.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) { func TestRelationshipTestSuite(t *testing.T) {
suite.Run(t, new(RelationshipTestSuite)) suite.Run(t, new(RelationshipTestSuite))
} }

View File

@ -165,4 +165,10 @@ type Relationship interface {
// CountAccountFollowerRequests returns number of follow requests originating from the given account. // CountAccountFollowerRequests returns number of follow requests originating from the given account.
CountAccountFollowRequesting(ctx context.Context, accountID string) (int, error) 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
} }

View File

@ -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 <http://www.gnu.org/licenses/>.
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.
}

View File

@ -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 <http://www.gnu.org/licenses/>.
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 := &gtsmodel.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)
}

View File

@ -20,6 +20,9 @@ EXPECT=$(cat <<"EOF"
"cache": { "cache": {
"gts": { "gts": {
"account-max-size": 99, "account-max-size": 99,
"account-note-max-size": 1000,
"account-note-sweep-freq": 60000000000,
"account-note-ttl": 1800000000000,
"account-sweep-freq": 1000000000, "account-sweep-freq": 1000000000,
"account-ttl": 10800000000000, "account-ttl": 10800000000000,
"block-max-size": 1000, "block-max-size": 1000,

View File

@ -60,6 +60,7 @@ var testModels = []interface{}{
&gtsmodel.EmojiCategory{}, &gtsmodel.EmojiCategory{},
&gtsmodel.Tombstone{}, &gtsmodel.Tombstone{},
&gtsmodel.Report{}, &gtsmodel.Report{},
&gtsmodel.AccountNote{},
} }
// NewTestDB returns a new initialized, empty database for testing. // 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 { if err := db.CreateInstanceAccount(ctx); err != nil {
log.Panic(nil, err) log.Panic(nil, err)
} }

View File

@ -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. // NewTestNotifications returns some notifications for use in testing.
func NewTestNotifications() map[string]*gtsmodel.Notification { func NewTestNotifications() map[string]*gtsmodel.Notification {
return map[string]*gtsmodel.Notification{ return map[string]*gtsmodel.Notification{