mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 14:22:25 -05:00 
			
		
		
		
	[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:
		
					parent
					
						
							
								5f3e095717
							
						
					
				
			
			
				commit
				
					
						22ac4607a1
					
				
			
		
					 19 changed files with 597 additions and 2 deletions
				
			
		|  | @ -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). | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
							
								
								
									
										108
									
								
								internal/api/client/accounts/note.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								internal/api/client/accounts/note.go
									
										
									
									
									
										Normal 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) | ||||
| } | ||||
|  | @ -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"` | ||||
| } | ||||
|  |  | |||
							
								
								
									
										22
									
								
								internal/cache/gts.go
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								internal/cache/gts.go
									
										
									
									
										vendored
									
									
								
							|  | @ -27,6 +27,7 @@ import ( | |||
| 
 | ||||
| type GTSCaches struct { | ||||
| 	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. | ||||
|  | @ -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"}, | ||||
|  |  | |||
|  | @ -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"` | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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() | ||||
|  |  | |||
|  | @ -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() { | ||||
|  |  | |||
							
								
								
									
										62
									
								
								internal/db/bundb/migrations/20230711214815_account_notes.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								internal/db/bundb/migrations/20230711214815_account_notes.go
									
										
									
									
									
										Normal 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(>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) | ||||
| 	} | ||||
| } | ||||
|  | @ -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 | ||||
| } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										99
									
								
								internal/db/bundb/relationship_note.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								internal/db/bundb/relationship_note.go
									
										
									
									
									
										Normal 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(¬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) | ||||
| 	}) | ||||
| } | ||||
|  | @ -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)) | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
							
								
								
									
										32
									
								
								internal/gtsmodel/accountnote.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								internal/gtsmodel/accountnote.go
									
										
									
									
									
										Normal 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. | ||||
| } | ||||
							
								
								
									
										48
									
								
								internal/processing/account/note.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								internal/processing/account/note.go
									
										
									
									
									
										Normal 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 := >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) | ||||
| } | ||||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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) | ||||
| 	} | ||||
|  |  | |||
|  | @ -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{ | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue