diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cache/gts.go | 24 | ||||
| -rw-r--r-- | internal/config/config.go | 4 | ||||
| -rw-r--r-- | internal/config/defaults.go | 4 | ||||
| -rw-r--r-- | internal/config/helpers.gen.go | 75 | ||||
| -rw-r--r-- | internal/db/bundb/bundb.go | 5 | ||||
| -rw-r--r-- | internal/db/bundb/bundb_test.go | 2 | ||||
| -rw-r--r-- | internal/db/bundb/migrations/20230105171144_report_model.go | 66 | ||||
| -rw-r--r-- | internal/db/bundb/report.go | 138 | ||||
| -rw-r--r-- | internal/db/bundb/report_test.go | 147 | ||||
| -rw-r--r-- | internal/db/bundb/status.go | 18 | ||||
| -rw-r--r-- | internal/db/bundb/status_test.go | 42 | ||||
| -rw-r--r-- | internal/db/db.go | 1 | ||||
| -rw-r--r-- | internal/db/report.go | 41 | ||||
| -rw-r--r-- | internal/db/status.go | 3 | ||||
| -rw-r--r-- | internal/gtsmodel/report.go | 46 | ||||
| -rw-r--r-- | internal/regexes/regexes.go | 11 | ||||
| -rw-r--r-- | internal/uris/uri.go | 29 | 
17 files changed, 651 insertions, 5 deletions
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 @@ var Defaults = Configuration{  			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 <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) +	} +} 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 <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 +} 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 <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)) +} 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 (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) {  	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 <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 +} 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 <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 +} 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 @@ const (  	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 @@ var (  	// 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 @@ import (  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 @@ const (  	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 +}  | 
