[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:
tobi 2023-01-10 15:19:05 +01:00 committed by GitHub
parent 36aa6854bd
commit d6487933c7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 693 additions and 6 deletions

View file

@ -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"

24
internal/cache/gts.go vendored
View file

@ -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"},

View file

@ -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"`

View file

@ -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,

View file

@ -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()

View file

@ -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,
},

View file

@ -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() {

View 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(&gtsmodel.Report{}).IfNotExists().Exec(ctx); err != nil {
return err
}
if _, err := tx.
NewCreateIndex().
Model(&gtsmodel.Report{}).
Index("report_account_id_idx").
Column("account_id").
Exec(ctx); err != nil {
return err
}
if _, err := tx.
NewCreateIndex().
Model(&gtsmodel.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
View 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
}

View 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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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))
}

View file

@ -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,

View file

@ -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 {

View file

@ -41,6 +41,7 @@ type DB interface {
Mention
Notification
Relationship
Report
Session
Status
Timeline

41
internal/db/report.go Normal file
View 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
}

View file

@ -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)

View 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
}

View file

@ -36,12 +36,10 @@ const (
followers = "followers"
following = "following"
liked = "liked"
// collections = "collections"
// featured = "featured"
publicKey = "main-key"
follow = "follow"
// update = "updates"
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

View file

@ -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
}

View file

@ -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

View file

@ -58,6 +58,7 @@ var testModels = []interface{}{
&gtsmodel.Client{},
&gtsmodel.EmojiCategory{},
&gtsmodel.Tombstone{},
&gtsmodel.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)

View file

@ -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