diff --git a/example/config.yaml b/example/config.yaml index 12e49e420..71d26e50c 100644 --- a/example/config.yaml +++ b/example/config.yaml @@ -207,6 +207,10 @@ cache: notification-ttl: "5m" notification-sweep-freq: "10s" + report-max-size: 100 + report-ttl: "5m" + report-sweep-freq: "10s" + status-max-size: 500 status-ttl: "5m" status-sweep-freq: "10s" diff --git a/internal/cache/gts.go b/internal/cache/gts.go index 8b087b6ad..f3f7a33ef 100644 --- a/internal/cache/gts.go +++ b/internal/cache/gts.go @@ -57,6 +57,9 @@ type GTSCaches interface { // Notification provides access to the gtsmodel Notification database cache. 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() *result.Cache[*gtsmodel.Status] @@ -80,6 +83,7 @@ type gtsCaches struct { emojiCategory *result.Cache[*gtsmodel.EmojiCategory] mention *result.Cache[*gtsmodel.Mention] notification *result.Cache[*gtsmodel.Notification] + report *result.Cache[*gtsmodel.Report] status *result.Cache[*gtsmodel.Status] tombstone *result.Cache[*gtsmodel.Tombstone] user *result.Cache[*gtsmodel.User] @@ -93,6 +97,7 @@ func (c *gtsCaches) Init() { c.initEmojiCategory() c.initMention() c.initNotification() + c.initReport() c.initStatus() c.initTombstone() c.initUser() @@ -120,6 +125,9 @@ func (c *gtsCaches) Start() { tryUntil("starting gtsmodel.Notification cache", 5, func() bool { 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 { 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.Mention cache", 5, c.mention.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.Tombstone cache", 5, c.tombstone.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 } +func (c *gtsCaches) Report() *result.Cache[*gtsmodel.Report] { + return c.report +} + func (c *gtsCaches) Status() *result.Cache[*gtsmodel.Status] { return c.status } @@ -267,6 +280,17 @@ func (c *gtsCaches) initNotification() { 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() { c.status = result.New([]result.Lookup{ {Name: "ID"}, diff --git a/internal/config/config.go b/internal/config/config.go index 107a94285..ec8675f2d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -175,6 +175,10 @@ type GTSCacheConfiguration struct { NotificationTTL time.Duration `name:"notification-ttl"` 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"` StatusTTL time.Duration `name:"status-ttl"` StatusSweepFreq time.Duration `name:"status-sweep-freq"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 26fecaa73..4d61bec05 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -138,6 +138,10 @@ NotificationTTL: time.Minute * 5, NotificationSweepFreq: time.Second * 10, + ReportMaxSize: 100, + ReportTTL: time.Minute * 5, + ReportSweepFreq: time.Second * 10, + StatusMaxSize: 500, StatusTTL: time.Minute * 5, StatusSweepFreq: time.Second * 10, diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index a1f920ac7..f340360b2 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -2378,6 +2378,81 @@ func GetCacheGTSNotificationSweepFreq() time.Duration { // SetCacheGTSNotificationSweepFreq safely sets the value for global configuration 'Cache.GTS.NotificationSweepFreq' field 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 func (st *ConfigState) GetCacheGTSStatusMaxSize() (v int) { st.mutex.Lock() diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index e749484a8..1225b2bb0 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -83,6 +83,7 @@ type DBService struct { db.Mention db.Notification db.Relationship + db.Report db.Session db.Status db.Timeline @@ -197,6 +198,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) { conn: conn, state: state, }, + Report: &reportDB{ + conn: conn, + state: state, + }, Session: &sessionDB{ conn: conn, }, diff --git a/internal/db/bundb/bundb_test.go b/internal/db/bundb/bundb_test.go index 45d2e70a7..e050c2b5d 100644 --- a/internal/db/bundb/bundb_test.go +++ b/internal/db/bundb/bundb_test.go @@ -42,6 +42,7 @@ type BunDBStandardTestSuite struct { testMentions map[string]*gtsmodel.Mention testFollows map[string]*gtsmodel.Follow testEmojis map[string]*gtsmodel.Emoji + testReports map[string]*gtsmodel.Report } func (suite *BunDBStandardTestSuite) SetupSuite() { @@ -56,6 +57,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() { suite.testMentions = testrig.NewTestMentions() suite.testFollows = testrig.NewTestFollows() suite.testEmojis = testrig.NewTestEmojis() + suite.testReports = testrig.NewTestReports() } func (suite *BunDBStandardTestSuite) SetupTest() { diff --git a/internal/db/bundb/migrations/20230105171144_report_model.go b/internal/db/bundb/migrations/20230105171144_report_model.go new file mode 100644 index 000000000..b175e2995 --- /dev/null +++ b/internal/db/bundb/migrations/20230105171144_report_model.go @@ -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 . +*/ + +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) + } +} diff --git a/internal/db/bundb/report.go b/internal/db/bundb/report.go new file mode 100644 index 000000000..8cc1d8de9 --- /dev/null +++ b/internal/db/bundb/report.go @@ -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 . +*/ + +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 +} diff --git a/internal/db/bundb/report_test.go b/internal/db/bundb/report_test.go new file mode 100644 index 000000000..85bc4b36f --- /dev/null +++ b/internal/db/bundb/report_test.go @@ -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 . +*/ + +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)) +} diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go index b52c06978..709105f72 100644 --- a/internal/db/bundb/status.go +++ b/internal/db/bundb/status.go @@ -67,6 +67,24 @@ func(status *gtsmodel.Status) error { ) } +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) { return s.getStatus( ctx, diff --git a/internal/db/bundb/status_test.go b/internal/db/bundb/status_test.go index bef8c7912..d86e0bcf9 100644 --- a/internal/db/bundb/status_test.go +++ b/internal/db/bundb/status_test.go @@ -50,6 +50,48 @@ func (suite *StatusTestSuite) TestGetStatusByID() { 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() { status, err := suite.db.GetStatusByURI(context.Background(), suite.testStatuses["local_account_2_status_3"].URI) if err != nil { diff --git a/internal/db/db.go b/internal/db/db.go index efe867e3e..aa1929da9 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -41,6 +41,7 @@ type DB interface { Mention Notification Relationship + Report Session Status Timeline diff --git a/internal/db/report.go b/internal/db/report.go new file mode 100644 index 000000000..216e10fdd --- /dev/null +++ b/internal/db/report.go @@ -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 . +*/ + +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 +} diff --git a/internal/db/status.go b/internal/db/status.go index f854664c8..15d1362f5 100644 --- a/internal/db/status.go +++ b/internal/db/status.go @@ -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(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(ctx context.Context, uri string) (*gtsmodel.Status, Error) diff --git a/internal/gtsmodel/report.go b/internal/gtsmodel/report.go new file mode 100644 index 000000000..782519af1 --- /dev/null +++ b/internal/gtsmodel/report.go @@ -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 . +*/ + +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 +} diff --git a/internal/regexes/regexes.go b/internal/regexes/regexes.go index 8c5b0b601..4c9d48dac 100644 --- a/internal/regexes/regexes.go +++ b/internal/regexes/regexes.go @@ -36,12 +36,10 @@ followers = "followers" following = "following" liked = "liked" - // collections = "collections" - // featured = "featured" publicKey = "main-key" follow = "follow" - // update = "updates" - blocks = "blocks" + blocks = "blocks" + reports = "reports" ) const ( @@ -141,6 +139,11 @@ // from eg /users/example_username/blocks/01F7XT5JZW1WMVSW1KADS8PVDH 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 parses a file storage path of the form [ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME] // eg 01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg diff --git a/internal/uris/uri.go b/internal/uris/uri.go index 287e66439..f6e06ca25 100644 --- a/internal/uris/uri.go +++ b/internal/uris/uri.go @@ -28,7 +28,6 @@ const ( UsersPath = "users" // UsersPath is for serving users info - ActorsPath = "actors" // ActorsPath is for serving actors info StatusesPath = "statuses" // StatusesPath is for serving statuses InboxPath = "inbox" // InboxPath represents the activitypub inbox location OutboxPath = "outbox" // OutboxPath represents the activitypub outbox location @@ -41,6 +40,7 @@ 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 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 FileserverPath = "fileserver" // FileserverPath is a path component for serving attachments + media 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) } +// 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: // https://example.org/confirm_email?token=490e337c-0162-454f-ac48-4b22bb92a205 func GenerateURIForEmailConfirm(token string) string { @@ -228,6 +239,11 @@ func IsBlockPath(id *url.URL) bool { 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 func ParseStatusesPath(id *url.URL) (username string, ulid string, err error) { matches := regexes.StatusesPath.FindStringSubmatch(id.Path) @@ -318,3 +334,14 @@ func ParseBlockPath(id *url.URL) (username string, ulid string, err error) { ulid = matches[2] 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 +} diff --git a/test/envparsing.sh b/test/envparsing.sh index 0de6d0bcf..b7d3083be 100755 --- a/test/envparsing.sh +++ b/test/envparsing.sh @@ -2,7 +2,7 @@ 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 # ensure that these are parsed without panic diff --git a/testrig/db.go b/testrig/db.go index ed9c1b916..4304050cf 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -58,6 +58,7 @@ >smodel.Client{}, >smodel.EmojiCategory{}, >smodel.Tombstone{}, + >smodel.Report{}, } // 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() { if err := db.Put(ctx, v); err != nil { log.Panic(err) diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 6845abdb9..88c5df77a 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -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. type ActivityWithSignature struct { Activity pub.Activity