[chore] Add interaction policy gtsmodels (#3075)

* [chore] introduce interaction policy gts models

* update migration a smidge

* fix copy paste typo

* update migration

* use int for InteractionType
This commit is contained in:
tobi 2024-07-11 16:44:29 +02:00 committed by GitHub
commit 5bc567196b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1318 additions and 531 deletions

View file

@ -115,15 +115,6 @@ func (suite *AccountTestSuite) populateTestStatus(testAccountKey string, status
if status.Federated == nil {
status.Federated = util.Ptr(true)
}
if status.Boostable == nil {
status.Boostable = util.Ptr(true)
}
if status.Likeable == nil {
status.Likeable = util.Ptr(true)
}
if status.Replyable == nil {
status.Replyable = util.Ptr(true)
}
if inReplyTo != nil {
status.InReplyToAccountID = inReplyTo.AccountID

View file

@ -60,6 +60,7 @@ type DBService struct {
db.Emoji
db.HeaderFilter
db.Instance
db.Interaction
db.Filter
db.List
db.Marker
@ -203,6 +204,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
db: db,
state: state,
},
Interaction: &interactionDB{
db: db,
state: state,
},
Filter: &filterDB{
db: db,
state: state,

View file

@ -0,0 +1,149 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package bundb
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/uptrace/bun"
)
type interactionDB struct {
db *bun.DB
state *state.State
}
func (r *interactionDB) newInteractionApprovalQ(approval interface{}) *bun.SelectQuery {
return r.db.
NewSelect().
Model(approval)
}
func (r *interactionDB) GetInteractionApprovalByID(ctx context.Context, id string) (*gtsmodel.InteractionApproval, error) {
return r.getInteractionApproval(
ctx,
"ID",
func(approval *gtsmodel.InteractionApproval) error {
return r.
newInteractionApprovalQ(approval).
Where("? = ?", bun.Ident("interaction_approval.id"), id).
Scan(ctx)
},
id,
)
}
func (r *interactionDB) GetInteractionApprovalByURI(ctx context.Context, uri string) (*gtsmodel.InteractionApproval, error) {
return r.getInteractionApproval(
ctx,
"URI",
func(approval *gtsmodel.InteractionApproval) error {
return r.
newInteractionApprovalQ(approval).
Where("? = ?", bun.Ident("interaction_approval.uri"), uri).
Scan(ctx)
},
uri,
)
}
func (r *interactionDB) getInteractionApproval(
ctx context.Context,
lookup string,
dbQuery func(*gtsmodel.InteractionApproval) error,
keyParts ...any,
) (*gtsmodel.InteractionApproval, error) {
// Fetch approval from database cache with loader callback
approval, err := r.state.Caches.GTS.InteractionApproval.LoadOne(lookup, func() (*gtsmodel.InteractionApproval, error) {
var approval gtsmodel.InteractionApproval
// Not cached! Perform database query
if err := dbQuery(&approval); err != nil {
return nil, err
}
return &approval, nil
}, keyParts...)
if err != nil {
// Error already processed.
return nil, err
}
if gtscontext.Barebones(ctx) {
// Only a barebones model was requested.
return approval, nil
}
if err := r.PopulateInteractionApproval(ctx, approval); err != nil {
return nil, err
}
return approval, nil
}
func (r *interactionDB) PopulateInteractionApproval(ctx context.Context, approval *gtsmodel.InteractionApproval) error {
var (
err error
errs = gtserror.NewMultiError(2)
)
if approval.Account == nil {
// Account is not set, fetch from the database.
approval.Account, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
approval.AccountID,
)
if err != nil {
errs.Appendf("error populating interactionApproval account: %w", err)
}
}
if approval.InteractingAccount == nil {
// InteractingAccount is not set, fetch from the database.
approval.InteractingAccount, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
approval.InteractingAccountID,
)
if err != nil {
errs.Appendf("error populating interactionApproval interacting account: %w", err)
}
}
return errs.Combine()
}
func (r *interactionDB) PutInteractionApproval(ctx context.Context, approval *gtsmodel.InteractionApproval) error {
return r.state.Caches.GTS.InteractionApproval.Store(approval, func() error {
_, err := r.db.NewInsert().Model(approval).Exec(ctx)
return err
})
}
func (r *interactionDB) DeleteInteractionApprovalByID(ctx context.Context, id string) error {
defer r.state.Caches.GTS.InteractionApproval.Invalidate("ID", id)
_, err := r.db.NewDelete().
TableExpr("? AS ?", bun.Ident("interaction_approvals"), bun.Ident("interaction_approval")).
Where("? = ?", bun.Ident("interaction_approval.id"), id).
Exec(ctx)
return err
}

View file

@ -0,0 +1,264 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/log"
oldmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20240620074530_interaction_policy"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
log.Info(ctx, "migrating statuses and account settings to interaction policy model, please wait...")
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Add new columns for interaction
// policies + related fields.
type spec struct {
table string
column string
columnType string
defaultVal string
}
for _, spec := range []spec{
// Statuses.
{
table: "statuses",
column: "interaction_policy",
columnType: "JSONB",
defaultVal: "",
},
{
table: "statuses",
column: "pending_approval",
columnType: "BOOLEAN",
defaultVal: "DEFAULT false",
},
{
table: "statuses",
column: "approved_by_uri",
columnType: "varchar",
defaultVal: "",
},
// Status faves.
{
table: "status_faves",
column: "pending_approval",
columnType: "BOOLEAN",
defaultVal: "DEFAULT false",
},
{
table: "status_faves",
column: "approved_by_uri",
columnType: "varchar",
defaultVal: "",
},
// Columns that must be added to the
// `account_settings` table to populate
// default interaction policies for
// different status visibilities.
{
table: "account_settings",
column: "interaction_policy_direct",
columnType: "JSONB",
defaultVal: "",
},
{
table: "account_settings",
column: "interaction_policy_mutuals_only",
columnType: "JSONB",
defaultVal: "",
},
{
table: "account_settings",
column: "interaction_policy_followers_only",
columnType: "JSONB",
defaultVal: "",
},
{
table: "account_settings",
column: "interaction_policy_unlocked",
columnType: "JSONB",
defaultVal: "",
},
{
table: "account_settings",
column: "interaction_policy_public",
columnType: "JSONB",
defaultVal: "",
},
} {
exists, err := doesColumnExist(ctx, tx,
spec.table, spec.column,
)
if err != nil {
// Real error.
return err
} else if exists {
// Already created.
continue
}
args := []any{
bun.Ident(spec.table),
bun.Ident(spec.column),
bun.Safe(spec.columnType),
}
qStr := "ALTER TABLE ? ADD COLUMN ? ?"
if spec.defaultVal != "" {
qStr += " ?"
args = append(args, bun.Safe(spec.defaultVal))
}
if _, err := tx.ExecContext(ctx, qStr, args...); err != nil {
return err
}
}
// Select each locally-created status
// with non-default old flags set.
oldStatuses := []oldmodel.Status{}
if err := tx.
NewSelect().
Model(&oldStatuses).
Column("id", "likeable", "replyable", "boostable", "visibility").
Where("? = ?", bun.Ident("local"), true).
WhereGroup(" AND ", func(sq *bun.SelectQuery) *bun.SelectQuery {
return sq.
Where("? = ?", bun.Ident("likeable"), false).
WhereOr("? = ?", bun.Ident("replyable"), false).
WhereOr("? = ?", bun.Ident("boostable"), false)
}).
Scan(ctx); err != nil {
return err
}
// For each status found in this way, update
// to new version of interaction policy.
for _, oldStatus := range oldStatuses {
// Start with default policy for this visibility.
v := gtsmodel.Visibility(oldStatus.Visibility)
policy := gtsmodel.DefaultInteractionPolicyFor(v)
if !*oldStatus.Likeable {
// Only author can like.
policy.CanLike = gtsmodel.PolicyRules{
Always: gtsmodel.PolicyValues{
gtsmodel.PolicyValueAuthor,
},
WithApproval: make(gtsmodel.PolicyValues, 0),
}
}
if !*oldStatus.Replyable {
// Only author + mentioned can Reply.
policy.CanReply = gtsmodel.PolicyRules{
Always: gtsmodel.PolicyValues{
gtsmodel.PolicyValueAuthor,
gtsmodel.PolicyValueMentioned,
},
WithApproval: make(gtsmodel.PolicyValues, 0),
}
}
if !*oldStatus.Boostable {
// Only author can Announce.
policy.CanAnnounce = gtsmodel.PolicyRules{
Always: gtsmodel.PolicyValues{
gtsmodel.PolicyValueAuthor,
},
WithApproval: make(gtsmodel.PolicyValues, 0),
}
}
// Update status with the new interaction policy.
newStatus := &gtsmodel.Status{
ID: oldStatus.ID,
InteractionPolicy: policy,
}
if _, err := tx.
NewUpdate().
Model(newStatus).
Column("interaction_policy").
Where("? = ?", bun.Ident("id"), newStatus.ID).
Exec(ctx); err != nil {
return err
}
}
// Drop now unused columns from statuses table.
oldColumns := []string{
"likeable",
"replyable",
"boostable",
}
for _, column := range oldColumns {
if _, err := tx.
NewDropColumn().
Table("statuses").
Column(column).
Exec(ctx); err != nil {
return err
}
}
// Add new indexes.
if _, err := tx.
NewCreateIndex().
Table("statuses").
Index("statuses_pending_approval_idx").
Column("pending_approval").
IfNotExists().
Exec(ctx); err != nil {
return err
}
if _, err := tx.
NewCreateIndex().
Table("status_faves").
Index("status_faves_pending_approval_idx").
Column("pending_approval").
IfNotExists().
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)
}
}

View file

@ -0,0 +1,61 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import (
"time"
)
type Status struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
FetchedAt time.Time `bun:"type:timestamptz,nullzero"`
PinnedAt time.Time `bun:"type:timestamptz,nullzero"`
URI string `bun:",unique,nullzero,notnull"`
URL string `bun:",nullzero"`
Content string `bun:""`
AttachmentIDs []string `bun:"attachments,array"`
TagIDs []string `bun:"tags,array"`
MentionIDs []string `bun:"mentions,array"`
EmojiIDs []string `bun:"emojis,array"`
Local *bool `bun:",nullzero,notnull,default:false"`
AccountID string `bun:"type:CHAR(26),nullzero,notnull"`
AccountURI string `bun:",nullzero,notnull"`
InReplyToID string `bun:"type:CHAR(26),nullzero"`
InReplyToURI string `bun:",nullzero"`
InReplyToAccountID string `bun:"type:CHAR(26),nullzero"`
InReplyTo *Status `bun:"-"`
BoostOfID string `bun:"type:CHAR(26),nullzero"`
BoostOfURI string `bun:"-"`
BoostOfAccountID string `bun:"type:CHAR(26),nullzero"`
BoostOf *Status `bun:"-"`
ThreadID string `bun:"type:CHAR(26),nullzero"`
PollID string `bun:"type:CHAR(26),nullzero"`
ContentWarning string `bun:",nullzero"`
Visibility string `bun:",nullzero,notnull"`
Sensitive *bool `bun:",nullzero,notnull,default:false"`
Language string `bun:",nullzero"`
CreatedWithApplicationID string `bun:"type:CHAR(26),nullzero"`
ActivityStreamsType string `bun:",nullzero,notnull"`
Text string `bun:""`
Federated *bool `bun:",notnull"`
Boostable *bool `bun:",notnull"`
Replyable *bool `bun:",notnull"`
Likeable *bool `bun:",notnull"`
}

View file

@ -44,9 +44,6 @@ func (suite *StatusTestSuite) TestGetStatusByID() {
suite.Nil(status.InReplyTo)
suite.Nil(status.InReplyToAccount)
suite.True(*status.Federated)
suite.True(*status.Boostable)
suite.True(*status.Replyable)
suite.True(*status.Likeable)
}
func (suite *StatusTestSuite) TestGetStatusesByIDs() {
@ -73,9 +70,6 @@ func (suite *StatusTestSuite) TestGetStatusesByIDs() {
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)
@ -86,9 +80,6 @@ func (suite *StatusTestSuite) TestGetStatusesByIDs() {
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() {
@ -104,9 +95,6 @@ func (suite *StatusTestSuite) TestGetStatusByURI() {
suite.Nil(status.InReplyTo)
suite.Nil(status.InReplyToAccount)
suite.True(*status.Federated)
suite.True(*status.Boostable)
suite.False(*status.Replyable)
suite.False(*status.Likeable)
}
func (suite *StatusTestSuite) TestGetStatusWithExtras() {
@ -121,9 +109,6 @@ func (suite *StatusTestSuite) TestGetStatusWithExtras() {
suite.NotEmpty(status.Attachments)
suite.NotEmpty(status.Emojis)
suite.True(*status.Federated)
suite.True(*status.Boostable)
suite.True(*status.Replyable)
suite.True(*status.Likeable)
}
func (suite *StatusTestSuite) TestGetStatusWithMention() {
@ -138,9 +123,6 @@ func (suite *StatusTestSuite) TestGetStatusWithMention() {
suite.NotEmpty(status.InReplyToID)
suite.NotEmpty(status.InReplyToAccountID)
suite.True(*status.Federated)
suite.True(*status.Boostable)
suite.True(*status.Replyable)
suite.True(*status.Likeable)
}
// The below test was originally used to ensure that a second

View file

@ -23,6 +23,7 @@ import (
"errors"
"fmt"
"slices"
"time"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
@ -77,6 +78,21 @@ func (s *statusFaveDB) GetStatusFaveByID(ctx context.Context, id string) (*gtsmo
)
}
func (s *statusFaveDB) GetStatusFaveByURI(ctx context.Context, uri string) (*gtsmodel.StatusFave, error) {
return s.getStatusFave(
ctx,
"URI",
func(fave *gtsmodel.StatusFave) error {
return s.db.
NewSelect().
Model(fave).
Where("? = ?", bun.Ident("uri"), uri).
Scan(ctx)
},
uri,
)
}
func (s *statusFaveDB) getStatusFave(ctx context.Context, lookup string, dbQuery func(*gtsmodel.StatusFave) error, keyParts ...any) (*gtsmodel.StatusFave, error) {
// Fetch status fave from database cache with loader callback
fave, err := s.state.Caches.GTS.StatusFave.LoadOne(lookup, func() (*gtsmodel.StatusFave, error) {
@ -242,6 +258,26 @@ func (s *statusFaveDB) PutStatusFave(ctx context.Context, fave *gtsmodel.StatusF
})
}
func (s *statusFaveDB) UpdateStatusFave(ctx context.Context, fave *gtsmodel.StatusFave, columns ...string) error {
fave.UpdatedAt = time.Now()
if len(columns) > 0 {
// If we're updating by column,
// ensure "updated_at" is included.
columns = append(columns, "updated_at")
}
// Update the status fave model in the database.
return s.state.Caches.GTS.StatusFave.Store(fave, func() error {
_, err := s.db.
NewUpdate().
Model(fave).
Where("? = ?", bun.Ident("status_fave.id"), fave.ID).
Column(columns...).
Exec(ctx)
return err
})
}
func (s *statusFaveDB) DeleteStatusFaveByID(ctx context.Context, id string) error {
var statusID string

View file

@ -65,9 +65,7 @@ func getFutureStatus() *gtsmodel.Status {
Language: "en",
CreatedWithApplicationID: "01F8MGXQRHYF5QPMTMXP78QC2F",
Federated: util.Ptr(true),
Boostable: util.Ptr(true),
Replyable: util.Ptr(true),
Likeable: util.Ptr(true),
InteractionPolicy: gtsmodel.DefaultInteractionPolicyPublic(),
ActivityStreamsType: ap.ObjectNote,
}
}