mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 08:22:27 -05:00 
			
		
		
		
	[feature] Implement Report database model and utility functions (#1310)
* implement report database model * implement report cache + config changes * implement report database functions * report uri / regex functions * update envparsing test * remove unnecessary uri index * remove unused function + cache lookup * process error when storing report
This commit is contained in:
		
					parent
					
						
							
								36aa6854bd
							
						
					
				
			
			
				commit
				
					
						d6487933c7
					
				
			
		
					 21 changed files with 693 additions and 6 deletions
				
			
		|  | @ -207,6 +207,10 @@ cache: | ||||||
|     notification-ttl: "5m" |     notification-ttl: "5m" | ||||||
|     notification-sweep-freq: "10s" |     notification-sweep-freq: "10s" | ||||||
| 
 | 
 | ||||||
|  |     report-max-size: 100 | ||||||
|  |     report-ttl: "5m" | ||||||
|  |     report-sweep-freq: "10s" | ||||||
|  | 
 | ||||||
|     status-max-size: 500 |     status-max-size: 500 | ||||||
|     status-ttl: "5m" |     status-ttl: "5m" | ||||||
|     status-sweep-freq: "10s" |     status-sweep-freq: "10s" | ||||||
|  |  | ||||||
							
								
								
									
										24
									
								
								internal/cache/gts.go
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										24
									
								
								internal/cache/gts.go
									
										
									
									
										vendored
									
									
								
							|  | @ -57,6 +57,9 @@ type GTSCaches interface { | ||||||
| 	// Notification provides access to the gtsmodel Notification database cache. | 	// Notification provides access to the gtsmodel Notification database cache. | ||||||
| 	Notification() *result.Cache[*gtsmodel.Notification] | 	Notification() *result.Cache[*gtsmodel.Notification] | ||||||
| 
 | 
 | ||||||
|  | 	// Report provides access to the gtsmodel Report database cache. | ||||||
|  | 	Report() *result.Cache[*gtsmodel.Report] | ||||||
|  | 
 | ||||||
| 	// Status provides access to the gtsmodel Status database cache. | 	// Status provides access to the gtsmodel Status database cache. | ||||||
| 	Status() *result.Cache[*gtsmodel.Status] | 	Status() *result.Cache[*gtsmodel.Status] | ||||||
| 
 | 
 | ||||||
|  | @ -80,6 +83,7 @@ type gtsCaches struct { | ||||||
| 	emojiCategory *result.Cache[*gtsmodel.EmojiCategory] | 	emojiCategory *result.Cache[*gtsmodel.EmojiCategory] | ||||||
| 	mention       *result.Cache[*gtsmodel.Mention] | 	mention       *result.Cache[*gtsmodel.Mention] | ||||||
| 	notification  *result.Cache[*gtsmodel.Notification] | 	notification  *result.Cache[*gtsmodel.Notification] | ||||||
|  | 	report        *result.Cache[*gtsmodel.Report] | ||||||
| 	status        *result.Cache[*gtsmodel.Status] | 	status        *result.Cache[*gtsmodel.Status] | ||||||
| 	tombstone     *result.Cache[*gtsmodel.Tombstone] | 	tombstone     *result.Cache[*gtsmodel.Tombstone] | ||||||
| 	user          *result.Cache[*gtsmodel.User] | 	user          *result.Cache[*gtsmodel.User] | ||||||
|  | @ -93,6 +97,7 @@ func (c *gtsCaches) Init() { | ||||||
| 	c.initEmojiCategory() | 	c.initEmojiCategory() | ||||||
| 	c.initMention() | 	c.initMention() | ||||||
| 	c.initNotification() | 	c.initNotification() | ||||||
|  | 	c.initReport() | ||||||
| 	c.initStatus() | 	c.initStatus() | ||||||
| 	c.initTombstone() | 	c.initTombstone() | ||||||
| 	c.initUser() | 	c.initUser() | ||||||
|  | @ -120,6 +125,9 @@ func (c *gtsCaches) Start() { | ||||||
| 	tryUntil("starting gtsmodel.Notification cache", 5, func() bool { | 	tryUntil("starting gtsmodel.Notification cache", 5, func() bool { | ||||||
| 		return c.notification.Start(config.GetCacheGTSNotificationSweepFreq()) | 		return c.notification.Start(config.GetCacheGTSNotificationSweepFreq()) | ||||||
| 	}) | 	}) | ||||||
|  | 	tryUntil("starting gtsmodel.Report cache", 5, func() bool { | ||||||
|  | 		return c.report.Start(config.GetCacheGTSReportSweepFreq()) | ||||||
|  | 	}) | ||||||
| 	tryUntil("starting gtsmodel.Status cache", 5, func() bool { | 	tryUntil("starting gtsmodel.Status cache", 5, func() bool { | ||||||
| 		return c.status.Start(config.GetCacheGTSStatusSweepFreq()) | 		return c.status.Start(config.GetCacheGTSStatusSweepFreq()) | ||||||
| 	}) | 	}) | ||||||
|  | @ -139,6 +147,7 @@ func (c *gtsCaches) Stop() { | ||||||
| 	tryUntil("stopping gtsmodel.EmojiCategory cache", 5, c.emojiCategory.Stop) | 	tryUntil("stopping gtsmodel.EmojiCategory cache", 5, c.emojiCategory.Stop) | ||||||
| 	tryUntil("stopping gtsmodel.Mention cache", 5, c.mention.Stop) | 	tryUntil("stopping gtsmodel.Mention cache", 5, c.mention.Stop) | ||||||
| 	tryUntil("stopping gtsmodel.Notification cache", 5, c.notification.Stop) | 	tryUntil("stopping gtsmodel.Notification cache", 5, c.notification.Stop) | ||||||
|  | 	tryUntil("stopping gtsmodel.Report cache", 5, c.report.Stop) | ||||||
| 	tryUntil("stopping gtsmodel.Status cache", 5, c.status.Stop) | 	tryUntil("stopping gtsmodel.Status cache", 5, c.status.Stop) | ||||||
| 	tryUntil("stopping gtsmodel.Tombstone cache", 5, c.tombstone.Stop) | 	tryUntil("stopping gtsmodel.Tombstone cache", 5, c.tombstone.Stop) | ||||||
| 	tryUntil("stopping gtsmodel.User cache", 5, c.user.Stop) | 	tryUntil("stopping gtsmodel.User cache", 5, c.user.Stop) | ||||||
|  | @ -172,6 +181,10 @@ func (c *gtsCaches) Notification() *result.Cache[*gtsmodel.Notification] { | ||||||
| 	return c.notification | 	return c.notification | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (c *gtsCaches) Report() *result.Cache[*gtsmodel.Report] { | ||||||
|  | 	return c.report | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (c *gtsCaches) Status() *result.Cache[*gtsmodel.Status] { | func (c *gtsCaches) Status() *result.Cache[*gtsmodel.Status] { | ||||||
| 	return c.status | 	return c.status | ||||||
| } | } | ||||||
|  | @ -267,6 +280,17 @@ func (c *gtsCaches) initNotification() { | ||||||
| 	c.notification.SetTTL(config.GetCacheGTSNotificationTTL(), true) | 	c.notification.SetTTL(config.GetCacheGTSNotificationTTL(), true) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (c *gtsCaches) initReport() { | ||||||
|  | 	c.report = result.New([]result.Lookup{ | ||||||
|  | 		{Name: "ID"}, | ||||||
|  | 	}, func(r1 *gtsmodel.Report) *gtsmodel.Report { | ||||||
|  | 		r2 := new(gtsmodel.Report) | ||||||
|  | 		*r2 = *r1 | ||||||
|  | 		return r2 | ||||||
|  | 	}, config.GetCacheGTSReportMaxSize()) | ||||||
|  | 	c.report.SetTTL(config.GetCacheGTSReportTTL(), true) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (c *gtsCaches) initStatus() { | func (c *gtsCaches) initStatus() { | ||||||
| 	c.status = result.New([]result.Lookup{ | 	c.status = result.New([]result.Lookup{ | ||||||
| 		{Name: "ID"}, | 		{Name: "ID"}, | ||||||
|  |  | ||||||
|  | @ -175,6 +175,10 @@ type GTSCacheConfiguration struct { | ||||||
| 	NotificationTTL       time.Duration `name:"notification-ttl"` | 	NotificationTTL       time.Duration `name:"notification-ttl"` | ||||||
| 	NotificationSweepFreq time.Duration `name:"notification-sweep-freq"` | 	NotificationSweepFreq time.Duration `name:"notification-sweep-freq"` | ||||||
| 
 | 
 | ||||||
|  | 	ReportMaxSize   int           `name:"report-max-size"` | ||||||
|  | 	ReportTTL       time.Duration `name:"report-ttl"` | ||||||
|  | 	ReportSweepFreq time.Duration `name:"report-sweep-freq"` | ||||||
|  | 
 | ||||||
| 	StatusMaxSize   int           `name:"status-max-size"` | 	StatusMaxSize   int           `name:"status-max-size"` | ||||||
| 	StatusTTL       time.Duration `name:"status-ttl"` | 	StatusTTL       time.Duration `name:"status-ttl"` | ||||||
| 	StatusSweepFreq time.Duration `name:"status-sweep-freq"` | 	StatusSweepFreq time.Duration `name:"status-sweep-freq"` | ||||||
|  |  | ||||||
|  | @ -138,6 +138,10 @@ var Defaults = Configuration{ | ||||||
| 			NotificationTTL:       time.Minute * 5, | 			NotificationTTL:       time.Minute * 5, | ||||||
| 			NotificationSweepFreq: time.Second * 10, | 			NotificationSweepFreq: time.Second * 10, | ||||||
| 
 | 
 | ||||||
|  | 			ReportMaxSize:   100, | ||||||
|  | 			ReportTTL:       time.Minute * 5, | ||||||
|  | 			ReportSweepFreq: time.Second * 10, | ||||||
|  | 
 | ||||||
| 			StatusMaxSize:   500, | 			StatusMaxSize:   500, | ||||||
| 			StatusTTL:       time.Minute * 5, | 			StatusTTL:       time.Minute * 5, | ||||||
| 			StatusSweepFreq: time.Second * 10, | 			StatusSweepFreq: time.Second * 10, | ||||||
|  |  | ||||||
|  | @ -2378,6 +2378,81 @@ func GetCacheGTSNotificationSweepFreq() time.Duration { | ||||||
| // SetCacheGTSNotificationSweepFreq safely sets the value for global configuration 'Cache.GTS.NotificationSweepFreq' field | // SetCacheGTSNotificationSweepFreq safely sets the value for global configuration 'Cache.GTS.NotificationSweepFreq' field | ||||||
| func SetCacheGTSNotificationSweepFreq(v time.Duration) { global.SetCacheGTSNotificationSweepFreq(v) } | func SetCacheGTSNotificationSweepFreq(v time.Duration) { global.SetCacheGTSNotificationSweepFreq(v) } | ||||||
| 
 | 
 | ||||||
|  | // GetCacheGTSReportMaxSize safely fetches the Configuration value for state's 'Cache.GTS.ReportMaxSize' field | ||||||
|  | func (st *ConfigState) GetCacheGTSReportMaxSize() (v int) { | ||||||
|  | 	st.mutex.Lock() | ||||||
|  | 	v = st.config.Cache.GTS.ReportMaxSize | ||||||
|  | 	st.mutex.Unlock() | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SetCacheGTSReportMaxSize safely sets the Configuration value for state's 'Cache.GTS.ReportMaxSize' field | ||||||
|  | func (st *ConfigState) SetCacheGTSReportMaxSize(v int) { | ||||||
|  | 	st.mutex.Lock() | ||||||
|  | 	defer st.mutex.Unlock() | ||||||
|  | 	st.config.Cache.GTS.ReportMaxSize = v | ||||||
|  | 	st.reloadToViper() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // CacheGTSReportMaxSizeFlag returns the flag name for the 'Cache.GTS.ReportMaxSize' field | ||||||
|  | func CacheGTSReportMaxSizeFlag() string { return "cache-gts-report-max-size" } | ||||||
|  | 
 | ||||||
|  | // GetCacheGTSReportMaxSize safely fetches the value for global configuration 'Cache.GTS.ReportMaxSize' field | ||||||
|  | func GetCacheGTSReportMaxSize() int { return global.GetCacheGTSReportMaxSize() } | ||||||
|  | 
 | ||||||
|  | // SetCacheGTSReportMaxSize safely sets the value for global configuration 'Cache.GTS.ReportMaxSize' field | ||||||
|  | func SetCacheGTSReportMaxSize(v int) { global.SetCacheGTSReportMaxSize(v) } | ||||||
|  | 
 | ||||||
|  | // GetCacheGTSReportTTL safely fetches the Configuration value for state's 'Cache.GTS.ReportTTL' field | ||||||
|  | func (st *ConfigState) GetCacheGTSReportTTL() (v time.Duration) { | ||||||
|  | 	st.mutex.Lock() | ||||||
|  | 	v = st.config.Cache.GTS.ReportTTL | ||||||
|  | 	st.mutex.Unlock() | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SetCacheGTSReportTTL safely sets the Configuration value for state's 'Cache.GTS.ReportTTL' field | ||||||
|  | func (st *ConfigState) SetCacheGTSReportTTL(v time.Duration) { | ||||||
|  | 	st.mutex.Lock() | ||||||
|  | 	defer st.mutex.Unlock() | ||||||
|  | 	st.config.Cache.GTS.ReportTTL = v | ||||||
|  | 	st.reloadToViper() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // CacheGTSReportTTLFlag returns the flag name for the 'Cache.GTS.ReportTTL' field | ||||||
|  | func CacheGTSReportTTLFlag() string { return "cache-gts-report-ttl" } | ||||||
|  | 
 | ||||||
|  | // GetCacheGTSReportTTL safely fetches the value for global configuration 'Cache.GTS.ReportTTL' field | ||||||
|  | func GetCacheGTSReportTTL() time.Duration { return global.GetCacheGTSReportTTL() } | ||||||
|  | 
 | ||||||
|  | // SetCacheGTSReportTTL safely sets the value for global configuration 'Cache.GTS.ReportTTL' field | ||||||
|  | func SetCacheGTSReportTTL(v time.Duration) { global.SetCacheGTSReportTTL(v) } | ||||||
|  | 
 | ||||||
|  | // GetCacheGTSReportSweepFreq safely fetches the Configuration value for state's 'Cache.GTS.ReportSweepFreq' field | ||||||
|  | func (st *ConfigState) GetCacheGTSReportSweepFreq() (v time.Duration) { | ||||||
|  | 	st.mutex.Lock() | ||||||
|  | 	v = st.config.Cache.GTS.ReportSweepFreq | ||||||
|  | 	st.mutex.Unlock() | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SetCacheGTSReportSweepFreq safely sets the Configuration value for state's 'Cache.GTS.ReportSweepFreq' field | ||||||
|  | func (st *ConfigState) SetCacheGTSReportSweepFreq(v time.Duration) { | ||||||
|  | 	st.mutex.Lock() | ||||||
|  | 	defer st.mutex.Unlock() | ||||||
|  | 	st.config.Cache.GTS.ReportSweepFreq = v | ||||||
|  | 	st.reloadToViper() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // CacheGTSReportSweepFreqFlag returns the flag name for the 'Cache.GTS.ReportSweepFreq' field | ||||||
|  | func CacheGTSReportSweepFreqFlag() string { return "cache-gts-report-sweep-freq" } | ||||||
|  | 
 | ||||||
|  | // GetCacheGTSReportSweepFreq safely fetches the value for global configuration 'Cache.GTS.ReportSweepFreq' field | ||||||
|  | func GetCacheGTSReportSweepFreq() time.Duration { return global.GetCacheGTSReportSweepFreq() } | ||||||
|  | 
 | ||||||
|  | // SetCacheGTSReportSweepFreq safely sets the value for global configuration 'Cache.GTS.ReportSweepFreq' field | ||||||
|  | func SetCacheGTSReportSweepFreq(v time.Duration) { global.SetCacheGTSReportSweepFreq(v) } | ||||||
|  | 
 | ||||||
| // GetCacheGTSStatusMaxSize safely fetches the Configuration value for state's 'Cache.GTS.StatusMaxSize' field | // GetCacheGTSStatusMaxSize safely fetches the Configuration value for state's 'Cache.GTS.StatusMaxSize' field | ||||||
| func (st *ConfigState) GetCacheGTSStatusMaxSize() (v int) { | func (st *ConfigState) GetCacheGTSStatusMaxSize() (v int) { | ||||||
| 	st.mutex.Lock() | 	st.mutex.Lock() | ||||||
|  |  | ||||||
|  | @ -83,6 +83,7 @@ type DBService struct { | ||||||
| 	db.Mention | 	db.Mention | ||||||
| 	db.Notification | 	db.Notification | ||||||
| 	db.Relationship | 	db.Relationship | ||||||
|  | 	db.Report | ||||||
| 	db.Session | 	db.Session | ||||||
| 	db.Status | 	db.Status | ||||||
| 	db.Timeline | 	db.Timeline | ||||||
|  | @ -197,6 +198,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) { | ||||||
| 			conn:  conn, | 			conn:  conn, | ||||||
| 			state: state, | 			state: state, | ||||||
| 		}, | 		}, | ||||||
|  | 		Report: &reportDB{ | ||||||
|  | 			conn:  conn, | ||||||
|  | 			state: state, | ||||||
|  | 		}, | ||||||
| 		Session: &sessionDB{ | 		Session: &sessionDB{ | ||||||
| 			conn: conn, | 			conn: conn, | ||||||
| 		}, | 		}, | ||||||
|  |  | ||||||
|  | @ -42,6 +42,7 @@ type BunDBStandardTestSuite struct { | ||||||
| 	testMentions     map[string]*gtsmodel.Mention | 	testMentions     map[string]*gtsmodel.Mention | ||||||
| 	testFollows      map[string]*gtsmodel.Follow | 	testFollows      map[string]*gtsmodel.Follow | ||||||
| 	testEmojis       map[string]*gtsmodel.Emoji | 	testEmojis       map[string]*gtsmodel.Emoji | ||||||
|  | 	testReports      map[string]*gtsmodel.Report | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *BunDBStandardTestSuite) SetupSuite() { | func (suite *BunDBStandardTestSuite) SetupSuite() { | ||||||
|  | @ -56,6 +57,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() { | ||||||
| 	suite.testMentions = testrig.NewTestMentions() | 	suite.testMentions = testrig.NewTestMentions() | ||||||
| 	suite.testFollows = testrig.NewTestFollows() | 	suite.testFollows = testrig.NewTestFollows() | ||||||
| 	suite.testEmojis = testrig.NewTestEmojis() | 	suite.testEmojis = testrig.NewTestEmojis() | ||||||
|  | 	suite.testReports = testrig.NewTestReports() | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *BunDBStandardTestSuite) SetupTest() { | func (suite *BunDBStandardTestSuite) SetupTest() { | ||||||
|  |  | ||||||
							
								
								
									
										66
									
								
								internal/db/bundb/migrations/20230105171144_report_model.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								internal/db/bundb/migrations/20230105171144_report_model.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,66 @@ | ||||||
|  | /* | ||||||
|  |    GoToSocial | ||||||
|  |    Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org | ||||||
|  | 
 | ||||||
|  |    This program is free software: you can redistribute it and/or modify | ||||||
|  |    it under the terms of the GNU Affero General Public License as published by | ||||||
|  |    the Free Software Foundation, either version 3 of the License, or | ||||||
|  |    (at your option) any later version. | ||||||
|  | 
 | ||||||
|  |    This program is distributed in the hope that it will be useful, | ||||||
|  |    but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  |    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  |    GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  |    You should have received a copy of the GNU Affero General Public License | ||||||
|  |    along with this program.  If not, see <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 { | ||||||
|  | 			if _, err := tx.NewCreateTable().Model(>smodel.Report{}).IfNotExists().Exec(ctx); err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			if _, err := tx. | ||||||
|  | 				NewCreateIndex(). | ||||||
|  | 				Model(>smodel.Report{}). | ||||||
|  | 				Index("report_account_id_idx"). | ||||||
|  | 				Column("account_id"). | ||||||
|  | 				Exec(ctx); err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			if _, err := tx. | ||||||
|  | 				NewCreateIndex(). | ||||||
|  | 				Model(>smodel.Report{}). | ||||||
|  | 				Index("report_target_account_id_idx"). | ||||||
|  | 				Column("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) | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										138
									
								
								internal/db/bundb/report.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								internal/db/bundb/report.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,138 @@ | ||||||
|  | /* | ||||||
|  |    GoToSocial | ||||||
|  |    Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org | ||||||
|  | 
 | ||||||
|  |    This program is free software: you can redistribute it and/or modify | ||||||
|  |    it under the terms of the GNU Affero General Public License as published by | ||||||
|  |    the Free Software Foundation, either version 3 of the License, or | ||||||
|  |    (at your option) any later version. | ||||||
|  | 
 | ||||||
|  |    This program is distributed in the hope that it will be useful, | ||||||
|  |    but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  |    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  |    GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  |    You should have received a copy of the GNU Affero General Public License | ||||||
|  |    along with this program.  If not, see <http://www.gnu.org/licenses/>. | ||||||
|  | */ | ||||||
|  | 
 | ||||||
|  | package bundb | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"fmt" | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/state" | ||||||
|  | 	"github.com/uptrace/bun" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type reportDB struct { | ||||||
|  | 	conn  *DBConn | ||||||
|  | 	state *state.State | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *reportDB) newReportQ(report interface{}) *bun.SelectQuery { | ||||||
|  | 	return r.conn.NewSelect().Model(report) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *reportDB) GetReportByID(ctx context.Context, id string) (*gtsmodel.Report, db.Error) { | ||||||
|  | 	return r.getReport( | ||||||
|  | 		ctx, | ||||||
|  | 		"ID", | ||||||
|  | 		func(report *gtsmodel.Report) error { | ||||||
|  | 			return r.newReportQ(report).Where("? = ?", bun.Ident("report.id"), id).Scan(ctx) | ||||||
|  | 		}, | ||||||
|  | 		id, | ||||||
|  | 	) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *reportDB) getReport(ctx context.Context, lookup string, dbQuery func(*gtsmodel.Report) error, keyParts ...any) (*gtsmodel.Report, db.Error) { | ||||||
|  | 	// Fetch report from database cache with loader callback | ||||||
|  | 	report, err := r.state.Caches.GTS.Report().Load(lookup, func() (*gtsmodel.Report, error) { | ||||||
|  | 		var report gtsmodel.Report | ||||||
|  | 
 | ||||||
|  | 		// Not cached! Perform database query | ||||||
|  | 		if err := dbQuery(&report); err != nil { | ||||||
|  | 			return nil, r.conn.ProcessError(err) | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		return &report, nil | ||||||
|  | 	}, keyParts...) | ||||||
|  | 	if err != nil { | ||||||
|  | 		// error already processed | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Set the report author account | ||||||
|  | 	report.Account, err = r.state.DB.GetAccountByID(ctx, report.AccountID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("error getting report account: %w", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Set the report target account | ||||||
|  | 	report.TargetAccount, err = r.state.DB.GetAccountByID(ctx, report.TargetAccountID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("error getting report target account: %w", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if len(report.StatusIDs) > 0 { | ||||||
|  | 		// Fetch reported statuses | ||||||
|  | 		report.Statuses, err = r.state.DB.GetStatuses(ctx, report.StatusIDs) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return nil, fmt.Errorf("error getting status mentions: %w", err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if report.ActionTakenByAccountID != "" { | ||||||
|  | 		// Set the report action taken by account | ||||||
|  | 		report.ActionTakenByAccount, err = r.state.DB.GetAccountByID(ctx, report.ActionTakenByAccountID) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return nil, fmt.Errorf("error getting report action taken by account: %w", err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return report, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *reportDB) PutReport(ctx context.Context, report *gtsmodel.Report) db.Error { | ||||||
|  | 	return r.state.Caches.GTS.Report().Store(report, func() error { | ||||||
|  | 		_, err := r.conn.NewInsert().Model(report).Exec(ctx) | ||||||
|  | 		return r.conn.ProcessError(err) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *reportDB) UpdateReport(ctx context.Context, report *gtsmodel.Report, columns ...string) (*gtsmodel.Report, db.Error) { | ||||||
|  | 	// Update the report's last-updated | ||||||
|  | 	report.UpdatedAt = time.Now() | ||||||
|  | 	if len(columns) != 0 { | ||||||
|  | 		columns = append(columns, "updated_at") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if _, err := r.conn. | ||||||
|  | 		NewUpdate(). | ||||||
|  | 		Model(report). | ||||||
|  | 		Where("? = ?", bun.Ident("report.id"), report.ID). | ||||||
|  | 		Column(columns...). | ||||||
|  | 		Exec(ctx); err != nil { | ||||||
|  | 		return nil, r.conn.ProcessError(err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	r.state.Caches.GTS.Report().Invalidate("ID", report.ID) | ||||||
|  | 	return report, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *reportDB) DeleteReportByID(ctx context.Context, id string) db.Error { | ||||||
|  | 	if _, err := r.conn. | ||||||
|  | 		NewDelete(). | ||||||
|  | 		TableExpr("? AS ?", bun.Ident("reports"), bun.Ident("report")). | ||||||
|  | 		Where("? = ?", bun.Ident("report.id"), id). | ||||||
|  | 		Exec(ctx); err != nil { | ||||||
|  | 		return r.conn.ProcessError(err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	r.state.Caches.GTS.Report().Invalidate("ID", id) | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
							
								
								
									
										147
									
								
								internal/db/bundb/report_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								internal/db/bundb/report_test.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,147 @@ | ||||||
|  | /* | ||||||
|  |    GoToSocial | ||||||
|  |    Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org | ||||||
|  | 
 | ||||||
|  |    This program is free software: you can redistribute it and/or modify | ||||||
|  |    it under the terms of the GNU Affero General Public License as published by | ||||||
|  |    the Free Software Foundation, either version 3 of the License, or | ||||||
|  |    (at your option) any later version. | ||||||
|  | 
 | ||||||
|  |    This program is distributed in the hope that it will be useful, | ||||||
|  |    but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  |    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  |    GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  |    You should have received a copy of the GNU Affero General Public License | ||||||
|  |    along with this program.  If not, see <http://www.gnu.org/licenses/>. | ||||||
|  | */ | ||||||
|  | 
 | ||||||
|  | package bundb_test | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/stretchr/testify/suite" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/testrig" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type ReportTestSuite struct { | ||||||
|  | 	BunDBStandardTestSuite | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (suite *ReportTestSuite) TestGetReportByID() { | ||||||
|  | 	report, err := suite.db.GetReportByID(context.Background(), suite.testReports["local_account_2_report_remote_account_1"].ID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 	suite.NotNil(report) | ||||||
|  | 	suite.NotNil(report.Account) | ||||||
|  | 	suite.NotNil(report.TargetAccount) | ||||||
|  | 	suite.Zero(report.ActionTakenAt) | ||||||
|  | 	suite.Nil(report.ActionTakenByAccount) | ||||||
|  | 	suite.Empty(report.ActionTakenByAccountID) | ||||||
|  | 	suite.NotEmpty(report.URI) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (suite *ReportTestSuite) TestGetReportByURI() { | ||||||
|  | 	report, err := suite.db.GetReportByID(context.Background(), suite.testReports["remote_account_1_report_local_account_2"].ID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 	suite.NotNil(report) | ||||||
|  | 	suite.NotNil(report.Account) | ||||||
|  | 	suite.NotNil(report.TargetAccount) | ||||||
|  | 	suite.NotZero(report.ActionTakenAt) | ||||||
|  | 	suite.NotNil(report.ActionTakenByAccount) | ||||||
|  | 	suite.NotEmpty(report.ActionTakenByAccountID) | ||||||
|  | 	suite.NotEmpty(report.URI) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (suite *ReportTestSuite) TestPutReport() { | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 
 | ||||||
|  | 	reportID := "01GP3ECY8QJD8DBJSS8B1CR0AX" | ||||||
|  | 	report := >smodel.Report{ | ||||||
|  | 		ID:              reportID, | ||||||
|  | 		CreatedAt:       testrig.TimeMustParse("2022-05-14T12:20:03+02:00"), | ||||||
|  | 		UpdatedAt:       testrig.TimeMustParse("2022-05-14T12:20:03+02:00"), | ||||||
|  | 		URI:             "http://localhost:8080/01GP3ECY8QJD8DBJSS8B1CR0AX", | ||||||
|  | 		AccountID:       "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|  | 		TargetAccountID: "01F8MH5ZK5VRH73AKHQM6Y9VNX", | ||||||
|  | 		Comment:         "another report", | ||||||
|  | 		StatusIDs:       []string{"01FVW7JHQFSFK166WWKR8CBA6M"}, | ||||||
|  | 		Forwarded:       testrig.TrueBool(), | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	err := suite.db.PutReport(ctx, report) | ||||||
|  | 	suite.NoError(err) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (suite *ReportTestSuite) TestUpdateReport() { | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 
 | ||||||
|  | 	report := >smodel.Report{} | ||||||
|  | 	*report = *suite.testReports["local_account_2_report_remote_account_1"] | ||||||
|  | 	report.ActionTaken = "nothing" | ||||||
|  | 	report.ActionTakenByAccountID = suite.testAccounts["admin_account"].ID | ||||||
|  | 	report.ActionTakenAt = testrig.TimeMustParse("2022-05-14T12:20:03+02:00") | ||||||
|  | 
 | ||||||
|  | 	if _, err := suite.db.UpdateReport(ctx, report, "action_taken", "action_taken_by_account_id", "action_taken_at"); err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	dbReport, err := suite.db.GetReportByID(ctx, report.ID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 	suite.NotNil(dbReport) | ||||||
|  | 	suite.NotNil(dbReport.Account) | ||||||
|  | 	suite.NotNil(dbReport.TargetAccount) | ||||||
|  | 	suite.NotZero(dbReport.ActionTakenAt) | ||||||
|  | 	suite.NotNil(dbReport.ActionTakenByAccount) | ||||||
|  | 	suite.NotEmpty(dbReport.ActionTakenByAccountID) | ||||||
|  | 	suite.NotEmpty(dbReport.URI) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (suite *ReportTestSuite) TestUpdateReportAllColumns() { | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 
 | ||||||
|  | 	report := >smodel.Report{} | ||||||
|  | 	*report = *suite.testReports["local_account_2_report_remote_account_1"] | ||||||
|  | 	report.ActionTaken = "nothing" | ||||||
|  | 	report.ActionTakenByAccountID = suite.testAccounts["admin_account"].ID | ||||||
|  | 	report.ActionTakenAt = testrig.TimeMustParse("2022-05-14T12:20:03+02:00") | ||||||
|  | 
 | ||||||
|  | 	if _, err := suite.db.UpdateReport(ctx, report); err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	dbReport, err := suite.db.GetReportByID(ctx, report.ID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 	suite.NotNil(dbReport) | ||||||
|  | 	suite.NotNil(dbReport.Account) | ||||||
|  | 	suite.NotNil(dbReport.TargetAccount) | ||||||
|  | 	suite.NotZero(dbReport.ActionTakenAt) | ||||||
|  | 	suite.NotNil(dbReport.ActionTakenByAccount) | ||||||
|  | 	suite.NotEmpty(dbReport.ActionTakenByAccountID) | ||||||
|  | 	suite.NotEmpty(dbReport.URI) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (suite *ReportTestSuite) TestDeleteReport() { | ||||||
|  | 	if err := suite.db.DeleteReportByID(context.Background(), suite.testReports["remote_account_1_report_local_account_2"].ID); err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	report, err := suite.db.GetReportByID(context.Background(), suite.testReports["remote_account_1_report_local_account_2"].ID) | ||||||
|  | 	suite.ErrorIs(err, db.ErrNoEntries) | ||||||
|  | 	suite.Nil(report) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestReportTestSuite(t *testing.T) { | ||||||
|  | 	suite.Run(t, new(ReportTestSuite)) | ||||||
|  | } | ||||||
|  | @ -67,6 +67,24 @@ func (s *statusDB) GetStatusByID(ctx context.Context, id string) (*gtsmodel.Stat | ||||||
| 	) | 	) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (s *statusDB) GetStatuses(ctx context.Context, ids []string) ([]*gtsmodel.Status, db.Error) { | ||||||
|  | 	statuses := make([]*gtsmodel.Status, 0, len(ids)) | ||||||
|  | 
 | ||||||
|  | 	for _, id := range ids { | ||||||
|  | 		// Attempt fetch from DB | ||||||
|  | 		status, err := s.GetStatusByID(ctx, id) | ||||||
|  | 		if err != nil { | ||||||
|  | 			log.Errorf("GetStatuses: error getting status %q: %v", id, err) | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Append status | ||||||
|  | 		statuses = append(statuses, status) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return statuses, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (s *statusDB) GetStatusByURI(ctx context.Context, uri string) (*gtsmodel.Status, db.Error) { | func (s *statusDB) GetStatusByURI(ctx context.Context, uri string) (*gtsmodel.Status, db.Error) { | ||||||
| 	return s.getStatus( | 	return s.getStatus( | ||||||
| 		ctx, | 		ctx, | ||||||
|  |  | ||||||
|  | @ -50,6 +50,48 @@ func (suite *StatusTestSuite) TestGetStatusByID() { | ||||||
| 	suite.True(*status.Likeable) | 	suite.True(*status.Likeable) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (suite *StatusTestSuite) TestGetStatusesByID() { | ||||||
|  | 	ids := []string{ | ||||||
|  | 		suite.testStatuses["local_account_1_status_1"].ID, | ||||||
|  | 		suite.testStatuses["local_account_2_status_3"].ID, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	statuses, err := suite.db.GetStatuses(context.Background(), ids) | ||||||
|  | 	if err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if len(statuses) != 2 { | ||||||
|  | 		suite.FailNow("expected 2 statuses in slice") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	status1 := statuses[0] | ||||||
|  | 	suite.NotNil(status1) | ||||||
|  | 	suite.NotNil(status1.Account) | ||||||
|  | 	suite.NotNil(status1.CreatedWithApplication) | ||||||
|  | 	suite.Nil(status1.BoostOf) | ||||||
|  | 	suite.Nil(status1.BoostOfAccount) | ||||||
|  | 	suite.Nil(status1.InReplyTo) | ||||||
|  | 	suite.Nil(status1.InReplyToAccount) | ||||||
|  | 	suite.True(*status1.Federated) | ||||||
|  | 	suite.True(*status1.Boostable) | ||||||
|  | 	suite.True(*status1.Replyable) | ||||||
|  | 	suite.True(*status1.Likeable) | ||||||
|  | 
 | ||||||
|  | 	status2 := statuses[1] | ||||||
|  | 	suite.NotNil(status2) | ||||||
|  | 	suite.NotNil(status2.Account) | ||||||
|  | 	suite.NotNil(status2.CreatedWithApplication) | ||||||
|  | 	suite.Nil(status2.BoostOf) | ||||||
|  | 	suite.Nil(status2.BoostOfAccount) | ||||||
|  | 	suite.Nil(status2.InReplyTo) | ||||||
|  | 	suite.Nil(status2.InReplyToAccount) | ||||||
|  | 	suite.True(*status2.Federated) | ||||||
|  | 	suite.True(*status2.Boostable) | ||||||
|  | 	suite.False(*status2.Replyable) | ||||||
|  | 	suite.False(*status2.Likeable) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (suite *StatusTestSuite) TestGetStatusByURI() { | func (suite *StatusTestSuite) TestGetStatusByURI() { | ||||||
| 	status, err := suite.db.GetStatusByURI(context.Background(), suite.testStatuses["local_account_2_status_3"].URI) | 	status, err := suite.db.GetStatusByURI(context.Background(), suite.testStatuses["local_account_2_status_3"].URI) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  |  | ||||||
|  | @ -41,6 +41,7 @@ type DB interface { | ||||||
| 	Mention | 	Mention | ||||||
| 	Notification | 	Notification | ||||||
| 	Relationship | 	Relationship | ||||||
|  | 	Report | ||||||
| 	Session | 	Session | ||||||
| 	Status | 	Status | ||||||
| 	Timeline | 	Timeline | ||||||
|  |  | ||||||
							
								
								
									
										41
									
								
								internal/db/report.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								internal/db/report.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,41 @@ | ||||||
|  | /* | ||||||
|  |    GoToSocial | ||||||
|  |    Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org | ||||||
|  | 
 | ||||||
|  |    This program is free software: you can redistribute it and/or modify | ||||||
|  |    it under the terms of the GNU Affero General Public License as published by | ||||||
|  |    the Free Software Foundation, either version 3 of the License, or | ||||||
|  |    (at your option) any later version. | ||||||
|  | 
 | ||||||
|  |    This program is distributed in the hope that it will be useful, | ||||||
|  |    but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  |    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  |    GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  |    You should have received a copy of the GNU Affero General Public License | ||||||
|  |    along with this program.  If not, see <http://www.gnu.org/licenses/>. | ||||||
|  | */ | ||||||
|  | 
 | ||||||
|  | package db | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 
 | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // Report handles getting/creation/deletion/updating of user reports/flags. | ||||||
|  | type Report interface { | ||||||
|  | 	// GetReportByID gets one report by its db id | ||||||
|  | 	GetReportByID(ctx context.Context, id string) (*gtsmodel.Report, Error) | ||||||
|  | 	// PutReport puts the given report in the database. | ||||||
|  | 	PutReport(ctx context.Context, report *gtsmodel.Report) Error | ||||||
|  | 	// UpdateReport updates one report by its db id. | ||||||
|  | 	// The given columns will be updated; if no columns are | ||||||
|  | 	// provided, then all columns will be updated. | ||||||
|  | 	// updated_at will also be updated, no need to pass this | ||||||
|  | 	// as a specific column. | ||||||
|  | 	UpdateReport(ctx context.Context, report *gtsmodel.Report, columns ...string) (*gtsmodel.Report, Error) | ||||||
|  | 	// DeleteReportByID deletes report with the given id. | ||||||
|  | 	DeleteReportByID(ctx context.Context, id string) Error | ||||||
|  | } | ||||||
|  | @ -29,6 +29,9 @@ type Status interface { | ||||||
| 	// GetStatusByID returns one status from the database, with no rel fields populated, only their linking ID / URIs | 	// GetStatusByID returns one status from the database, with no rel fields populated, only their linking ID / URIs | ||||||
| 	GetStatusByID(ctx context.Context, id string) (*gtsmodel.Status, Error) | 	GetStatusByID(ctx context.Context, id string) (*gtsmodel.Status, Error) | ||||||
| 
 | 
 | ||||||
|  | 	// GetStatuses gets a slice of statuses corresponding to the given status IDs. | ||||||
|  | 	GetStatuses(ctx context.Context, ids []string) ([]*gtsmodel.Status, Error) | ||||||
|  | 
 | ||||||
| 	// GetStatusByURI returns one status from the database, with no rel fields populated, only their linking ID / URIs | 	// GetStatusByURI returns one status from the database, with no rel fields populated, only their linking ID / URIs | ||||||
| 	GetStatusByURI(ctx context.Context, uri string) (*gtsmodel.Status, Error) | 	GetStatusByURI(ctx context.Context, uri string) (*gtsmodel.Status, Error) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										46
									
								
								internal/gtsmodel/report.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								internal/gtsmodel/report.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | ||||||
|  | /* | ||||||
|  |    GoToSocial | ||||||
|  |    Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org | ||||||
|  | 
 | ||||||
|  |    This program is free software: you can redistribute it and/or modify | ||||||
|  |    it under the terms of the GNU Affero General Public License as published by | ||||||
|  |    the Free Software Foundation, either version 3 of the License, or | ||||||
|  |    (at your option) any later version. | ||||||
|  | 
 | ||||||
|  |    This program is distributed in the hope that it will be useful, | ||||||
|  |    but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  |    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  |    GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  |    You should have received a copy of the GNU Affero General Public License | ||||||
|  |    along with this program.  If not, see <http://www.gnu.org/licenses/>. | ||||||
|  | */ | ||||||
|  | 
 | ||||||
|  | package gtsmodel | ||||||
|  | 
 | ||||||
|  | import "time" | ||||||
|  | 
 | ||||||
|  | // Report models a user-created reported about an account, which should be reviewed | ||||||
|  | // and acted upon by instance admins. | ||||||
|  | // | ||||||
|  | // This can be either a report created locally (on this instance) about a user on this | ||||||
|  | // or another instance, OR a report that was created remotely (on another instance) | ||||||
|  | // about a user on this instance, and received via the federated (s2s) API. | ||||||
|  | type Report 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 | ||||||
|  | 	URI                    string    `validate:"required,url" bun:",unique,nullzero,notnull"`                         // activitypub URI of this report | ||||||
|  | 	AccountID              string    `validate:"required,ulid" bun:"type:CHAR(26),nullzero,notnull"`                  // which account created this report | ||||||
|  | 	Account                *Account  `validate:"-" bun:"-"`                                                           // account corresponding to AccountID | ||||||
|  | 	TargetAccountID        string    `validate:"required,ulid" bun:"type:CHAR(26),nullzero,notnull"`                  // which account is targeted by this report | ||||||
|  | 	TargetAccount          *Account  `validate:"-" bun:"-"`                                                           // account corresponding to TargetAccountID | ||||||
|  | 	Comment                string    `validate:"-" bun:",nullzero"`                                                   // comment / explanation for this report, by the reporter | ||||||
|  | 	StatusIDs              []string  `validate:"dive,ulid" bun:"statuses,array"`                                      // database IDs of any statuses referenced by this report | ||||||
|  | 	Statuses               []*Status `validate:"-" bun:"-"`                                                           // statuses corresponding to StatusIDs | ||||||
|  | 	Forwarded              *bool     `validate:"-" bun:",nullzero,notnull,default:false"`                             // flag to indicate report should be forwarded to remote instance | ||||||
|  | 	ActionTaken            string    `validate:"-" bun:",nullzero"`                                                   // string description of what action was taken in response to this report | ||||||
|  | 	ActionTakenAt          time.Time `validate:"-" bun:"type:timestamptz,nullzero"`                                   // time at which action was taken, if any | ||||||
|  | 	ActionTakenByAccountID string    `validate:",omitempty,ulid" bun:"type:CHAR(26),nullzero"`                        // database ID of account which took action, if any | ||||||
|  | 	ActionTakenByAccount   *Account  `validate:"-" bun:"-"`                                                           // account corresponding to ActionTakenByID, if any | ||||||
|  | } | ||||||
|  | @ -36,12 +36,10 @@ const ( | ||||||
| 	followers = "followers" | 	followers = "followers" | ||||||
| 	following = "following" | 	following = "following" | ||||||
| 	liked     = "liked" | 	liked     = "liked" | ||||||
| 	// collections = "collections" |  | ||||||
| 	// featured    = "featured" |  | ||||||
| 	publicKey = "main-key" | 	publicKey = "main-key" | ||||||
| 	follow    = "follow" | 	follow    = "follow" | ||||||
| 	// update      = "updates" |  | ||||||
| 	blocks    = "blocks" | 	blocks    = "blocks" | ||||||
|  | 	reports   = "reports" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
|  | @ -141,6 +139,11 @@ var ( | ||||||
| 	// from eg /users/example_username/blocks/01F7XT5JZW1WMVSW1KADS8PVDH | 	// from eg /users/example_username/blocks/01F7XT5JZW1WMVSW1KADS8PVDH | ||||||
| 	BlockPath = regexp.MustCompile(blockPath) | 	BlockPath = regexp.MustCompile(blockPath) | ||||||
| 
 | 
 | ||||||
|  | 	reportPath = fmt.Sprintf(`^/?%s/(%s)$`, reports, ulid) | ||||||
|  | 	// ReportPath parses a path that validates and captures the ulid part | ||||||
|  | 	// from eg /reports/01GP3AWY4CRDVRNZKW0TEAMB5R | ||||||
|  | 	ReportPath = regexp.MustCompile(reportPath) | ||||||
|  | 
 | ||||||
| 	filePath = fmt.Sprintf(`^(%s)/([a-z]+)/([a-z]+)/(%s)\.([a-z]+)$`, ulid, ulid) | 	filePath = fmt.Sprintf(`^(%s)/([a-z]+)/([a-z]+)/(%s)\.([a-z]+)$`, ulid, ulid) | ||||||
| 	// FilePath parses a file storage path of the form [ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME] | 	// FilePath parses a file storage path of the form [ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME] | ||||||
| 	// eg 01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg | 	// eg 01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg | ||||||
|  |  | ||||||
|  | @ -28,7 +28,6 @@ import ( | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
| 	UsersPath        = "users"         // UsersPath is for serving users info | 	UsersPath        = "users"         // UsersPath is for serving users info | ||||||
| 	ActorsPath       = "actors"        // ActorsPath is for serving actors info |  | ||||||
| 	StatusesPath     = "statuses"      // StatusesPath is for serving statuses | 	StatusesPath     = "statuses"      // StatusesPath is for serving statuses | ||||||
| 	InboxPath        = "inbox"         // InboxPath represents the activitypub inbox location | 	InboxPath        = "inbox"         // InboxPath represents the activitypub inbox location | ||||||
| 	OutboxPath       = "outbox"        // OutboxPath represents the activitypub outbox location | 	OutboxPath       = "outbox"        // OutboxPath represents the activitypub outbox location | ||||||
|  | @ -41,6 +40,7 @@ const ( | ||||||
| 	FollowPath       = "follow"        // FollowPath used to generate the URI for an individual follow or follow request | 	FollowPath       = "follow"        // FollowPath used to generate the URI for an individual follow or follow request | ||||||
| 	UpdatePath       = "updates"       // UpdatePath is used to generate the URI for an account update | 	UpdatePath       = "updates"       // UpdatePath is used to generate the URI for an account update | ||||||
| 	BlocksPath       = "blocks"        // BlocksPath is used to generate the URI for a block | 	BlocksPath       = "blocks"        // BlocksPath is used to generate the URI for a block | ||||||
|  | 	ReportsPath      = "reports"       // ReportsPath is used to generate the URI for a report/flag | ||||||
| 	ConfirmEmailPath = "confirm_email" // ConfirmEmailPath is used to generate the URI for an email confirmation link | 	ConfirmEmailPath = "confirm_email" // ConfirmEmailPath is used to generate the URI for an email confirmation link | ||||||
| 	FileserverPath   = "fileserver"    // FileserverPath is a path component for serving attachments + media | 	FileserverPath   = "fileserver"    // FileserverPath is a path component for serving attachments + media | ||||||
| 	EmojiPath        = "emoji"         // EmojiPath represents the activitypub emoji location | 	EmojiPath        = "emoji"         // EmojiPath represents the activitypub emoji location | ||||||
|  | @ -107,6 +107,17 @@ func GenerateURIForBlock(username string, thisBlockID string) string { | ||||||
| 	return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, BlocksPath, thisBlockID) | 	return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, BlocksPath, thisBlockID) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // GenerateURIForReport returns the API URI for a new Flag activity -- something like: | ||||||
|  | // https://example.org/reports/01GP3AWY4CRDVRNZKW0TEAMB5R | ||||||
|  | // | ||||||
|  | // This path specifically doesn't contain any info about the user who did the reporting, | ||||||
|  | // to protect their privacy. | ||||||
|  | func GenerateURIForReport(thisReportID string) string { | ||||||
|  | 	protocol := config.GetProtocol() | ||||||
|  | 	host := config.GetHost() | ||||||
|  | 	return fmt.Sprintf("%s://%s/%s/%s", protocol, host, ReportsPath, thisReportID) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // GenerateURIForEmailConfirm returns a link for email confirmation -- something like: | // GenerateURIForEmailConfirm returns a link for email confirmation -- something like: | ||||||
| // https://example.org/confirm_email?token=490e337c-0162-454f-ac48-4b22bb92a205 | // https://example.org/confirm_email?token=490e337c-0162-454f-ac48-4b22bb92a205 | ||||||
| func GenerateURIForEmailConfirm(token string) string { | func GenerateURIForEmailConfirm(token string) string { | ||||||
|  | @ -228,6 +239,11 @@ func IsBlockPath(id *url.URL) bool { | ||||||
| 	return regexes.BlockPath.MatchString(id.Path) | 	return regexes.BlockPath.MatchString(id.Path) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // IsReportPath returns true if the given URL path corresponds to eg /reports/SOME_ULID_OF_A_REPORT | ||||||
|  | func IsReportPath(id *url.URL) bool { | ||||||
|  | 	return regexes.ReportPath.MatchString(id.Path) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // ParseStatusesPath returns the username and ulid from a path such as /users/example_username/statuses/SOME_ULID_OF_A_STATUS | // ParseStatusesPath returns the username and ulid from a path such as /users/example_username/statuses/SOME_ULID_OF_A_STATUS | ||||||
| func ParseStatusesPath(id *url.URL) (username string, ulid string, err error) { | func ParseStatusesPath(id *url.URL) (username string, ulid string, err error) { | ||||||
| 	matches := regexes.StatusesPath.FindStringSubmatch(id.Path) | 	matches := regexes.StatusesPath.FindStringSubmatch(id.Path) | ||||||
|  | @ -318,3 +334,14 @@ func ParseBlockPath(id *url.URL) (username string, ulid string, err error) { | ||||||
| 	ulid = matches[2] | 	ulid = matches[2] | ||||||
| 	return | 	return | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // ParseReportPath returns the ulid from a path such as /reports/SOME_ULID_OF_A_REPORT | ||||||
|  | func ParseReportPath(id *url.URL) (ulid string, err error) { | ||||||
|  | 	matches := regexes.ReportPath.FindStringSubmatch(id.Path) | ||||||
|  | 	if len(matches) != 2 { | ||||||
|  | 		err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches)) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	ulid = matches[1] | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ | ||||||
| 
 | 
 | ||||||
| set -eu | set -eu | ||||||
| 
 | 
 | ||||||
| EXPECT='{"account-domain":"peepee","accounts-allow-custom-css":true,"accounts-approval-required":false,"accounts-reason-required":false,"accounts-registration-open":true,"advanced-cookies-samesite":"strict","advanced-rate-limit-requests":6969,"advanced-throttling-multiplier":-1,"application-name":"gts","bind-address":"127.0.0.1","cache":{"gts":{"account-max-size":99,"account-sweep-freq":1000000000,"account-ttl":10800000000000,"block-max-size":100,"block-sweep-freq":10000000000,"block-ttl":300000000000,"domain-block-max-size":1000,"domain-block-sweep-freq":60000000000,"domain-block-ttl":86400000000000,"emoji-category-max-size":100,"emoji-category-sweep-freq":10000000000,"emoji-category-ttl":300000000000,"emoji-max-size":500,"emoji-sweep-freq":10000000000,"emoji-ttl":300000000000,"mention-max-size":500,"mention-sweep-freq":10000000000,"mention-ttl":300000000000,"notification-max-size":500,"notification-sweep-freq":10000000000,"notification-ttl":300000000000,"status-max-size":500,"status-sweep-freq":10000000000,"status-ttl":300000000000,"tombstone-max-size":100,"tombstone-sweep-freq":10000000000,"tombstone-ttl":300000000000,"user-max-size":100,"user-sweep-freq":10000000000,"user-ttl":300000000000}},"config-path":"internal/config/testdata/test.yaml","db-address":":memory:","db-database":"gotosocial_prod","db-password":"hunter2","db-port":6969,"db-tls-ca-cert":"","db-tls-mode":"disable","db-type":"sqlite","db-user":"sex-haver","dry-run":false,"email":"","host":"example.com","instance-deliver-to-shared-inboxes":false,"instance-expose-peers":true,"instance-expose-public-timeline":true,"instance-expose-suspended":true,"landing-page-user":"admin","letsencrypt-cert-dir":"/gotosocial/storage/certs","letsencrypt-email-address":"","letsencrypt-enabled":true,"letsencrypt-port":80,"log-db-queries":true,"log-level":"info","media-description-max-chars":5000,"media-description-min-chars":69,"media-emoji-local-max-size":420,"media-emoji-remote-max-size":420,"media-image-max-size":420,"media-remote-cache-days":30,"media-video-max-size":420,"oidc-client-id":"1234","oidc-client-secret":"shhhh its a secret","oidc-enabled":true,"oidc-idp-name":"sex-haver","oidc-issuer":"whoknows","oidc-link-existing":true,"oidc-scopes":["read","write"],"oidc-skip-verification":true,"password":"","path":"","port":6969,"protocol":"http","smtp-from":"queen.rip.in.piss@terfisland.org","smtp-host":"example.com","smtp-password":"hunter2","smtp-port":4269,"smtp-username":"sex-haver","software-version":"","statuses-cw-max-chars":420,"statuses-max-chars":69,"statuses-media-max-files":1,"statuses-poll-max-options":1,"statuses-poll-option-max-chars":50,"storage-backend":"local","storage-local-base-path":"/root/store","storage-s3-access-key":"minio","storage-s3-bucket":"gts","storage-s3-endpoint":"localhost:9000","storage-s3-proxy":true,"storage-s3-secret-key":"miniostorage","storage-s3-use-ssl":false,"syslog-address":"127.0.0.1:6969","syslog-enabled":true,"syslog-protocol":"udp","trusted-proxies":["127.0.0.1/32","docker.host.local"],"username":"","web-asset-base-dir":"/root","web-template-base-dir":"/root"}' | EXPECT='{"account-domain":"peepee","accounts-allow-custom-css":true,"accounts-approval-required":false,"accounts-reason-required":false,"accounts-registration-open":true,"advanced-cookies-samesite":"strict","advanced-rate-limit-requests":6969,"advanced-throttling-multiplier":-1,"application-name":"gts","bind-address":"127.0.0.1","cache":{"gts":{"account-max-size":99,"account-sweep-freq":1000000000,"account-ttl":10800000000000,"block-max-size":100,"block-sweep-freq":10000000000,"block-ttl":300000000000,"domain-block-max-size":1000,"domain-block-sweep-freq":60000000000,"domain-block-ttl":86400000000000,"emoji-category-max-size":100,"emoji-category-sweep-freq":10000000000,"emoji-category-ttl":300000000000,"emoji-max-size":500,"emoji-sweep-freq":10000000000,"emoji-ttl":300000000000,"mention-max-size":500,"mention-sweep-freq":10000000000,"mention-ttl":300000000000,"notification-max-size":500,"notification-sweep-freq":10000000000,"notification-ttl":300000000000,"report-max-size":100,"report-sweep-freq":10000000000,"report-ttl":300000000000,"status-max-size":500,"status-sweep-freq":10000000000,"status-ttl":300000000000,"tombstone-max-size":100,"tombstone-sweep-freq":10000000000,"tombstone-ttl":300000000000,"user-max-size":100,"user-sweep-freq":10000000000,"user-ttl":300000000000}},"config-path":"internal/config/testdata/test.yaml","db-address":":memory:","db-database":"gotosocial_prod","db-password":"hunter2","db-port":6969,"db-tls-ca-cert":"","db-tls-mode":"disable","db-type":"sqlite","db-user":"sex-haver","dry-run":false,"email":"","host":"example.com","instance-deliver-to-shared-inboxes":false,"instance-expose-peers":true,"instance-expose-public-timeline":true,"instance-expose-suspended":true,"landing-page-user":"admin","letsencrypt-cert-dir":"/gotosocial/storage/certs","letsencrypt-email-address":"","letsencrypt-enabled":true,"letsencrypt-port":80,"log-db-queries":true,"log-level":"info","media-description-max-chars":5000,"media-description-min-chars":69,"media-emoji-local-max-size":420,"media-emoji-remote-max-size":420,"media-image-max-size":420,"media-remote-cache-days":30,"media-video-max-size":420,"oidc-client-id":"1234","oidc-client-secret":"shhhh its a secret","oidc-enabled":true,"oidc-idp-name":"sex-haver","oidc-issuer":"whoknows","oidc-link-existing":true,"oidc-scopes":["read","write"],"oidc-skip-verification":true,"password":"","path":"","port":6969,"protocol":"http","smtp-from":"queen.rip.in.piss@terfisland.org","smtp-host":"example.com","smtp-password":"hunter2","smtp-port":4269,"smtp-username":"sex-haver","software-version":"","statuses-cw-max-chars":420,"statuses-max-chars":69,"statuses-media-max-files":1,"statuses-poll-max-options":1,"statuses-poll-option-max-chars":50,"storage-backend":"local","storage-local-base-path":"/root/store","storage-s3-access-key":"minio","storage-s3-bucket":"gts","storage-s3-endpoint":"localhost:9000","storage-s3-proxy":true,"storage-s3-secret-key":"miniostorage","storage-s3-use-ssl":false,"syslog-address":"127.0.0.1:6969","syslog-enabled":true,"syslog-protocol":"udp","trusted-proxies":["127.0.0.1/32","docker.host.local"],"username":"","web-asset-base-dir":"/root","web-template-base-dir":"/root"}' | ||||||
| 
 | 
 | ||||||
| # Set all the environment variables to  | # Set all the environment variables to  | ||||||
| # ensure that these are parsed without panic | # ensure that these are parsed without panic | ||||||
|  |  | ||||||
|  | @ -58,6 +58,7 @@ var testModels = []interface{}{ | ||||||
| 	>smodel.Client{}, | 	>smodel.Client{}, | ||||||
| 	>smodel.EmojiCategory{}, | 	>smodel.EmojiCategory{}, | ||||||
| 	>smodel.Tombstone{}, | 	>smodel.Tombstone{}, | ||||||
|  | 	>smodel.Report{}, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // NewTestDB returns a new initialized, empty database for testing. | // NewTestDB returns a new initialized, empty database for testing. | ||||||
|  | @ -157,6 +158,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) { | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	for _, v := range NewTestReports() { | ||||||
|  | 		if err := db.Put(ctx, v); err != nil { | ||||||
|  | 			log.Panic(err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	for _, v := range NewTestDomainBlocks() { | 	for _, v := range NewTestDomainBlocks() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(err) | 			log.Panic(err) | ||||||
|  |  | ||||||
|  | @ -1971,6 +1971,36 @@ func NewTestBlocks() map[string]*gtsmodel.Block { | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func NewTestReports() map[string]*gtsmodel.Report { | ||||||
|  | 	return map[string]*gtsmodel.Report{ | ||||||
|  | 		"local_account_2_report_remote_account_1": { | ||||||
|  | 			ID:              "01GP3AWY4CRDVRNZKW0TEAMB5R", | ||||||
|  | 			CreatedAt:       TimeMustParse("2022-05-14T12:20:03+02:00"), | ||||||
|  | 			UpdatedAt:       TimeMustParse("2022-05-14T12:20:03+02:00"), | ||||||
|  | 			URI:             "http://localhost:8080/01GP3AWY4CRDVRNZKW0TEAMB5R", | ||||||
|  | 			AccountID:       "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|  | 			TargetAccountID: "01F8MH5ZK5VRH73AKHQM6Y9VNX", | ||||||
|  | 			Comment:         "dark souls sucks, please yeet this nerd", | ||||||
|  | 			StatusIDs:       []string{"01FVW7JHQFSFK166WWKR8CBA6M"}, | ||||||
|  | 			Forwarded:       TrueBool(), | ||||||
|  | 		}, | ||||||
|  | 		"remote_account_1_report_local_account_2": { | ||||||
|  | 			ID:                     "01GP3DFY9XQ1TJMZT5BGAZPXX7", | ||||||
|  | 			CreatedAt:              TimeMustParse("2022-05-15T16:20:12+02:00"), | ||||||
|  | 			UpdatedAt:              TimeMustParse("2022-05-15T16:20:12+02:00"), | ||||||
|  | 			URI:                    "http://fossbros-anonymous.io/87fb1478-ac46-406a-8463-96ce05645219", | ||||||
|  | 			AccountID:              "01F8MH5ZK5VRH73AKHQM6Y9VNX", | ||||||
|  | 			TargetAccountID:        "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|  | 			Comment:                "this is a turtle, not a person, therefore should not be a poster", | ||||||
|  | 			StatusIDs:              []string{}, | ||||||
|  | 			Forwarded:              TrueBool(), | ||||||
|  | 			ActionTaken:            "user was warned not to be a turtle anymore", | ||||||
|  | 			ActionTakenAt:          TimeMustParse("2022-05-15T17:01:56+02:00"), | ||||||
|  | 			ActionTakenByAccountID: "01AY6P665V14JJR0AFVRT7311Y", | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // ActivityWithSignature wraps a pub.Activity along with its signature headers, for testing. | // ActivityWithSignature wraps a pub.Activity along with its signature headers, for testing. | ||||||
| type ActivityWithSignature struct { | type ActivityWithSignature struct { | ||||||
| 	Activity        pub.Activity | 	Activity        pub.Activity | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue