Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
S0yKaf 2025-01-18 13:55:15 -05:00
commit 0e137c0f2d
1759 changed files with 864109 additions and 314186 deletions

View file

@ -36,6 +36,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
@ -86,7 +87,7 @@ func (a *accountDB) GetAccountsByIDs(ctx context.Context, ids []string) ([]*gtsm
// Reorder the statuses by their
// IDs to ensure in correct order.
getID := func(a *gtsmodel.Account) string { return a.ID }
util.OrderBy(accounts, ids, getID)
xslices.OrderBy(accounts, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
@ -136,8 +137,9 @@ func (a *accountDB) GetAccountByURL(ctx context.Context, url string) (*gtsmodel.
func (a *accountDB) GetAccountByUsernameDomain(ctx context.Context, username string, domain string) (*gtsmodel.Account, error) {
if domain != "" {
// Normalize the domain as punycode
var err error
// Normalize the domain as punycode
domain, err = util.Punify(domain)
if err != nil {
return nil, err

View file

@ -46,7 +46,7 @@ type AccountTestSuite struct {
func (suite *AccountTestSuite) TestGetAccountStatuses() {
statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, false, false, "", "", false, false)
suite.NoError(err)
suite.Len(statuses, 8)
suite.Len(statuses, 9)
}
func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() {
@ -69,7 +69,7 @@ func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() {
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(statuses, 2)
suite.Len(statuses, 3)
// try to get the last page (should be empty)
statuses, err = suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 3, false, false, statuses[len(statuses)-1].ID, "", false, false)
@ -80,13 +80,13 @@ func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() {
func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesAndReblogs() {
statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, true, true, "", "", false, false)
suite.NoError(err)
suite.Len(statuses, 7)
suite.Len(statuses, 8)
}
func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesAndReblogsPublicOnly() {
statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, true, true, "", "", false, true)
suite.NoError(err)
suite.Len(statuses, 3)
suite.Len(statuses, 4)
}
// populateTestStatus adds mandatory fields to a partially populated status.
@ -106,7 +106,7 @@ func (suite *AccountTestSuite) populateTestStatus(testAccountKey string, status
status.URI = fmt.Sprintf("http://localhost:8080/users/%s/statuses/%s", testAccount.Username, status.ID)
status.Local = util.Ptr(true)
if status.Visibility == "" {
if status.Visibility == 0 {
status.Visibility = gtsmodel.VisibilityDefault
}
if status.ActivityStreamsType == "" {
@ -173,7 +173,7 @@ func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesExcludesSelfR
testAccount := suite.testAccounts["local_account_1"]
statuses, err := suite.db.GetAccountStatuses(context.Background(), testAccount.ID, 20, true, true, "", "", false, false)
suite.NoError(err)
suite.Len(statuses, 8)
suite.Len(statuses, 9)
for _, status := range statuses {
if status.InReplyToID != "" && status.InReplyToAccountID != testAccount.ID {
suite.FailNowf("", "Status with ID %s is a non-self reply and should have been excluded", status.ID)

View file

@ -22,7 +22,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -169,7 +169,7 @@ func (a *applicationDB) GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, er
// Reoroder the tokens by their
// IDs to ensure in correct order.
getID := func(t *gtsmodel.Token) string { return t.ID }
util.OrderBy(tokens, tokenIDs, getID)
xslices.OrderBy(tokens, tokenIDs, getID)
return tokens, nil
}

View file

@ -114,7 +114,7 @@ func (suite *BasicTestSuite) TestGetAllStatuses() {
s := []*gtsmodel.Status{}
err := suite.db.GetAll(context.Background(), &s)
suite.NoError(err)
suite.Len(s, 25)
suite.Len(s, 28)
}
func (suite *BasicTestSuite) TestGetAllNotNull() {

View file

@ -25,6 +25,7 @@ import (
"encoding/pem"
"errors"
"fmt"
"math"
"net/url"
"os"
"runtime"
@ -48,6 +49,7 @@ import (
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/dialect/sqlitedialect"
"github.com/uptrace/bun/migrate"
"github.com/uptrace/bun/schema"
)
// DBService satisfies the DB interface
@ -79,6 +81,7 @@ type DBService struct {
db.SinBinStatus
db.Status
db.StatusBookmark
db.StatusEdit
db.StatusFave
db.Tag
db.Thread
@ -130,18 +133,18 @@ func doMigration(ctx context.Context, db *bun.DB) error {
// NewBunDBService returns a bunDB derived from the provided config, which implements the go-fed DB interface.
// Under the hood, it uses https://github.com/uptrace/bun to create and maintain a database connection.
func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
var db *bun.DB
var sqldb *sql.DB
var dialect func() schema.Dialect
var err error
t := strings.ToLower(config.GetDbType())
switch t {
switch t := strings.ToLower(config.GetDbType()); t {
case "postgres":
db, err = pgConn(ctx)
sqldb, dialect, err = pgConn(ctx)
if err != nil {
return nil, err
}
case "sqlite":
db, err = sqliteConn(ctx)
sqldb, dialect, err = sqliteConn(ctx)
if err != nil {
return nil, err
}
@ -149,34 +152,20 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
return nil, fmt.Errorf("database type %s not supported for bundb", t)
}
// Add database query hooks.
db.AddQueryHook(queryHook{})
if config.GetTracingEnabled() {
db.AddQueryHook(tracing.InstrumentBun())
}
if config.GetMetricsEnabled() {
db.AddQueryHook(metrics.InstrumentBun())
}
// table registration is needed for many-to-many, see:
// https://bun.uptrace.dev/orm/many-to-many-relation/
for _, t := range []interface{}{
&gtsmodel.AccountToEmoji{},
&gtsmodel.ConversationToStatus{},
&gtsmodel.StatusToEmoji{},
&gtsmodel.StatusToTag{},
&gtsmodel.ThreadToStatus{},
} {
db.RegisterModel(t)
}
// perform any pending database migrations: this includes
// the very first 'migration' on startup which just creates
// necessary tables
if err := doMigration(ctx, db); err != nil {
// perform any pending database migrations: this includes the first
// 'migration' on startup which just creates necessary db tables.
//
// Note this uses its own instance of bun.DB as bun will automatically
// store in-memory reflect type schema of any Go models passed to it,
// and we still maintain lots of old model versions in the migrations.
if err := doMigration(ctx, bunDB(sqldb, dialect)); err != nil {
return nil, fmt.Errorf("db migration error: %s", err)
}
// Wrap sql.DB as bun.DB type,
// adding any connection hooks.
db := bunDB(sqldb, dialect)
ps := &DBService{
Account: &accountDB{
db: db,
@ -284,6 +273,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
db: db,
state: state,
},
StatusEdit: &statusEditDB{
db: db,
state: state,
},
StatusFave: &statusFaveDB{
db: db,
state: state,
@ -318,17 +311,47 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
return ps, nil
}
func pgConn(ctx context.Context) (*bun.DB, error) {
// bunDB returns a new bun.DB for given sql.DB connection pool and dialect
// function. This can be used to apply any necessary opts / hooks as we
// initialize a bun.DB object both before and after performing migrations.
func bunDB(sqldb *sql.DB, dialect func() schema.Dialect) *bun.DB {
db := bun.NewDB(sqldb, dialect())
// Add our SQL connection hooks.
db.AddQueryHook(queryHook{})
if config.GetTracingEnabled() {
db.AddQueryHook(tracing.InstrumentBun())
}
if config.GetMetricsEnabled() {
db.AddQueryHook(metrics.InstrumentBun())
}
// table registration is needed for many-to-many, see:
// https://bun.uptrace.dev/orm/many-to-many-relation/
for _, t := range []interface{}{
&gtsmodel.AccountToEmoji{},
&gtsmodel.ConversationToStatus{},
&gtsmodel.StatusToEmoji{},
&gtsmodel.StatusToTag{},
&gtsmodel.ThreadToStatus{},
} {
db.RegisterModel(t)
}
return db
}
func pgConn(ctx context.Context) (*sql.DB, func() schema.Dialect, error) {
opts, err := deriveBunDBPGOptions() //nolint:contextcheck
if err != nil {
return nil, fmt.Errorf("could not create bundb postgres options: %w", err)
return nil, nil, fmt.Errorf("could not create bundb postgres options: %w", err)
}
cfg := stdlib.RegisterConnConfig(opts)
sqldb, err := sql.Open("pgx-gts", cfg)
if err != nil {
return nil, fmt.Errorf("could not open postgres db: %w", err)
return nil, nil, fmt.Errorf("could not open postgres db: %w", err)
}
// Tune db connections for postgres, see:
@ -338,22 +361,20 @@ func pgConn(ctx context.Context) (*bun.DB, error) {
sqldb.SetMaxIdleConns(2) // assume default 2; if max idle is less than max open, it will be automatically adjusted
sqldb.SetConnMaxLifetime(5 * time.Minute) // fine to kill old connections
db := bun.NewDB(sqldb, pgdialect.New())
// ping to check the db is there and listening
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("postgres ping: %w", err)
if err := sqldb.PingContext(ctx); err != nil {
return nil, nil, fmt.Errorf("postgres ping: %w", err)
}
log.Info(ctx, "connected to POSTGRES database")
return db, nil
return sqldb, func() schema.Dialect { return pgdialect.New() }, nil
}
func sqliteConn(ctx context.Context) (*bun.DB, error) {
func sqliteConn(ctx context.Context) (*sql.DB, func() schema.Dialect, error) {
// validate db address has actually been set
address := config.GetDbAddress()
if address == "" {
return nil, fmt.Errorf("'%s' was not set when attempting to start sqlite", config.DbAddressFlag())
return nil, nil, fmt.Errorf("'%s' was not set when attempting to start sqlite", config.DbAddressFlag())
}
// Build SQLite connection address with prefs.
@ -362,7 +383,7 @@ func sqliteConn(ctx context.Context) (*bun.DB, error) {
// Open new DB instance
sqldb, err := sql.Open("sqlite-gts", address)
if err != nil {
return nil, fmt.Errorf("could not open sqlite db with address %s: %w", address, err)
return nil, nil, fmt.Errorf("could not open sqlite db with address %s: %w", address, err)
}
// Tune db connections for sqlite, see:
@ -378,16 +399,14 @@ func sqliteConn(ctx context.Context) (*bun.DB, error) {
sqldb.SetConnMaxLifetime(5 * time.Minute)
}
db := bun.NewDB(sqldb, sqlitedialect.New())
// ping to check the db is there and listening
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("sqlite ping: %w", err)
if err := sqldb.PingContext(ctx); err != nil {
return nil, nil, fmt.Errorf("sqlite ping: %w", err)
}
log.Infof(ctx, "connected to SQLITE database with address %s", address)
return db, nil
return sqldb, func() schema.Dialect { return sqlitedialect.New() }, nil
}
/*
@ -401,19 +420,30 @@ func maxOpenConns() int {
if multiplier < 1 {
return 1
}
// Specifically for SQLite databases with
// a journal mode of anything EXCEPT "wal",
// only 1 concurrent connection is supported.
if strings.ToLower(config.GetDbType()) == "sqlite" {
journalMode := config.GetDbSqliteJournalMode()
journalMode = strings.ToLower(journalMode)
if journalMode != "wal" {
return 1
}
}
return multiplier * runtime.GOMAXPROCS(0)
}
// deriveBunDBPGOptions takes an application config and returns either a ready-to-use set of options
// with sensible defaults, or an error if it's not satisfied by the provided config.
func deriveBunDBPGOptions() (*pgx.ConnConfig, error) {
url := config.GetDbPostgresConnectionString()
// if database URL is defined, ignore other DB related configuration fields
if url != "" {
cfg, err := pgx.ParseConfig(url)
return cfg, err
// If database URL is defined, ignore
// other DB-related configuration fields.
if url := config.GetDbPostgresConnectionString(); url != "" {
return pgx.ParseConfig(url)
}
// these are all optional, the db adapter figures out defaults
address := config.GetDbAddress()
@ -477,7 +507,10 @@ func deriveBunDBPGOptions() (*pgx.ConnConfig, error) {
cfg.Host = address
}
if port := config.GetDbPort(); port > 0 {
cfg.Port = uint16(port)
if port > math.MaxUint16 {
return nil, errors.New("invalid port, must be in range 1-65535")
}
cfg.Port = uint16(port) // #nosec G115 -- Just validated above.
}
if u := config.GetDbUser(); u != "" {
cfg.User = u
@ -502,15 +535,12 @@ func buildSQLiteAddress(addr string) (string, bool) {
//
// - SQLite by itself supports setting a subset of its configuration options
// via URI query arguments in the connection. Namely `mode` and `cache`.
// This is the same situation for the directly transpiled C->Go code in
// modernc.org/sqlite, i.e. modernc.org/sqlite/lib, NOT the Go SQL driver.
// This is the same situation for our supported SQLite implementations.
//
// - `modernc.org/sqlite` has a "shim" around it to allow the directly
// transpiled C code to be usable with a more native Go API. This is in
// the form of a `database/sql/driver.Driver{}` implementation that calls
// through to the transpiled C code.
// - Both implementations have a "shim" around them in the form of a
// `database/sql/driver.Driver{}` implementation.
//
// - The SQLite shim we interface with adds support for setting ANY of the
// - The SQLite shims we interface with add support for setting ANY of the
// configuration options via query arguments, through using a special `_pragma`
// query key that specifies SQLite PRAGMAs to set upon opening each connection.
// As such you will see below that most config is set with the `_pragma` key.
@ -536,12 +566,6 @@ func buildSQLiteAddress(addr string) (string, bool) {
// reached. And for whatever reason (:shrug:) SQLite is very particular about
// setting this BEFORE the `journal_mode` is set, otherwise you can end up
// running into more of these `SQLITE_BUSY` return codes than you might expect.
//
// - One final thing (I promise!): `SQLITE_BUSY` is only handled by the internal
// `busy_timeout` handler in the case that a data race occurs contending for
// table locks. THERE ARE STILL OTHER SITUATIONS IN WHICH THIS MAY BE RETURNED!
// As such, we use our wrapping DB{} and Tx{} types (in "db.go") which make use
// of our own retry-busy handler.
// Drop anything fancy from DB address
addr = strings.Split(addr, "?")[0] // drop any provided query strings

View file

@ -57,6 +57,7 @@ type BunDBStandardTestSuite struct {
testPolls map[string]*gtsmodel.Poll
testPollVotes map[string]*gtsmodel.PollVote
testInteractionRequests map[string]*gtsmodel.InteractionRequest
testStatusEdits map[string]*gtsmodel.StatusEdit
}
func (suite *BunDBStandardTestSuite) SetupSuite() {
@ -83,6 +84,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() {
suite.testPolls = testrig.NewTestPolls()
suite.testPollVotes = testrig.NewTestPollVotes()
suite.testInteractionRequests = testrig.NewTestInteractionRequests()
suite.testStatusEdits = testrig.NewTestStatusEdits()
}
func (suite *BunDBStandardTestSuite) SetupTest() {

View file

@ -31,7 +31,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
@ -209,7 +209,7 @@ func (c *conversationDB) getConversationsByLastStatusIDs(
// Reorder the conversations by their last status IDs to ensure correct order.
getID := func(b *gtsmodel.Conversation) string { return b.ID }
util.OrderBy(conversations, conversationLastStatusIDs, getID)
xslices.OrderBy(conversations, conversationLastStatusIDs, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
@ -558,7 +558,7 @@ func (c *conversationDB) DeleteStatusFromConversations(ctx context.Context, stat
// Invalidate cache entries.
updatedConversationIDs = append(updatedConversationIDs, deletedConversationIDs...)
updatedConversationIDs = util.Deduplicate(updatedConversationIDs)
updatedConversationIDs = xslices.Deduplicate(updatedConversationIDs)
c.state.Caches.DB.Conversation.InvalidateIDs("ID", updatedConversationIDs)
return nil

View file

@ -20,6 +20,7 @@ package bundb
import (
"context"
"net/url"
"time"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
@ -35,12 +36,12 @@ type domainDB struct {
state *state.State
}
func (d *domainDB) CreateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow) error {
// Normalize the domain as punycode
var err error
allow.Domain, err = util.Punify(allow.Domain)
func (d *domainDB) CreateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow) (err error) {
// Normalize the domain as punycode, note the extra
// validation step for domain name write operations.
allow.Domain, err = util.PunifySafely(allow.Domain)
if err != nil {
return err
return gtserror.Newf("error punifying domain %s: %w", allow.Domain, err)
}
// Attempt to store domain allow in DB
@ -57,10 +58,10 @@ func (d *domainDB) CreateDomainAllow(ctx context.Context, allow *gtsmodel.Domain
}
func (d *domainDB) GetDomainAllow(ctx context.Context, domain string) (*gtsmodel.DomainAllow, error) {
// Normalize the domain as punycode
// Normalize domain as punycode for lookup.
domain, err := util.Punify(domain)
if err != nil {
return nil, err
return nil, gtserror.Newf("error punifying domain %s: %w", domain, err)
}
// Check for easy case, domain referencing *us*
@ -110,11 +111,41 @@ func (d *domainDB) GetDomainAllowByID(ctx context.Context, id string) (*gtsmodel
return &allow, nil
}
func (d *domainDB) UpdateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow, columns ...string) (err error) {
// Normalize the domain as punycode, note the extra
// validation step for domain name write operations.
allow.Domain, err = util.PunifySafely(allow.Domain)
if err != nil {
return gtserror.Newf("error punifying domain %s: %w", allow.Domain, err)
}
// Ensure updated_at is set.
allow.UpdatedAt = time.Now()
if len(columns) != 0 {
columns = append(columns, "updated_at")
}
// Attempt to update domain allow.
if _, err := d.db.
NewUpdate().
Model(allow).
Column(columns...).
Where("? = ?", bun.Ident("domain_allow.id"), allow.ID).
Exec(ctx); err != nil {
return err
}
// Clear the domain allow cache (for later reload)
d.state.Caches.DB.DomainAllow.Clear()
return nil
}
func (d *domainDB) DeleteDomainAllow(ctx context.Context, domain string) error {
// Normalize the domain as punycode
// Normalize domain as punycode for lookup.
domain, err := util.Punify(domain)
if err != nil {
return err
return gtserror.Newf("error punifying domain %s: %w", domain, err)
}
// Attempt to delete domain allow
@ -132,11 +163,13 @@ func (d *domainDB) DeleteDomainAllow(ctx context.Context, domain string) error {
}
func (d *domainDB) CreateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock) error {
// Normalize the domain as punycode
var err error
block.Domain, err = util.Punify(block.Domain)
// Normalize the domain as punycode, note the extra
// validation step for domain name write operations.
block.Domain, err = util.PunifySafely(block.Domain)
if err != nil {
return err
return gtserror.Newf("error punifying domain %s: %w", block.Domain, err)
}
// Attempt to store domain block in DB
@ -153,10 +186,10 @@ func (d *domainDB) CreateDomainBlock(ctx context.Context, block *gtsmodel.Domain
}
func (d *domainDB) GetDomainBlock(ctx context.Context, domain string) (*gtsmodel.DomainBlock, error) {
// Normalize the domain as punycode
// Normalize domain as punycode for lookup.
domain, err := util.Punify(domain)
if err != nil {
return nil, err
return nil, gtserror.Newf("error punifying domain %s: %w", domain, err)
}
// Check for easy case, domain referencing *us*
@ -206,11 +239,43 @@ func (d *domainDB) GetDomainBlockByID(ctx context.Context, id string) (*gtsmodel
return &block, nil
}
func (d *domainDB) UpdateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock, columns ...string) error {
var err error
// Normalize the domain as punycode, note the extra
// validation step for domain name write operations.
block.Domain, err = util.PunifySafely(block.Domain)
if err != nil {
return gtserror.Newf("error punifying domain %s: %w", block.Domain, err)
}
// Ensure updated_at is set.
block.UpdatedAt = time.Now()
if len(columns) != 0 {
columns = append(columns, "updated_at")
}
// Attempt to update domain block.
if _, err := d.db.
NewUpdate().
Model(block).
Column(columns...).
Where("? = ?", bun.Ident("domain_block.id"), block.ID).
Exec(ctx); err != nil {
return err
}
// Clear the domain block cache (for later reload)
d.state.Caches.DB.DomainBlock.Clear()
return nil
}
func (d *domainDB) DeleteDomainBlock(ctx context.Context, domain string) error {
// Normalize the domain as punycode
// Normalize domain as punycode for lookup.
domain, err := util.Punify(domain)
if err != nil {
return err
return gtserror.Newf("error punifying domain %s: %w", domain, err)
}
// Attempt to delete domain block
@ -228,10 +293,10 @@ func (d *domainDB) DeleteDomainBlock(ctx context.Context, domain string) error {
}
func (d *domainDB) IsDomainBlocked(ctx context.Context, domain string) (bool, error) {
// Normalize the domain as punycode
// Normalize domain as punycode for lookup.
domain, err := util.Punify(domain)
if err != nil {
return false, err
return false, gtserror.Newf("error punifying domain %s: %w", domain, err)
}
// Domain referencing *us* cannot be blocked.

View file

@ -0,0 +1,286 @@
// 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"
"errors"
"slices"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/uptrace/bun"
)
func (d *domainDB) getDomainPermissionDraft(
ctx context.Context,
lookup string,
dbQuery func(*gtsmodel.DomainPermissionDraft) error,
keyParts ...any,
) (*gtsmodel.DomainPermissionDraft, error) {
// Fetch perm draft from database cache with loader callback.
permDraft, err := d.state.Caches.DB.DomainPermissionDraft.LoadOne(
lookup,
// Only called if not cached.
func() (*gtsmodel.DomainPermissionDraft, error) {
var permDraft gtsmodel.DomainPermissionDraft
if err := dbQuery(&permDraft); err != nil {
return nil, err
}
return &permDraft, nil
},
keyParts...,
)
if err != nil {
return nil, err
}
if gtscontext.Barebones(ctx) {
// No need to fully populate.
return permDraft, nil
}
if permDraft.CreatedByAccount == nil {
// Not set, fetch from database.
permDraft.CreatedByAccount, err = d.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
permDraft.CreatedByAccountID,
)
if err != nil {
return nil, gtserror.Newf("error populating created by account: %w", err)
}
}
return permDraft, nil
}
func (d *domainDB) GetDomainPermissionDraftByID(
ctx context.Context,
id string,
) (*gtsmodel.DomainPermissionDraft, error) {
return d.getDomainPermissionDraft(
ctx,
"ID",
func(permDraft *gtsmodel.DomainPermissionDraft) error {
return d.db.
NewSelect().
Model(permDraft).
Where("? = ?", bun.Ident("domain_permission_draft.id"), id).
Scan(ctx)
},
id,
)
}
func (d *domainDB) GetDomainPermissionDrafts(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
permSubID string,
domain string,
page *paging.Page,
) (
[]*gtsmodel.DomainPermissionDraft,
error,
) {
var (
// Get paging params.
minID = page.GetMin()
maxID = page.GetMax()
limit = page.GetLimit()
order = page.GetOrder()
// Make educated guess for slice size
permDraftIDs = make([]string, 0, limit)
)
q := d.db.
NewSelect().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_drafts"),
bun.Ident("domain_permission_draft"),
).
// Select only IDs from table
Column("domain_permission_draft.id")
// Return only items with id
// lower than provided maxID.
if maxID != "" {
q = q.Where(
"? < ?",
bun.Ident("domain_permission_draft.id"),
maxID,
)
}
// Return only items with id
// greater than provided minID.
if minID != "" {
q = q.Where(
"? > ?",
bun.Ident("domain_permission_draft.id"),
minID,
)
}
// Return only items with
// given permission type.
if permType != gtsmodel.DomainPermissionUnknown {
q = q.Where(
"? = ?",
bun.Ident("domain_permission_draft.permission_type"),
permType,
)
}
// Return only items with
// given subscription ID.
if permSubID != "" {
q = q.Where(
"? = ?",
bun.Ident("domain_permission_draft.subscription_id"),
permSubID,
)
}
// Return only items
// with given domain.
if domain != "" {
var err error
// Normalize domain as punycode for lookup.
domain, err = util.Punify(domain)
if err != nil {
return nil, gtserror.Newf("error punifying domain %s: %w", domain, err)
}
q = q.Where(
"? = ?",
bun.Ident("domain_permission_draft.domain"),
domain,
)
}
if limit > 0 {
// Limit amount of
// items returned.
q = q.Limit(limit)
}
if order == paging.OrderAscending {
// Page up.
q = q.OrderExpr(
"? ASC",
bun.Ident("domain_permission_draft.id"),
)
} else {
// Page down.
q = q.OrderExpr(
"? DESC",
bun.Ident("domain_permission_draft.id"),
)
}
if err := q.Scan(ctx, &permDraftIDs); err != nil {
return nil, err
}
// Catch case of no items early
if len(permDraftIDs) == 0 {
return nil, db.ErrNoEntries
}
// If we're paging up, we still want items
// to be sorted by ID desc, so reverse slice.
if order == paging.OrderAscending {
slices.Reverse(permDraftIDs)
}
// Allocate return slice (will be at most len permDraftIDs)
permDrafts := make([]*gtsmodel.DomainPermissionDraft, 0, len(permDraftIDs))
for _, id := range permDraftIDs {
permDraft, err := d.GetDomainPermissionDraftByID(ctx, id)
if err != nil {
log.Errorf(ctx, "error getting domain permission draft %q: %v", id, err)
continue
}
// Append to return slice
permDrafts = append(permDrafts, permDraft)
}
return permDrafts, nil
}
func (d *domainDB) PutDomainPermissionDraft(
ctx context.Context,
draft *gtsmodel.DomainPermissionDraft,
) error {
var err error
// Normalize the domain as punycode, note the extra
// validation step for domain name write operations.
draft.Domain, err = util.PunifySafely(draft.Domain)
if err != nil {
return gtserror.Newf("error punifying domain %s: %w", draft.Domain, err)
}
return d.state.Caches.DB.DomainPermissionDraft.Store(
draft,
func() error {
_, err := d.db.
NewInsert().
Model(draft).
Exec(ctx)
return err
},
)
}
func (d *domainDB) DeleteDomainPermissionDraft(
ctx context.Context,
id string,
) error {
// Delete the permDraft from DB.
q := d.db.NewDelete().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_drafts"),
bun.Ident("domain_permission_draft"),
).
Where(
"? = ?",
bun.Ident("domain_permission_draft.id"),
id,
)
_, err := q.Exec(ctx)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return err
}
// Invalidate any cached model by ID.
d.state.Caches.DB.DomainPermissionDraft.Invalidate("ID", id)
return nil
}

View file

@ -0,0 +1,120 @@
// 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_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
type DomainPermissionDraftTestSuite struct {
BunDBStandardTestSuite
}
func (suite *DomainPermissionDraftTestSuite) TestPermDraftCreateGetDelete() {
var (
ctx = context.Background()
draft = &gtsmodel.DomainPermissionDraft{
ID: "01JCZN614XG85GCGAMSV9ZZAEJ",
PermissionType: gtsmodel.DomainPermissionBlock,
Domain: "exämple.org",
CreatedByAccountID: suite.testAccounts["admin_account"].ID,
PrivateComment: "this domain is poo",
PublicComment: "this domain is poo, but phrased in a more outward-facing way",
Obfuscate: util.Ptr(false),
SubscriptionID: "01JCZN8PG55KKEVTDAY52D0T3P",
}
)
// Whack the draft in.
if err := suite.state.DB.PutDomainPermissionDraft(ctx, draft); err != nil {
suite.FailNow(err.Error())
}
// Get the draft again.
dbDraft, err := suite.state.DB.GetDomainPermissionDraftByID(ctx, draft.ID)
if err != nil {
suite.FailNow(err.Error())
}
// Domain should have been stored punycoded.
suite.Equal("xn--exmple-cua.org", dbDraft.Domain)
// Search for domain using both
// punycode and unicode variants.
search1, err := suite.state.DB.GetDomainPermissionDrafts(
ctx,
gtsmodel.DomainPermissionUnknown,
"",
"exämple.org",
nil,
)
if err != nil {
suite.FailNow(err.Error())
}
if len(search1) != 1 {
suite.FailNow("couldn't get domain perm draft exämple.org")
}
search2, err := suite.state.DB.GetDomainPermissionDrafts(
ctx,
gtsmodel.DomainPermissionUnknown,
"",
"xn--exmple-cua.org",
nil,
)
if err != nil {
suite.FailNow(err.Error())
}
if len(search2) != 1 {
suite.FailNow("couldn't get domain perm draft example.org")
}
// Change ID + try to put the same draft again.
draft.ID = "01JCZNVYSDT3JE385FABMJ7ADQ"
err = suite.state.DB.PutDomainPermissionDraft(ctx, draft)
if !errors.Is(err, db.ErrAlreadyExists) {
suite.FailNow("was able to insert same domain perm draft twice")
}
// Put same draft but change permission type, should work.
draft.PermissionType = gtsmodel.DomainPermissionAllow
if err := suite.state.DB.PutDomainPermissionDraft(ctx, draft); err != nil {
suite.FailNow(err.Error())
}
// Delete both drafts.
for _, id := range []string{
"01JCZN614XG85GCGAMSV9ZZAEJ",
"01JCZNVYSDT3JE385FABMJ7ADQ",
} {
if err := suite.state.DB.DeleteDomainPermissionDraft(ctx, id); err != nil {
suite.FailNow("error deleting domain permission draft")
}
}
}
func TestDomainPermissionDraftTestSuite(t *testing.T) {
suite.Run(t, new(DomainPermissionDraftTestSuite))
}

View file

@ -0,0 +1,272 @@
// 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"
"errors"
"slices"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/uptrace/bun"
)
func (d *domainDB) PutDomainPermissionExclude(
ctx context.Context,
exclude *gtsmodel.DomainPermissionExclude,
) error {
var err error
// Normalize the domain as punycode, note the extra
// validation step for domain name write operations.
exclude.Domain, err = util.PunifySafely(exclude.Domain)
if err != nil {
return gtserror.Newf("error punifying domain %s: %w", exclude.Domain, err)
}
// Attempt to store domain perm exclude in DB
if _, err := d.db.NewInsert().
Model(exclude).
Exec(ctx); err != nil {
return err
}
// Clear the domain perm exclude cache (for later reload)
d.state.Caches.DB.DomainPermissionExclude.Clear()
return nil
}
func (d *domainDB) IsDomainPermissionExcluded(ctx context.Context, domain string) (bool, error) {
// Normalize domain as punycode for lookup.
domain, err := util.Punify(domain)
if err != nil {
return false, gtserror.Newf("error punifying domain %s: %w", domain, err)
}
// Func to scan list of all
// excluded domain perms from DB.
loadF := func() ([]string, error) {
var domains []string
if err := d.db.
NewSelect().
Table("domain_permission_excludes").
Column("domain").
Scan(ctx, &domains); err != nil {
return nil, err
}
// Exclude our own domain as creating blocks
// or allows for self will likely break things.
domains = append(domains, config.GetHost())
return domains, nil
}
// Check the cache for a domain perm exclude,
// hydrating the cache with loadF if necessary.
return d.state.Caches.DB.DomainPermissionExclude.Matches(domain, loadF)
}
func (d *domainDB) GetDomainPermissionExcludeByID(
ctx context.Context,
id string,
) (*gtsmodel.DomainPermissionExclude, error) {
exclude := new(gtsmodel.DomainPermissionExclude)
q := d.db.
NewSelect().
Model(exclude).
Where("? = ?", bun.Ident("domain_permission_exclude.id"), id)
if err := q.Scan(ctx); err != nil {
return nil, err
}
if gtscontext.Barebones(ctx) {
// No need to fully populate.
return exclude, nil
}
if exclude.CreatedByAccount == nil {
// Not set, fetch from database.
var err error
exclude.CreatedByAccount, err = d.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
exclude.CreatedByAccountID,
)
if err != nil {
return nil, gtserror.Newf("error populating created by account: %w", err)
}
}
return exclude, nil
}
func (d *domainDB) GetDomainPermissionExcludes(
ctx context.Context,
domain string,
page *paging.Page,
) (
[]*gtsmodel.DomainPermissionExclude,
error,
) {
var (
// Get paging params.
minID = page.GetMin()
maxID = page.GetMax()
limit = page.GetLimit()
order = page.GetOrder()
// Make educated guess for slice size
excludeIDs = make([]string, 0, limit)
)
q := d.db.
NewSelect().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_excludes"),
bun.Ident("domain_permission_exclude"),
).
// Select only IDs from table
Column("domain_permission_exclude.id")
// Return only items with id
// lower than provided maxID.
if maxID != "" {
q = q.Where(
"? < ?",
bun.Ident("domain_permission_exclude.id"),
maxID,
)
}
// Return only items with id
// greater than provided minID.
if minID != "" {
q = q.Where(
"? > ?",
bun.Ident("domain_permission_exclude.id"),
minID,
)
}
// Return only items
// with given domain.
if domain != "" {
var err error
// Normalize domain as punycode for lookup.
domain, err = util.Punify(domain)
if err != nil {
return nil, gtserror.Newf("error punifying domain %s: %w", domain, err)
}
q = q.Where(
"? = ?",
bun.Ident("domain_permission_exclude.domain"),
domain,
)
}
if limit > 0 {
// Limit amount of
// items returned.
q = q.Limit(limit)
}
if order == paging.OrderAscending {
// Page up.
q = q.OrderExpr(
"? ASC",
bun.Ident("domain_permission_exclude.id"),
)
} else {
// Page down.
q = q.OrderExpr(
"? DESC",
bun.Ident("domain_permission_exclude.id"),
)
}
if err := q.Scan(ctx, &excludeIDs); err != nil {
return nil, err
}
// Catch case of no items early
if len(excludeIDs) == 0 {
return nil, db.ErrNoEntries
}
// If we're paging up, we still want items
// to be sorted by ID desc, so reverse slice.
if order == paging.OrderAscending {
slices.Reverse(excludeIDs)
}
// Allocate return slice (will be at most len permSubIDs).
excludes := make([]*gtsmodel.DomainPermissionExclude, 0, len(excludeIDs))
for _, id := range excludeIDs {
exclude, err := d.GetDomainPermissionExcludeByID(ctx, id)
if err != nil {
log.Errorf(ctx, "error getting domain permission exclude %q: %v", id, err)
continue
}
// Append to return slice
excludes = append(excludes, exclude)
}
return excludes, nil
}
func (d *domainDB) DeleteDomainPermissionExclude(
ctx context.Context,
id string,
) error {
// Delete the permSub from DB.
q := d.db.NewDelete().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_excludes"),
bun.Ident("domain_permission_exclude"),
).
Where(
"? = ?",
bun.Ident("domain_permission_exclude.id"),
id,
)
_, err := q.Exec(ctx)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return err
}
// Clear the domain perm exclude cache (for later reload)
d.state.Caches.DB.DomainPermissionExclude.Clear()
return nil
}

View file

@ -0,0 +1,185 @@
// 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_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type DomainPermissionExcludeTestSuite struct {
BunDBStandardTestSuite
}
func (suite *DomainPermissionExcludeTestSuite) TestPermExcludeCreateGetDelete() {
var (
ctx = context.Background()
exclude = &gtsmodel.DomainPermissionExclude{
ID: "01JCZN614XG85GCGAMSV9ZZAEJ",
Domain: "exämple.org",
CreatedByAccountID: suite.testAccounts["admin_account"].ID,
PrivateComment: "this domain is poo",
}
)
// Whack the exclude in.
if err := suite.state.DB.PutDomainPermissionExclude(ctx, exclude); err != nil {
suite.FailNow(err.Error())
}
// Get the exclude again.
dbExclude, err := suite.state.DB.GetDomainPermissionExcludeByID(ctx, exclude.ID)
if err != nil {
suite.FailNow(err.Error())
}
// Domain should have been stored punycoded.
suite.Equal("xn--exmple-cua.org", dbExclude.Domain)
// Search for domain using both
// punycode and unicode variants.
search1, err := suite.state.DB.GetDomainPermissionExcludes(
ctx,
"exämple.org",
nil,
)
if err != nil {
suite.FailNow(err.Error())
}
if len(search1) != 1 {
suite.FailNow("couldn't get domain perm exclude exämple.org")
}
search2, err := suite.state.DB.GetDomainPermissionExcludes(
ctx,
"xn--exmple-cua.org",
nil,
)
if err != nil {
suite.FailNow(err.Error())
}
if len(search2) != 1 {
suite.FailNow("couldn't get domain perm exclude example.org")
}
// Change ID + try to put the same exclude again.
exclude.ID = "01JCZNVYSDT3JE385FABMJ7ADQ"
err = suite.state.DB.PutDomainPermissionExclude(ctx, exclude)
if !errors.Is(err, db.ErrAlreadyExists) {
suite.FailNow("was able to insert same domain perm exclude twice")
}
// Delete both excludes.
for _, id := range []string{
"01JCZN614XG85GCGAMSV9ZZAEJ",
"01JCZNVYSDT3JE385FABMJ7ADQ",
} {
if err := suite.state.DB.DeleteDomainPermissionExclude(ctx, id); err != nil {
suite.FailNow("error deleting domain permission exclude")
}
}
}
func (suite *DomainPermissionExcludeTestSuite) TestExcluded() {
var (
ctx = context.Background()
createdByAccountID = suite.testAccounts["admin_account"].ID
)
// Insert some excludes into the db.
for _, exclude := range []*gtsmodel.DomainPermissionExclude{
{
ID: "01JD7AFFBBZSPY8R2M0JCGQGPW",
Domain: "example.org",
CreatedByAccountID: createdByAccountID,
},
{
ID: "01JD7AMK98E2QX78KXEZJ1RF5Z",
Domain: "boobs.com",
CreatedByAccountID: createdByAccountID,
},
{
ID: "01JD7AMXW3R3W98E91R62ACDA0",
Domain: "rad.boobs.com",
CreatedByAccountID: createdByAccountID,
},
{
ID: "01JD7AYYN5TXQVASB30PT08CE1",
Domain: "honkers.org",
CreatedByAccountID: createdByAccountID,
},
} {
if err := suite.state.DB.PutDomainPermissionExclude(ctx, exclude); err != nil {
suite.FailNow(err.Error())
}
}
type testCase struct {
domain string
excluded bool
}
for i, testCase := range []testCase{
{
domain: config.GetHost(),
excluded: true,
},
{
domain: "test.example.org",
excluded: true,
},
{
domain: "example.org",
excluded: true,
},
{
domain: "boobs.com",
excluded: true,
},
{
domain: "rad.boobs.com",
excluded: true,
},
{
domain: "sir.not.appearing.in.this.list",
excluded: false,
},
} {
excluded, err := suite.state.DB.IsDomainPermissionExcluded(ctx, testCase.domain)
if err != nil {
suite.FailNow(err.Error())
}
if excluded != testCase.excluded {
suite.Failf("",
"test %d: %s excluded should be %t",
i, testCase.domain, testCase.excluded,
)
}
}
}
func TestDomainPermissionExcludeTestSuite(t *testing.T) {
suite.Run(t, new(DomainPermissionExcludeTestSuite))
}

View file

@ -0,0 +1,354 @@
// 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"
"errors"
"slices"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/uptrace/bun"
)
func (d *domainDB) getDomainPermissionSubscription(
ctx context.Context,
lookup string,
dbQuery func(*gtsmodel.DomainPermissionSubscription) error,
keyParts ...any,
) (*gtsmodel.DomainPermissionSubscription, error) {
// Fetch perm subscription from database cache with loader callback.
permSub, err := d.state.Caches.DB.DomainPermissionSubscription.LoadOne(
lookup,
// Only called if not cached.
func() (*gtsmodel.DomainPermissionSubscription, error) {
var permSub gtsmodel.DomainPermissionSubscription
if err := dbQuery(&permSub); err != nil {
return nil, err
}
return &permSub, nil
},
keyParts...,
)
if err != nil {
return nil, err
}
if gtscontext.Barebones(ctx) {
// No need to fully populate.
return permSub, nil
}
if permSub.CreatedByAccount == nil {
// Not set, fetch from database.
permSub.CreatedByAccount, err = d.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
permSub.CreatedByAccountID,
)
if err != nil {
return nil, gtserror.Newf("error populating created by account: %w", err)
}
}
return permSub, nil
}
func (d *domainDB) GetDomainPermissionSubscriptionByID(
ctx context.Context,
id string,
) (*gtsmodel.DomainPermissionSubscription, error) {
return d.getDomainPermissionSubscription(
ctx,
"ID",
func(permSub *gtsmodel.DomainPermissionSubscription) error {
return d.db.
NewSelect().
Model(permSub).
Where("? = ?", bun.Ident("domain_permission_subscription.id"), id).
Scan(ctx)
},
id,
)
}
func (d *domainDB) GetDomainPermissionSubscriptions(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
page *paging.Page,
) (
[]*gtsmodel.DomainPermissionSubscription,
error,
) {
var (
// Get paging params.
minID = page.GetMin()
maxID = page.GetMax()
limit = page.GetLimit()
order = page.GetOrder()
// Make educated guess for slice size
permSubIDs = make([]string, 0, limit)
)
q := d.db.
NewSelect().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_subscriptions"),
bun.Ident("domain_permission_subscription"),
).
// Select only IDs from table
Column("domain_permission_subscription.id")
// Return only items with id
// lower than provided maxID.
if maxID != "" {
q = q.Where(
"? < ?",
bun.Ident("domain_permission_subscription.id"),
maxID,
)
}
// Return only items with id
// greater than provided minID.
if minID != "" {
q = q.Where(
"? > ?",
bun.Ident("domain_permission_subscription.id"),
minID,
)
}
// Return only items with
// given permission type.
if permType != gtsmodel.DomainPermissionUnknown {
q = q.Where(
"? = ?",
bun.Ident("domain_permission_subscription.permission_type"),
permType,
)
}
if limit > 0 {
// Limit amount of
// items returned.
q = q.Limit(limit)
}
if order == paging.OrderAscending {
// Page up.
q = q.OrderExpr(
"? ASC",
bun.Ident("domain_permission_subscription.id"),
)
} else {
// Page down.
q = q.OrderExpr(
"? DESC",
bun.Ident("domain_permission_subscription.id"),
)
}
if err := q.Scan(ctx, &permSubIDs); err != nil {
return nil, err
}
// Catch case of no items early
if len(permSubIDs) == 0 {
return nil, db.ErrNoEntries
}
// If we're paging up, we still want items
// to be sorted by ID desc, so reverse slice.
if order == paging.OrderAscending {
slices.Reverse(permSubIDs)
}
// Allocate return slice (will be at most len permSubIDs).
permSubs := make([]*gtsmodel.DomainPermissionSubscription, 0, len(permSubIDs))
for _, id := range permSubIDs {
permSub, err := d.GetDomainPermissionSubscriptionByID(ctx, id)
if err != nil {
log.Errorf(ctx, "error getting domain permission subscription %q: %v", id, err)
continue
}
// Append to return slice
permSubs = append(permSubs, permSub)
}
return permSubs, nil
}
func (d *domainDB) GetDomainPermissionSubscriptionsByPriority(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
) (
[]*gtsmodel.DomainPermissionSubscription,
error,
) {
permSubIDs := []string{}
q := d.db.
NewSelect().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_subscriptions"),
bun.Ident("domain_permission_subscription"),
).
// Select only IDs from table
Column("domain_permission_subscription.id").
// Select only subs of given perm type.
Where(
"? = ?",
bun.Ident("domain_permission_subscription.permission_type"),
permType,
).
// Order by priority descending.
OrderExpr(
"? DESC",
bun.Ident("domain_permission_subscription.priority"),
)
if err := q.Scan(ctx, &permSubIDs); err != nil {
return nil, err
}
// Catch case of no items early
if len(permSubIDs) == 0 {
return nil, db.ErrNoEntries
}
// Allocate return slice (will be at most len permSubIDs).
permSubs := make([]*gtsmodel.DomainPermissionSubscription, 0, len(permSubIDs))
for _, id := range permSubIDs {
permSub, err := d.GetDomainPermissionSubscriptionByID(ctx, id)
if err != nil {
log.Errorf(ctx, "error getting domain permission subscription %q: %v", id, err)
continue
}
// Append to return slice
permSubs = append(permSubs, permSub)
}
return permSubs, nil
}
func (d *domainDB) PutDomainPermissionSubscription(
ctx context.Context,
permSubscription *gtsmodel.DomainPermissionSubscription,
) error {
return d.state.Caches.DB.DomainPermissionSubscription.Store(
permSubscription,
func() error {
_, err := d.db.
NewInsert().
Model(permSubscription).
Exec(ctx)
return err
},
)
}
func (d *domainDB) UpdateDomainPermissionSubscription(
ctx context.Context,
permSubscription *gtsmodel.DomainPermissionSubscription,
columns ...string,
) error {
return d.state.Caches.DB.DomainPermissionSubscription.Store(
permSubscription,
func() error {
_, err := d.db.
NewUpdate().
Model(permSubscription).
Where("? = ?", bun.Ident("id"), permSubscription.ID).
Column(columns...).
Exec(ctx)
return err
},
)
}
func (d *domainDB) DeleteDomainPermissionSubscription(
ctx context.Context,
id string,
) error {
// Delete the permSub from DB.
q := d.db.NewDelete().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_subscriptions"),
bun.Ident("domain_permission_subscription"),
).
Where(
"? = ?",
bun.Ident("domain_permission_subscription.id"),
id,
)
_, err := q.Exec(ctx)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return err
}
// Invalidate any cached model by ID.
d.state.Caches.DB.DomainPermissionSubscription.Invalidate("ID", id)
return nil
}
func (d *domainDB) CountDomainPermissionSubscriptionPerms(
ctx context.Context,
id string,
) (int, error) {
permSubscription, err := d.GetDomainPermissionSubscriptionByID(
gtscontext.SetBarebones(ctx),
id,
)
if err != nil {
return 0, err
}
q := d.db.NewSelect()
if permSubscription.PermissionType == gtsmodel.DomainPermissionBlock {
q = q.TableExpr(
"? AS ?",
bun.Ident("domain_blocks"),
bun.Ident("perm"),
)
} else {
q = q.TableExpr(
"? AS ?",
bun.Ident("domain_allows"),
bun.Ident("perm"),
)
}
return q.
Column("perm.id").
Where("? = ?", bun.Ident("perm.subscription_id"), id).
Count(ctx)
}

View file

@ -0,0 +1,99 @@
// 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_test
import (
"context"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type DomainPermissionSubscriptionTestSuite struct {
BunDBStandardTestSuite
}
func (suite *DomainPermissionSubscriptionTestSuite) TestCount() {
var (
ctx = context.Background()
testAccount = suite.testAccounts["admin_account"]
permSub = &gtsmodel.DomainPermissionSubscription{
ID: "01JGV3VZ72K58BYW8H5GEVBZGN",
PermissionType: gtsmodel.DomainPermissionBlock,
CreatedByAccountID: testAccount.ID,
CreatedByAccount: testAccount,
URI: "https://example.org/whatever.csv",
ContentType: gtsmodel.DomainPermSubContentTypeCSV,
}
perms = []*gtsmodel.DomainBlock{
{
ID: "01JGV42G72YCKN06AC51RZPFES",
Domain: "whatever.com",
CreatedByAccountID: testAccount.ID,
CreatedByAccount: testAccount,
SubscriptionID: permSub.ID,
},
{
ID: "01JGV43ZQKYPHM2M0YBQDFDSD1",
Domain: "aaaa.example.org",
CreatedByAccountID: testAccount.ID,
CreatedByAccount: testAccount,
SubscriptionID: permSub.ID,
},
{
ID: "01JGV444KDDC4WFG6MZQVM0N2Z",
Domain: "bbbb.example.org",
CreatedByAccountID: testAccount.ID,
CreatedByAccount: testAccount,
SubscriptionID: permSub.ID,
},
{
ID: "01JGV44AFEMBWS6P6S72BQK376",
Domain: "cccc.example.org",
CreatedByAccountID: testAccount.ID,
CreatedByAccount: testAccount,
SubscriptionID: permSub.ID,
},
}
)
// Whack the perm sub in the DB.
if err := suite.state.DB.PutDomainPermissionSubscription(ctx, permSub); err != nil {
suite.FailNow(err.Error())
}
// Whack the perms in the db.
for _, perm := range perms {
if err := suite.state.DB.CreateDomainBlock(ctx, perm); err != nil {
suite.FailNow(err.Error())
}
}
// Count 'em.
count, err := suite.state.DB.CountDomainPermissionSubscriptionPerms(ctx, permSub.ID)
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(4, count)
}
func TestDomainPermissionSubscriptionTestSuite(t *testing.T) {
suite.Run(t, new(DomainPermissionSubscriptionTestSuite))
}

View file

@ -31,7 +31,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
@ -597,7 +597,7 @@ func (e *emojiDB) GetEmojisByIDs(ctx context.Context, ids []string) ([]*gtsmodel
// Reorder the emojis by their
// IDs to ensure in correct order.
getID := func(e *gtsmodel.Emoji) string { return e.ID }
util.OrderBy(emojis, ids, getID)
xslices.OrderBy(emojis, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
@ -661,7 +661,7 @@ func (e *emojiDB) GetEmojiCategoriesByIDs(ctx context.Context, ids []string) ([]
// Reorder the categories by their
// IDs to ensure in correct order.
getID := func(c *gtsmodel.EmojiCategory) string { return c.ID }
util.OrderBy(categories, ids, getID)
xslices.OrderBy(categories, ids, getID)
return categories, nil
}

View file

@ -27,7 +27,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -99,7 +99,7 @@ func (f *filterDB) GetFiltersForAccountID(ctx context.Context, accountID string)
}
// Put the filter structs in the same order as the filter IDs.
util.OrderBy(filters, filterIDs, func(filter *gtsmodel.Filter) string { return filter.ID })
xslices.OrderBy(filters, filterIDs, func(filter *gtsmodel.Filter) string { return filter.ID })
if gtscontext.Barebones(ctx) {
return filters, nil

View file

@ -247,6 +247,54 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
}
}
func (suite *FilterTestSuite) TestFilterTitleOverlap() {
var (
ctx = context.Background()
account1 = "01HNEJXCPRTJVJY9MV0VVHGD47"
account2 = "01JAG5BRJPJYA0FSA5HR2MMFJH"
)
// Create an empty filter for account 1.
account1filter1 := &gtsmodel.Filter{
ID: "01HNEJNVZZVXJTRB3FX3K2B1YF",
AccountID: account1,
Title: "my filter",
Action: gtsmodel.FilterActionWarn,
ContextHome: util.Ptr(true),
}
if err := suite.db.PutFilter(ctx, account1filter1); err != nil {
suite.FailNow("", "error putting account1filter1: %s", err)
}
// Create a filter for account 2 with
// the same title, should be no issue.
account2filter1 := &gtsmodel.Filter{
ID: "01JAG5GPXG7H5Y4ZP78GV1F2ET",
AccountID: account2,
Title: "my filter",
Action: gtsmodel.FilterActionWarn,
ContextHome: util.Ptr(true),
}
if err := suite.db.PutFilter(ctx, account2filter1); err != nil {
suite.FailNow("", "error putting account2filter1: %s", err)
}
// Try to create another filter for
// account 1 with the same name as
// an existing filter of theirs.
account1filter2 := &gtsmodel.Filter{
ID: "01JAG5J8NYKQE2KYCD28Y4P05V",
AccountID: account1,
Title: "my filter",
Action: gtsmodel.FilterActionWarn,
ContextHome: util.Ptr(true),
}
err := suite.db.PutFilter(ctx, account1filter2)
if !errors.Is(err, db.ErrAlreadyExists) {
suite.FailNow("", "wanted ErrAlreadyExists, got %s", err)
}
}
func TestFilterTestSuite(t *testing.T) {
suite.Run(t, new(FilterTestSuite))
}

View file

@ -26,7 +26,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -140,7 +140,7 @@ func (f *filterDB) getFilterKeywords(ctx context.Context, idColumn string, id st
}
// Put the filter keyword structs in the same order as the filter keyword IDs.
util.OrderBy(filterKeywords, filterKeywordIDs, func(filterKeyword *gtsmodel.FilterKeyword) string {
xslices.OrderBy(filterKeywords, filterKeywordIDs, func(filterKeyword *gtsmodel.FilterKeyword) string {
return filterKeyword.ID
})

View file

@ -25,7 +25,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -116,7 +116,7 @@ func (f *filterDB) getFilterStatuses(ctx context.Context, idColumn string, id st
}
// Put the filter status structs in the same order as the filter status IDs.
util.OrderBy(filterStatuses, filterStatusIDs, func(filterStatus *gtsmodel.FilterStatus) string {
xslices.OrderBy(filterStatuses, filterStatusIDs, func(filterStatus *gtsmodel.FilterStatus) string {
return filterStatus.ID
})

View file

@ -103,6 +103,9 @@ func (i *instanceDB) CountInstanceStatuses(ctx context.Context, domain string) (
// Ignore statuses that are currently pending approval.
q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true)
// Ignore statuses that are direct messages.
q = q.Where("NOT ? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityDirect)
count, err := q.Count(ctx)
if err != nil {
return 0, err
@ -155,8 +158,9 @@ func (i *instanceDB) CountInstanceDomains(ctx context.Context, domain string) (i
}
func (i *instanceDB) GetInstance(ctx context.Context, domain string) (*gtsmodel.Instance, error) {
// Normalize the domain as punycode
var err error
// Normalize the domain as punycode
domain, err = util.Punify(domain)
if err != nil {
return nil, gtserror.Newf("error punifying domain %s: %w", domain, err)
@ -262,8 +266,9 @@ func (i *instanceDB) PopulateInstance(ctx context.Context, instance *gtsmodel.In
func (i *instanceDB) PutInstance(ctx context.Context, instance *gtsmodel.Instance) error {
var err error
// Normalize the domain as punycode
instance.Domain, err = util.Punify(instance.Domain)
// Normalize the domain as punycode, note the extra
// validation step for domain name write operations.
instance.Domain, err = util.PunifySafely(instance.Domain)
if err != nil {
return gtserror.Newf("error punifying domain %s: %w", instance.Domain, err)
}
@ -276,9 +281,11 @@ func (i *instanceDB) PutInstance(ctx context.Context, instance *gtsmodel.Instanc
}
func (i *instanceDB) UpdateInstance(ctx context.Context, instance *gtsmodel.Instance, columns ...string) error {
// Normalize the domain as punycode
var err error
instance.Domain, err = util.Punify(instance.Domain)
// Normalize the domain as punycode, note the extra
// validation step for domain name write operations.
instance.Domain, err = util.PunifySafely(instance.Domain)
if err != nil {
return gtserror.Newf("error punifying domain %s: %w", instance.Domain, err)
}
@ -346,8 +353,9 @@ func (i *instanceDB) GetInstanceAccounts(ctx context.Context, domain string, max
limit = 0
}
// Normalize the domain as punycode.
var err error
// Normalize the domain as punycode
domain, err = util.Punify(domain)
if err != nil {
return nil, gtserror.Newf("error punifying domain %s: %w", domain, err)

View file

@ -47,13 +47,13 @@ func (suite *InstanceTestSuite) TestCountInstanceUsersRemote() {
func (suite *InstanceTestSuite) TestCountInstanceStatuses() {
count, err := suite.db.CountInstanceStatuses(context.Background(), config.GetHost())
suite.NoError(err)
suite.Equal(20, count)
suite.Equal(21, count)
}
func (suite *InstanceTestSuite) TestCountInstanceStatusesRemote() {
count, err := suite.db.CountInstanceStatuses(context.Background(), "fossbros-anonymous.io")
suite.NoError(err)
suite.Equal(3, count)
suite.Equal(4, count)
}
func (suite *InstanceTestSuite) TestCountInstanceDomains() {

View file

@ -26,8 +26,10 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -84,6 +86,53 @@ func (i *interactionDB) GetInteractionRequestByURI(ctx context.Context, uri stri
)
}
func (i *interactionDB) GetInteractionRequestsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.InteractionRequest, error) {
// Load all interaction request IDs via cache loader callbacks.
requests, err := i.state.Caches.DB.InteractionRequest.LoadIDs("ID",
ids,
func(uncached []string) ([]*gtsmodel.InteractionRequest, error) {
// Preallocate expected length of uncached interaction requests.
requests := make([]*gtsmodel.InteractionRequest, 0, len(uncached))
// Perform database query scanning
// the remaining (uncached) IDs.
if err := i.db.NewSelect().
Model(&requests).
Where("? IN (?)", bun.Ident("id"), bun.In(uncached)).
Scan(ctx); err != nil {
return nil, err
}
return requests, nil
},
)
if err != nil {
return nil, err
}
// Reorder the requests by their
// IDs to ensure in correct order.
getID := func(r *gtsmodel.InteractionRequest) string { return r.ID }
xslices.OrderBy(requests, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
return requests, nil
}
// Populate all loaded interaction requests, removing those we
// fail to populate (removes needing so many nil checks everywhere).
requests = slices.DeleteFunc(requests, func(request *gtsmodel.InteractionRequest) bool {
if err := i.PopulateInteractionRequest(ctx, request); err != nil {
log.Errorf(ctx, "error populating %s: %v", request.ID, err)
return true
}
return false
})
return requests, nil
}
func (i *interactionDB) getInteractionRequest(
ctx context.Context,
lookup string,
@ -205,13 +254,18 @@ func (i *interactionDB) UpdateInteractionRequest(ctx context.Context, request *g
}
func (i *interactionDB) DeleteInteractionRequestByID(ctx context.Context, id string) error {
defer i.state.Caches.DB.InteractionRequest.Invalidate("ID", id)
// Delete interaction request by ID.
if _, err := i.db.NewDelete().
Table("interaction_requests").
Where("? = ?", bun.Ident("id"), id).
Exec(ctx); err != nil {
return err
}
_, err := i.db.NewDelete().
TableExpr("? AS ?", bun.Ident("interaction_requests"), bun.Ident("interaction_request")).
Where("? = ?", bun.Ident("interaction_request.id"), id).
Exec(ctx)
return err
// Invalidate cached interaction request with ID.
i.state.Caches.DB.InteractionRequest.Invalidate("ID", id)
return nil
}
func (i *interactionDB) GetInteractionsRequestsForAcct(
@ -248,9 +302,9 @@ func (i *interactionDB) GetInteractionsRequestsForAcct(
bun.Ident("interaction_request"),
).
// Select only interaction requests that
// are neither accepted or rejected yet,
// ie., without an Accept or Reject URI.
Where("? IS NULL", bun.Ident("uri"))
// are neither accepted or rejected yet.
Where("? IS NULL", bun.Ident("accepted_at")).
Where("? IS NULL", bun.Ident("rejected_at"))
// Select interactions targeting status.
if statusID != "" {
@ -317,19 +371,8 @@ func (i *interactionDB) GetInteractionsRequestsForAcct(
slices.Reverse(reqIDs)
}
// For each interaction request ID,
// select the interaction request.
reqs := make([]*gtsmodel.InteractionRequest, 0, len(reqIDs))
for _, id := range reqIDs {
req, err := i.GetInteractionRequestByID(ctx, id)
if err != nil {
return nil, err
}
reqs = append(reqs, req)
}
return reqs, nil
// Load all interaction requests by their IDs.
return i.GetInteractionRequestsByIDs(ctx, reqIDs)
}
func (i *interactionDB) IsInteractionRejected(ctx context.Context, interactionURI string) (bool, error) {

View file

@ -59,11 +59,7 @@ func (suite *InteractionTestSuite) markInteractionsPending(
// Put an interaction request
// in the DB for this reply.
req, err := typeutils.StatusToInteractionRequest(ctx, reply)
if err != nil {
suite.FailNow(err.Error())
}
req := typeutils.StatusToInteractionRequest(reply)
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
@ -90,11 +86,7 @@ func (suite *InteractionTestSuite) markInteractionsPending(
// Put an interaction request
// in the DB for this boost.
req, err := typeutils.StatusToInteractionRequest(ctx, boost)
if err != nil {
suite.FailNow(err.Error())
}
req := typeutils.StatusToInteractionRequest(boost)
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
@ -121,11 +113,7 @@ func (suite *InteractionTestSuite) markInteractionsPending(
// Put an interaction request
// in the DB for this fave.
req, err := typeutils.StatusFaveToInteractionRequest(ctx, fave)
if err != nil {
suite.FailNow(err.Error())
}
req := typeutils.StatusFaveToInteractionRequest(fave)
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}

View file

@ -31,7 +31,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -333,7 +333,7 @@ func (l *listDB) GetListsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.L
// Reorder the lists by their
// IDs to ensure in correct order.
getID := func(l *gtsmodel.List) string { return l.ID }
util.OrderBy(lists, ids, getID)
xslices.OrderBy(lists, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
@ -387,12 +387,12 @@ func (l *listDB) PutListEntries(ctx context.Context, entries []*gtsmodel.ListEnt
}
// Collect unique list IDs from the provided list entries.
listIDs := util.Collate(entries, func(e *gtsmodel.ListEntry) string {
listIDs := xslices.Collate(entries, func(e *gtsmodel.ListEntry) string {
return e.ListID
})
// Collect unique follow IDs from the provided list entries.
followIDs := util.Collate(entries, func(e *gtsmodel.ListEntry) string {
followIDs := xslices.Collate(entries, func(e *gtsmodel.ListEntry) string {
return e.FollowID
})
@ -441,7 +441,7 @@ func (l *listDB) DeleteAllListEntriesByFollows(ctx context.Context, followIDs ..
}
// Deduplicate IDs before invalidate.
listIDs = util.Deduplicate(listIDs)
listIDs = xslices.Deduplicate(listIDs)
// Invalidate all related list entry caches.
l.invalidateEntryCaches(ctx, listIDs, followIDs)

View file

@ -28,7 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -78,7 +78,7 @@ func (m *mediaDB) GetAttachmentsByIDs(ctx context.Context, ids []string) ([]*gts
// Reorder the media by their
// IDs to ensure in correct order.
getID := func(m *gtsmodel.MediaAttachment) string { return m.ID }
util.OrderBy(media, ids, getID)
xslices.OrderBy(media, ids, getID)
return media, nil
}
@ -104,12 +104,6 @@ func (m *mediaDB) PutAttachment(ctx context.Context, media *gtsmodel.MediaAttach
}
func (m *mediaDB) UpdateAttachment(ctx context.Context, media *gtsmodel.MediaAttachment, columns ...string) error {
media.UpdatedAt = time.Now()
if len(columns) > 0 {
// If we're updating by column, ensure "updated_at" is included.
columns = append(columns, "updated_at")
}
return m.state.Caches.DB.Media.Store(media, func() error {
_, err := m.db.NewUpdate().
Model(media).

View file

@ -28,7 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -91,7 +91,7 @@ func (m *mentionDB) GetMentions(ctx context.Context, ids []string) ([]*gtsmodel.
// Reorder the mentions by their
// IDs to ensure in correct order.
getID := func(m *gtsmodel.Mention) string { return m.ID }
util.OrderBy(mentions, ids, getID)
xslices.OrderBy(mentions, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.

View file

@ -77,7 +77,7 @@ func init() {
UpdatedAt: oldAction.UpdatedAt,
TargetCategory: gtsmodel.AdminActionCategoryAccount,
TargetID: oldAction.TargetAccountID,
Type: gtsmodel.NewAdminActionType(string(oldAction.Type)),
Type: gtsmodel.ParseAdminActionType(string(oldAction.Type)),
AccountID: oldAction.AccountID,
Text: oldAction.Text,
SendEmail: util.Ptr(oldAction.SendEmail),

View file

@ -21,7 +21,8 @@ import (
"context"
"strings"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20231002153327_add_status_polls"
"github.com/uptrace/bun"
)

View file

@ -0,0 +1,48 @@
// 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"
)
// Poll represents an attached (to) Status poll, i.e. a questionaire. Can be remote / local.
type Poll struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // Unique identity string.
Multiple *bool `bun:",nullzero,notnull,default:false"` // Is this a multiple choice poll? i.e. can you vote on multiple options.
HideCounts *bool `bun:",nullzero,notnull,default:false"` // Hides vote counts until poll ends.
Options []string `bun:",nullzero,notnull"` // The available options for this poll.
Votes []int `bun:",nullzero,notnull"` // Vote counts per choice.
Voters *int `bun:",nullzero,notnull"` // Total no. voters count.
StatusID string `bun:"type:CHAR(26),nullzero,notnull,unique"` // Status ID of which this Poll is attached to.
ExpiresAt time.Time `bun:"type:timestamptz,nullzero,notnull"` // The expiry date of this Poll.
ClosedAt time.Time `bun:"type:timestamptz,nullzero"` // The closure date of this poll, will be zerotime until set.
Closing bool `bun:"-"` // An ephemeral field only set on Polls in the middle of closing.
// no creation date, use attached Status.CreatedAt.
}
// PollVote represents a single instance of vote(s) in a Poll by an account.
// If the Poll is single-choice, len(.Choices) = 1, if multiple-choice then
// len(.Choices) >= 1. Can be remote or local.
type PollVote struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // Unique identity string.
Choices []int `bun:",nullzero,notnull"` // The Poll's option indices of which these are votes for.
AccountID string `bun:"type:CHAR(26),nullzero,notnull,unique:in_poll_by_account"` // Account ID from which this vote originated.
PollID string `bun:"type:CHAR(26),nullzero,notnull,unique:in_poll_by_account"` // Poll ID of which this is a vote in.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // The creation date of this PollVote.
}

View file

@ -0,0 +1,75 @@
// 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"
// Status represents a user-created 'post' or 'status' in the database, either remote or local
type Status struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
FetchedAt time.Time `bun:"type:timestamptz,nullzero"` // when was item (remote) last fetched.
PinnedAt time.Time `bun:"type:timestamptz,nullzero"` // Status was pinned by owning account at this time.
URI string `bun:",unique,nullzero,notnull"` // activitypub URI of this status
URL string `bun:",nullzero"` // web url for viewing this status
Content string `bun:""` // content of this status; likely html-formatted but not guaranteed
AttachmentIDs []string `bun:"attachments,array"` // Database IDs of any media attachments associated with this status
TagIDs []string `bun:"tags,array"` // Database IDs of any tags used in this status
MentionIDs []string `bun:"mentions,array"` // Database IDs of any mentions in this status
EmojiIDs []string `bun:"emojis,array"` // Database IDs of any emojis used in this status
Local *bool `bun:",nullzero,notnull,default:false"` // is this status from a local account?
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // which account posted this status?
AccountURI string `bun:",nullzero,notnull"` // activitypub uri of the owner of this status
InReplyToID string `bun:"type:CHAR(26),nullzero"` // id of the status this status replies to
InReplyToURI string `bun:",nullzero"` // activitypub uri of the status this status is a reply to
InReplyToAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that this status replies to
BoostOfID string `bun:"type:CHAR(26),nullzero"` // id of the status this status is a boost of
BoostOfAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that owns the boosted status
ThreadID string `bun:"type:CHAR(26),nullzero"` // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null
PollID string `bun:"type:CHAR(26),nullzero"` //
ContentWarning string `bun:",nullzero"` // cw string for this status
Visibility Visibility `bun:",nullzero,notnull"` // visibility entry for this status
Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive?
Language string `bun:",nullzero"` // what language is this status written in?
CreatedWithApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application was used to create this status?
ActivityStreamsType string `bun:",nullzero,notnull"` // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types. Will probably almost always be Note but who knows!.
Text string `bun:""` // Original text of the status without formatting
Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s)
Boostable *bool `bun:",notnull"` // This status can be boosted/reblogged
Replyable *bool `bun:",notnull"` // This status can be replied to
Likeable *bool `bun:",notnull"` // This status can be liked/faved
}
// Visibility represents the visibility granularity of a status.
type Visibility string
const (
// VisibilityPublic means this status will be visible to everyone on all timelines.
VisibilityPublic Visibility = "public"
// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = "unlocked"
// VisibilityFollowersOnly means this status is viewable to followers only.
VisibilityFollowersOnly Visibility = "followers_only"
// VisibilityMutualsOnly means this status is visible to mutual followers only.
VisibilityMutualsOnly Visibility = "mutuals_only"
// VisibilityDirect means this status is visible only to mentioned recipients.
VisibilityDirect Visibility = "direct"
// VisibilityDefault is used when no other setting can be found.
VisibilityDefault Visibility = VisibilityUnlocked
)

View file

@ -20,7 +20,7 @@ package migrations
import (
"context"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20240126064004_add_filters"
"github.com/uptrace/bun"
)

View file

@ -0,0 +1,65 @@
// 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 (
"regexp"
"time"
)
// Filter stores a filter created by a local account.
type Filter struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time filter should expire. If null, should not expire.
AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter.
Title string `bun:",nullzero,notnull,unique"` // The name of the filter.
Action string `bun:",nullzero,notnull"` // The action to take.
Keywords []*FilterKeyword `bun:"-"` // Keywords for this filter.
Statuses []*FilterStatus `bun:"-"` // Statuses for this filter.
ContextHome *bool `bun:",nullzero,notnull,default:false"` // Apply filter to home timeline and lists.
ContextNotifications *bool `bun:",nullzero,notnull,default:false"` // Apply filter to notifications.
ContextPublic *bool `bun:",nullzero,notnull,default:false"` // Apply filter to home timeline and lists.
ContextThread *bool `bun:",nullzero,notnull,default:false"` // Apply filter when viewing a status's associated thread.
ContextAccount *bool `bun:",nullzero,notnull,default:false"` // Apply filter when viewing an account profile.
}
// FilterKeyword stores a single keyword to filter statuses against.
type FilterKeyword struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter keyword.
FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_keywords_filter_id_keyword_uniq"` // ID of the filter that this keyword belongs to.
Filter *Filter `bun:"-"` // Filter corresponding to FilterID
Keyword string `bun:",nullzero,notnull,unique:filter_keywords_filter_id_keyword_uniq"` // The keyword or phrase to filter against.
WholeWord *bool `bun:",nullzero,notnull,default:false"` // Should the filter consider word boundaries?
Regexp *regexp.Regexp `bun:"-"` // pre-prepared regular expression
}
// FilterStatus stores a single status to filter.
type FilterStatus struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter keyword.
FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the filter that this keyword belongs to.
Filter *Filter `bun:"-"` // Filter corresponding to FilterID
StatusID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the status to filter.
}

View file

@ -40,7 +40,7 @@ func init() {
table string
column string
columnType string
defaultVal string
extra string
}
for _, spec := range []spec{
// Statuses.
@ -48,19 +48,19 @@ func init() {
table: "statuses",
column: "interaction_policy",
columnType: "JSONB",
defaultVal: "",
extra: "",
},
{
table: "statuses",
column: "pending_approval",
columnType: "BOOLEAN",
defaultVal: "DEFAULT false",
extra: "NOT NULL DEFAULT false",
},
{
table: "statuses",
column: "approved_by_uri",
columnType: "varchar",
defaultVal: "",
extra: "",
},
// Status faves.
@ -68,13 +68,13 @@ func init() {
table: "status_faves",
column: "pending_approval",
columnType: "BOOLEAN",
defaultVal: "DEFAULT false",
extra: "NOT NULL DEFAULT false",
},
{
table: "status_faves",
column: "approved_by_uri",
columnType: "varchar",
defaultVal: "",
extra: "",
},
// Columns that must be added to the
@ -85,31 +85,31 @@ func init() {
table: "account_settings",
column: "interaction_policy_direct",
columnType: "JSONB",
defaultVal: "",
extra: "",
},
{
table: "account_settings",
column: "interaction_policy_mutuals_only",
columnType: "JSONB",
defaultVal: "",
extra: "",
},
{
table: "account_settings",
column: "interaction_policy_followers_only",
columnType: "JSONB",
defaultVal: "",
extra: "",
},
{
table: "account_settings",
column: "interaction_policy_unlocked",
columnType: "JSONB",
defaultVal: "",
extra: "",
},
{
table: "account_settings",
column: "interaction_policy_public",
columnType: "JSONB",
defaultVal: "",
extra: "",
},
} {
exists, err := doesColumnExist(ctx, tx,
@ -130,9 +130,9 @@ func init() {
}
qStr := "ALTER TABLE ? ADD COLUMN ? ?"
if spec.defaultVal != "" {
if spec.extra != "" {
qStr += " ?"
args = append(args, bun.Safe(spec.defaultVal))
args = append(args, bun.Safe(spec.extra))
}
log.Infof(ctx, "adding column '%s' to '%s'...", spec.column, spec.table)
@ -161,11 +161,14 @@ func init() {
return err
}
// Get the mapping of old enum string values to new integer values.
visibilityMapping := visibilityEnumMapping[oldmodel.Visibility]()
// 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)
v := visibilityMapping[oldStatus.Visibility]
policy := gtsmodel.DefaultInteractionPolicyFor(v)
if !*oldStatus.Likeable {

View file

@ -22,40 +22,61 @@ import (
)
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"`
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 Visibility `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"`
}
// Visibility represents the visibility granularity of a status.
type Visibility string
const (
// VisibilityNone means nobody can see this.
// It's only used for web status visibility.
VisibilityNone Visibility = "none"
// VisibilityPublic means this status will be visible to everyone on all timelines.
VisibilityPublic Visibility = "public"
// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = "unlocked"
// VisibilityFollowersOnly means this status is viewable to followers only.
VisibilityFollowersOnly Visibility = "followers_only"
// VisibilityMutualsOnly means this status is visible to mutual followers only.
VisibilityMutualsOnly Visibility = "mutuals_only"
// VisibilityDirect means this status is visible only to mentioned recipients.
VisibilityDirect Visibility = "direct"
// VisibilityDefault is used when no other setting can be found.
VisibilityDefault Visibility = VisibilityUnlocked
)

View file

@ -93,11 +93,7 @@ func init() {
// For each currently pending status, check whether it's a reply or
// a boost, and insert a corresponding interaction request into the db.
for _, pendingStatus := range pendingStatuses {
req, err := typeutils.StatusToInteractionRequest(ctx, pendingStatus)
if err != nil {
return err
}
req := typeutils.StatusToInteractionRequest(pendingStatus)
if _, err := tx.
NewInsert().
Model(req).
@ -125,10 +121,7 @@ func init() {
}
for _, pendingFave := range pendingFaves {
req, err := typeutils.StatusFaveToInteractionRequest(ctx, pendingFave)
if err != nil {
return err
}
req := typeutils.StatusFaveToInteractionRequest(pendingFave)
if _, err := tx.
NewInsert().

View file

@ -20,7 +20,7 @@ package migrations
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20240904084406_fedi_api_reject_interaction"
"github.com/uptrace/bun"
)

View file

@ -0,0 +1,66 @@
// 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"
// SinBinStatus represents a status that's been rejected and/or reported + quarantined.
//
// Automatically rejected statuses are not put in the sin bin, only statuses that were
// stored on the instance and which someone (local or remote) has subsequently rejected.
type SinBinStatus struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Creation time of this item.
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Last-updated time of this item.
URI string `bun:",unique,nullzero,notnull"` // ActivityPub URI/ID of this status.
URL string `bun:",nullzero"` // Web url for viewing this status.
Domain string `bun:",nullzero"` // Domain of the status, will be null if this is a local status, otherwise something like `example.org`.
AccountURI string `bun:",nullzero,notnull"` // ActivityPub uri of the author of this status.
InReplyToURI string `bun:",nullzero"` // ActivityPub uri of the status this status is a reply to.
Content string `bun:",nullzero"` // Content of this status.
AttachmentLinks []string `bun:",nullzero,array"` // Links to attachments of this status.
MentionTargetURIs []string `bun:",nullzero,array"` // URIs of mentioned accounts.
EmojiLinks []string `bun:",nullzero,array"` // Links to any emoji images used in this status.
PollOptions []string `bun:",nullzero,array"` // String values of any poll options used in this status.
ContentWarning string `bun:",nullzero"` // CW / subject string for this status.
Visibility Visibility `bun:",nullzero,notnull"` // Visibility level of this status.
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Mark the status as sensitive.
Language string `bun:",nullzero"` // Language code for this status.
ActivityStreamsType string `bun:",nullzero,notnull"` // ActivityStreams type of this status.
}
// Visibility represents the visibility granularity of a status.
type Visibility string
const (
// VisibilityNone means nobody can see this.
// It's only used for web status visibility.
VisibilityNone Visibility = "none"
// VisibilityPublic means this status will be visible to everyone on all timelines.
VisibilityPublic Visibility = "public"
// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = "unlocked"
// VisibilityFollowersOnly means this status is viewable to followers only.
VisibilityFollowersOnly Visibility = "followers_only"
// VisibilityMutualsOnly means this status is visible to mutual followers only.
VisibilityMutualsOnly Visibility = "mutuals_only"
// VisibilityDirect means this status is visible only to mentioned recipients.
VisibilityDirect Visibility = "direct"
// VisibilityDefault is used when no other setting can be found.
VisibilityDefault Visibility = VisibilityUnlocked
)

View file

@ -0,0 +1,44 @@
// 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"
"strings"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? TEXT", bun.Ident("instances"), bun.Ident("custom_css"))
if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) {
return err
}
return nil
}
down := func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, "ALTER TABLE ? DROP COLUMN ?", bun.Ident("instances"), bun.Ident("custom_css"))
return err
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -0,0 +1,57 @@
// 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/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 {
for idx, col := range map[string]string{
"interaction_requests_accepted_at_idx": "accepted_at",
"interaction_requests_rejected_at_idx": "rejected_at",
} {
if _, err := tx.
NewCreateIndex().
Table("interaction_requests").
Index(idx).
Column(col).
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,80 @@
// 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"
"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 {
// Previous versions of 20240620074530_interaction_policy.go
// didn't set NOT NULL on gtsmodel.Status.PendingApproval and
// gtsmodel.StatusFave.PendingApproval, resulting in NULL being
// set for that column for some statuses. Correct for this.
log.Info(ctx, "correcting pending_approval on statuses table...")
res, err := tx.
NewUpdate().
Table("statuses").
Set("? = ?", bun.Ident("pending_approval"), false).
Where("? IS NULL", bun.Ident("pending_approval")).
Exec(ctx)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err == nil {
log.Infof(ctx, "corrected %d entries", rows)
}
log.Info(ctx, "correcting pending_approval on status_faves table...")
res, err = tx.
NewUpdate().
Table("status_faves").
Set("? = ?", bun.Ident("pending_approval"), false).
Where("? IS NULL", bun.Ident("pending_approval")).
Exec(ctx)
if err != nil {
return err
}
rows, err = res.RowsAffected()
if err == nil {
log.Infof(ctx, "corrected %d entries", rows)
}
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,131 @@
// 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/gtsmodel"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Create the new filters table
// with the unique constraint
// set on AccountID + Title.
if _, err := tx.
NewCreateTable().
ModelTableExpr("new_filters").
Model((*gtsmodel.Filter)(nil)).
Exec(ctx); err != nil {
return err
}
// Explicitly specify columns to bring
// from old table to new, to avoid any
// potential Postgres shenanigans.
columns := []string{
"id",
"created_at",
"updated_at",
"expires_at",
"account_id",
"title",
"action",
"context_home",
"context_notifications",
"context_public",
"context_thread",
"context_account",
}
// Copy all data for existing
// filters to the new table.
if _, err := tx.
NewInsert().
Table("new_filters").
Table("filters").
Column(columns...).
Exec(ctx); err != nil {
return err
}
// Drop the old table.
if _, err := tx.
NewDropTable().
Table("filters").
Exec(ctx); err != nil {
return err
}
// Rename new table to old table.
if _, err := tx.
ExecContext(
ctx,
"ALTER TABLE ? RENAME TO ?",
bun.Ident("new_filters"),
bun.Ident("filters"),
); err != nil {
return err
}
// Index the new version
// of the filters table.
if _, err := tx.
NewCreateIndex().
Table("filters").
Index("filters_account_id_idx").
Column("account_id").
IfNotExists().
Exec(ctx); err != nil {
return err
}
if db.Dialect().Name() == dialect.PG {
// Rename "new_filters_pkey" from the
// new table to just "filters_pkey".
// This is only necessary on Postgres.
if _, err := tx.ExecContext(
ctx,
"ALTER TABLE ? RENAME CONSTRAINT ? TO ?",
bun.Ident("public.filters"),
bun.Safe("new_filters_pkey"),
bun.Safe("filters_pkey"),
); 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,82 @@
// 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"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude"
"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 {
// Create `domain_permission_drafts`.
if _, err := tx.
NewCreateTable().
Model((*gtsmodel.DomainPermissionDraft)(nil)).
IfNotExists().
Exec(ctx); err != nil {
return err
}
// Create `domain_permission_ignores`.
if _, err := tx.
NewCreateTable().
Model((*gtsmodel.DomainPermissionExclude)(nil)).
IfNotExists().
Exec(ctx); err != nil {
return err
}
// Create indexes. Indices. Indie sexes.
for table, indexes := range map[string]map[string][]string{
"domain_permission_drafts": {
"domain_permission_drafts_domain_idx": {"domain"},
"domain_permission_drafts_subscription_id_idx": {"subscription_id"},
},
} {
for index, columns := range indexes {
if _, err := tx.
NewCreateIndex().
Table(table).
Index(index).
Column(columns...).
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,33 @@
// 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 DomainPermissionDraft 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"`
PermissionType uint8 `bun:",notnull,unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"`
Domain string `bun:",nullzero,notnull,unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"`
CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"`
PrivateComment string `bun:",nullzero"`
PublicComment string `bun:",nullzero"`
Obfuscate *bool `bun:",nullzero,notnull,default:false"`
SubscriptionID string `bun:"type:CHAR(26),unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"`
}

View file

@ -0,0 +1,31 @@
// 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 DomainPermissionExclude 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"`
Domain string `bun:",nullzero,notnull,unique"`
CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"`
PrivateComment string `bun:",nullzero"`
}

View file

@ -0,0 +1,75 @@
// 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"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241022153016_domain_permission_subscriptions"
"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 {
// Create `domain_permission_subscriptions`.
if _, err := tx.
NewCreateTable().
Model((*gtsmodel.DomainPermissionSubscription)(nil)).
IfNotExists().
Exec(ctx); err != nil {
return err
}
// Create indexes. Indices. Indie sexes.
if _, err := tx.
NewCreateIndex().
Table("domain_permission_subscriptions").
// Filter on permission type.
Index("domain_permission_subscriptions_permission_type_idx").
Column("permission_type").
IfNotExists().
Exec(ctx); err != nil {
return err
}
if _, err := tx.
NewCreateIndex().
Table("domain_permission_subscriptions").
// Sort by priority DESC.
Index("domain_permission_subscriptions_priority_order_idx").
ColumnExpr("? DESC", bun.Ident("priority")).
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,38 @@
// 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 DomainPermissionSubscription struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
Priority uint8 `bun:""`
Title string `bun:",nullzero,unique"`
PermissionType uint8 `bun:",nullzero,notnull"`
AsDraft *bool `bun:",nullzero,notnull,default:true"`
AdoptOrphans *bool `bun:",nullzero,notnull,default:false"`
CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"`
URI string `bun:",nullzero,notnull,unique"`
ContentType uint16 `bun:",nullzero,notnull"`
FetchUsername string `bun:",nullzero"`
FetchPassword string `bun:",nullzero"`
FetchedAt time.Time `bun:"type:timestamptz,nullzero"`
SuccessfullyFetchedAt time.Time `bun:"type:timestamptz,nullzero"`
ETag string `bun:"etag,nullzero"`
Error string `bun:",nullzero"`
}

View file

@ -0,0 +1,59 @@
// 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/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"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 {
// Check for 'updated_at' column on mentions table, else return.
exists, err := doesColumnExist(ctx, tx, "mentions", "updated_at")
if err != nil {
return err
} else if !exists {
return nil
}
// Remove 'updated_at' column.
log.Info(ctx, "removing unused updated_at column from mentions to save space, please wait...")
_, err = tx.NewDropColumn().
Model((*gtsmodel.Mention)(nil)).
Column("updated_at").
Exec(ctx)
return err
})
}
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,69 @@
// 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"
"reflect"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241113152126_add_status_edits"
"github.com/superseriousbusiness/gotosocial/internal/log"
"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 {
statusType := reflect.TypeOf((*gtsmodel.Status)(nil))
// Generate new Status.EditIDs column definition from bun.
colDef, err := getBunColumnDef(tx, statusType, "EditIDs")
if err != nil {
return err
}
// Add EditIDs column to Status table.
log.Info(ctx, "adding edits column to statuses table...")
_, err = tx.NewAddColumn().
Model((*gtsmodel.Status)(nil)).
ColumnExpr(colDef).
Exec(ctx)
if err != nil {
return err
}
// Create the main StatusEdits table.
_, err = tx.NewCreateTable().
IfNotExists().
Model((*gtsmodel.StatusEdit)(nil)).
Exec(ctx)
return err
})
}
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,97 @@
// 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"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// Status represents a user-created 'post' or 'status' in the database, either remote or local
type Status struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
FetchedAt time.Time `bun:"type:timestamptz,nullzero"` // when was item (remote) last fetched.
PinnedAt time.Time `bun:"type:timestamptz,nullzero"` // Status was pinned by owning account at this time.
URI string `bun:",unique,nullzero,notnull"` // activitypub URI of this status
URL string `bun:",nullzero"` // web url for viewing this status
Content string `bun:""` // content of this status; likely html-formatted but not guaranteed
AttachmentIDs []string `bun:"attachments,array"` // Database IDs of any media attachments associated with this status
Attachments []*gtsmodel.MediaAttachment `bun:"attached_media,rel:has-many"` // Attachments corresponding to attachmentIDs
TagIDs []string `bun:"tags,array"` // Database IDs of any tags used in this status
Tags []*gtsmodel.Tag `bun:"attached_tags,m2m:status_to_tags"` // Tags corresponding to tagIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
MentionIDs []string `bun:"mentions,array"` // Database IDs of any mentions in this status
Mentions []*gtsmodel.Mention `bun:"attached_mentions,rel:has-many"` // Mentions corresponding to mentionIDs
EmojiIDs []string `bun:"emojis,array"` // Database IDs of any emojis used in this status
Emojis []*gtsmodel.Emoji `bun:"attached_emojis,m2m:status_to_emojis"` // Emojis corresponding to emojiIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
Local *bool `bun:",nullzero,notnull,default:false"` // is this status from a local account?
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // which account posted this status?
Account *gtsmodel.Account `bun:"rel:belongs-to"` // account corresponding to accountID
AccountURI string `bun:",nullzero,notnull"` // activitypub uri of the owner of this status
InReplyToID string `bun:"type:CHAR(26),nullzero"` // id of the status this status replies to
InReplyToURI string `bun:",nullzero"` // activitypub uri of the status this status is a reply to
InReplyToAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that this status replies to
InReplyTo *Status `bun:"-"` // status corresponding to inReplyToID
InReplyToAccount *gtsmodel.Account `bun:"rel:belongs-to"` // account corresponding to inReplyToAccountID
BoostOfID string `bun:"type:CHAR(26),nullzero"` // id of the status this status is a boost of
BoostOfURI string `bun:"-"` // URI of the status this status is a boost of; field not inserted in the db, just for dereferencing purposes.
BoostOfAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that owns the boosted status
BoostOf *Status `bun:"-"` // status that corresponds to boostOfID
BoostOfAccount *gtsmodel.Account `bun:"rel:belongs-to"` // account that corresponds to boostOfAccountID
ThreadID string `bun:"type:CHAR(26),nullzero"` // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null
EditIDs []string `bun:"edits,array"` //
Edits []*StatusEdit `bun:"-"` //
PollID string `bun:"type:CHAR(26),nullzero"` //
Poll *gtsmodel.Poll `bun:"-"` //
ContentWarning string `bun:",nullzero"` // cw string for this status
Visibility Visibility `bun:",nullzero,notnull"` // visibility entry for this status
Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive?
Language string `bun:",nullzero"` // what language is this status written in?
CreatedWithApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application was used to create this status?
CreatedWithApplication *gtsmodel.Application `bun:"rel:belongs-to"` // application corresponding to createdWithApplicationID
ActivityStreamsType string `bun:",nullzero,notnull"` // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types. Will probably almost always be Note but who knows!.
Text string `bun:""` // Original text of the status without formatting
Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s)
InteractionPolicy *gtsmodel.InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers.
PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed.
PreApproved bool `bun:"-"` // If true, then status is a reply to or boost wrapper of a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB.
ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves the Announce or Create Activity that this status was/will be attached to.
}
// Visibility represents the visibility granularity of a status.
type Visibility string
const (
// VisibilityNone means nobody can see this.
// It's only used for web status visibility.
VisibilityNone Visibility = "none"
// VisibilityPublic means this status will be visible to everyone on all timelines.
VisibilityPublic Visibility = "public"
// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = "unlocked"
// VisibilityFollowersOnly means this status is viewable to followers only.
VisibilityFollowersOnly Visibility = "followers_only"
// VisibilityMutualsOnly means this status is visible to mutual followers only.
VisibilityMutualsOnly Visibility = "mutuals_only"
// VisibilityDirect means this status is visible only to mentioned recipients.
VisibilityDirect Visibility = "direct"
// VisibilityDefault is used when no other setting can be found.
VisibilityDefault Visibility = VisibilityUnlocked
)

View file

@ -0,0 +1,48 @@
// 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"
)
// StatusEdit represents a **historical** view of a Status
// after a received edit. The Status itself will always
// contain the latest up-to-date information.
//
// Note that stored status edits may not exactly match that
// of the origin server, they are a best-effort by receiver
// to store version history. There is no AP history endpoint.
type StatusEdit struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database.
Content string `bun:""` // Content of status at time of edit; likely html-formatted but not guaranteed.
ContentWarning string `bun:",nullzero"` // Content warning of status at time of edit.
Text string `bun:""` // Original status text, without formatting, at time of edit.
Language string `bun:",nullzero"` // Status language at time of edit.
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Status sensitive flag at time of edit.
AttachmentIDs []string `bun:"attachments,array"` // Database IDs of media attachments associated with status at time of edit.
AttachmentDescriptions []string `bun:",array"` // Previous media descriptions of media attachments associated with status at time of edit.
PollOptions []string `bun:",array"` // Poll options of status at time of edit, only set if status contains a poll.
PollVotes []int `bun:",array"` // Poll vote count at time of status edit, only set if poll votes were reset.
StatusID string `bun:"type:CHAR(26),nullzero,notnull"` // The originating status ID this is a historical edit of.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // The creation time of this version of the status content (according to receiving server).
// We don't bother having a *gtsmodel.Status model here
// as the StatusEdit is always just attached to a Status,
// so it doesn't need a self-reference back to it.
}

View file

@ -0,0 +1,160 @@
// 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"
old_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints"
new_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
"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 {
// Tables with visibility types.
var visTables = []struct {
Table string
Column string
Default *new_gtsmodel.Visibility
}{
{Table: "statuses", Column: "visibility"},
{Table: "sin_bin_statuses", Column: "visibility"},
{Table: "account_settings", Column: "privacy", Default: util.Ptr(new_gtsmodel.VisibilityDefault)},
{Table: "account_settings", Column: "web_visibility", Default: util.Ptr(new_gtsmodel.VisibilityDefault)},
}
// Visibility type indices.
var visIndices = []struct {
name string
cols []string
order string
}{
{
name: "statuses_visibility_idx",
cols: []string{"visibility"},
order: "",
},
{
name: "statuses_profile_web_view_idx",
cols: []string{"account_id", "visibility"},
order: "id DESC",
},
{
name: "statuses_public_timeline_idx",
cols: []string{"visibility"},
order: "id DESC",
},
}
// Before making changes to the visibility col
// we must drop all indices that rely on it.
log.Info(ctx, "dropping old visibility indexes...")
for _, index := range visIndices {
log.Info(ctx, "dropping old index %s...", index.name)
if _, err := tx.NewDropIndex().
Index(index.name).
Exec(ctx); err != nil {
return err
}
}
// Get the mapping of old enum string values to new integer values.
visibilityMapping := visibilityEnumMapping[old_gtsmodel.Visibility]()
// Convert all visibility tables.
for _, table := range visTables {
if err := convertEnums(ctx, tx, table.Table, table.Column,
visibilityMapping, table.Default); err != nil {
return err
}
}
// Recreate the visibility indices.
log.Info(ctx, "creating new visibility indexes...")
for _, index := range visIndices {
log.Info(ctx, "creating new index %s...", index.name)
q := tx.NewCreateIndex().
Table("statuses").
Index(index.name).
Column(index.cols...)
if index.order != "" {
q = q.ColumnExpr(index.order)
}
if _, err := q.Exec(ctx); err != nil {
return err
}
}
// Get the mapping of old enum string values to the new integer value types.
notificationMapping := notificationEnumMapping[old_gtsmodel.NotificationType]()
// Migrate over old notifications table column over to new column type.
if err := convertEnums(ctx, tx, "notifications", "notification_type", //nolint:revive
notificationMapping, nil); 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)
}
}
// visibilityEnumMapping maps old Visibility enum values to their newer integer type.
func visibilityEnumMapping[T ~string]() map[T]new_gtsmodel.Visibility {
return map[T]new_gtsmodel.Visibility{
T(old_gtsmodel.VisibilityNone): new_gtsmodel.VisibilityNone,
T(old_gtsmodel.VisibilityPublic): new_gtsmodel.VisibilityPublic,
T(old_gtsmodel.VisibilityUnlocked): new_gtsmodel.VisibilityUnlocked,
T(old_gtsmodel.VisibilityFollowersOnly): new_gtsmodel.VisibilityFollowersOnly,
T(old_gtsmodel.VisibilityMutualsOnly): new_gtsmodel.VisibilityMutualsOnly,
T(old_gtsmodel.VisibilityDirect): new_gtsmodel.VisibilityDirect,
}
}
// notificationEnumMapping maps old NotificationType enum values to their newer integer type.
func notificationEnumMapping[T ~string]() map[T]new_gtsmodel.NotificationType {
return map[T]new_gtsmodel.NotificationType{
T(old_gtsmodel.NotificationFollow): new_gtsmodel.NotificationFollow,
T(old_gtsmodel.NotificationFollowRequest): new_gtsmodel.NotificationFollowRequest,
T(old_gtsmodel.NotificationMention): new_gtsmodel.NotificationMention,
T(old_gtsmodel.NotificationReblog): new_gtsmodel.NotificationReblog,
T(old_gtsmodel.NotificationFave): new_gtsmodel.NotificationFave,
T(old_gtsmodel.NotificationPoll): new_gtsmodel.NotificationPoll,
T(old_gtsmodel.NotificationStatus): new_gtsmodel.NotificationStatus,
T(old_gtsmodel.NotificationSignup): new_gtsmodel.NotificationSignup,
T(old_gtsmodel.NotificationPendingFave): new_gtsmodel.NotificationPendingFave,
T(old_gtsmodel.NotificationPendingReply): new_gtsmodel.NotificationPendingReply,
T(old_gtsmodel.NotificationPendingReblog): new_gtsmodel.NotificationPendingReblog,
}
}

View file

@ -0,0 +1,45 @@
// 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"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// AccountSettings models settings / preferences for a local, non-instance account.
type AccountSettings struct {
AccountID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // AccountID that owns this settings.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created.
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item was last updated.
Privacy Visibility `bun:",nullzero,default:3"` // Default post privacy for this account
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Set posts from this account to sensitive by default?
Language string `bun:",nullzero,notnull,default:'en'"` // What language does this account post in?
StatusContentType string `bun:",nullzero"` // What is the default format for statuses posted by this account (only for local accounts).
Theme string `bun:",nullzero"` // Preset CSS theme filename selected by this Account (empty string if nothing set).
CustomCSS string `bun:",nullzero"` // Custom CSS that should be displayed for this Account's profile and statuses.
EnableRSS *bool `bun:",nullzero,notnull,default:false"` // enable RSS feed subscription for this account's public posts at [URL]/feed
HideCollections *bool `bun:",nullzero,notnull,default:false"` // Hide this account's followers/following collections.
WebVisibility Visibility `bun:",nullzero,notnull,default:3"` // Visibility level of statuses that visitors can view via the web profile.
InteractionPolicyDirect *gtsmodel.InteractionPolicy `bun:""` // Interaction policy to use for new direct visibility statuses by this account. If null, assume default policy.
InteractionPolicyMutualsOnly *gtsmodel.InteractionPolicy `bun:""` // Interaction policy to use for new mutuals only visibility statuses. If null, assume default policy.
InteractionPolicyFollowersOnly *gtsmodel.InteractionPolicy `bun:""` // Interaction policy to use for new followers only visibility statuses. If null, assume default policy.
InteractionPolicyUnlocked *gtsmodel.InteractionPolicy `bun:""` // Interaction policy to use for new unlocked visibility statuses. If null, assume default policy.
InteractionPolicyPublic *gtsmodel.InteractionPolicy `bun:""` // Interaction policy to use for new public visibility statuses. If null, assume default policy.
}

View file

@ -0,0 +1,57 @@
// 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"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// Notification models an alert/notification sent to an account about something like a reblog, like, new follow request, etc.
type Notification struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
NotificationType NotificationType `bun:",nullzero,notnull"` // Type of this notification
TargetAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // ID of the account targeted by the notification (ie., who will receive the notification?)
TargetAccount *gtsmodel.Account `bun:"-"` // Account corresponding to TargetAccountID. Can be nil, always check first + select using ID if necessary.
OriginAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // ID of the account that performed the action that created the notification.
OriginAccount *gtsmodel.Account `bun:"-"` // Account corresponding to OriginAccountID. Can be nil, always check first + select using ID if necessary.
StatusID string `bun:"type:CHAR(26),nullzero"` // If the notification pertains to a status, what is the database ID of that status?
Status *Status `bun:"-"` // Status corresponding to StatusID. Can be nil, always check first + select using ID if necessary.
Read *bool `bun:",nullzero,notnull,default:false"` // Notification has been seen/read
}
// NotificationType describes the reason/type of this notification.
type NotificationType string
// Notification Types
const (
NotificationFollow NotificationType = "follow" // NotificationFollow -- someone followed you
NotificationFollowRequest NotificationType = "follow_request" // NotificationFollowRequest -- someone requested to follow you
NotificationMention NotificationType = "mention" // NotificationMention -- someone mentioned you in their status
NotificationReblog NotificationType = "reblog" // NotificationReblog -- someone boosted one of your statuses
NotificationFave NotificationType = "favourite" // NotificationFave -- someone faved/liked one of your statuses
NotificationPoll NotificationType = "poll" // NotificationPoll -- a poll you voted in or created has ended
NotificationStatus NotificationType = "status" // NotificationStatus -- someone you enabled notifications for has posted a status.
NotificationSignup NotificationType = "admin.sign_up" // NotificationSignup -- someone has submitted a new account sign-up to the instance.
NotificationPendingFave NotificationType = "pending.favourite" // Someone has faved a status of yours, which requires approval by you.
NotificationPendingReply NotificationType = "pending.reply" // Someone has replied to a status of yours, which requires approval by you.
NotificationPendingReblog NotificationType = "pending.reblog" // Someone has boosted a status of yours, which requires approval by you.
)

View file

@ -0,0 +1,45 @@
// 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"
// SinBinStatus represents a status that's been rejected and/or reported + quarantined.
//
// Automatically rejected statuses are not put in the sin bin, only statuses that were
// stored on the instance and which someone (local or remote) has subsequently rejected.
type SinBinStatus struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Creation time of this item.
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Last-updated time of this item.
URI string `bun:",unique,nullzero,notnull"` // ActivityPub URI/ID of this status.
URL string `bun:",nullzero"` // Web url for viewing this status.
Domain string `bun:",nullzero"` // Domain of the status, will be null if this is a local status, otherwise something like `example.org`.
AccountURI string `bun:",nullzero,notnull"` // ActivityPub uri of the author of this status.
InReplyToURI string `bun:",nullzero"` // ActivityPub uri of the status this status is a reply to.
Content string `bun:",nullzero"` // Content of this status.
AttachmentLinks []string `bun:",nullzero,array"` // Links to attachments of this status.
MentionTargetURIs []string `bun:",nullzero,array"` // URIs of mentioned accounts.
EmojiLinks []string `bun:",nullzero,array"` // Links to any emoji images used in this status.
PollOptions []string `bun:",nullzero,array"` // String values of any poll options used in this status.
ContentWarning string `bun:",nullzero"` // CW / subject string for this status.
Visibility Visibility `bun:",nullzero,notnull"` // Visibility level of this status.
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Mark the status as sensitive.
Language string `bun:",nullzero"` // Language code for this status.
ActivityStreamsType string `bun:",nullzero,notnull"` // ActivityStreams type of this status.
}

View file

@ -0,0 +1,95 @@
// 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"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// Status represents a user-created 'post' or 'status' in the database, either remote or local
type Status struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
FetchedAt time.Time `bun:"type:timestamptz,nullzero"` // when was item (remote) last fetched.
PinnedAt time.Time `bun:"type:timestamptz,nullzero"` // Status was pinned by owning account at this time.
URI string `bun:",unique,nullzero,notnull"` // activitypub URI of this status
URL string `bun:",nullzero"` // web url for viewing this status
Content string `bun:""` // content of this status; likely html-formatted but not guaranteed
AttachmentIDs []string `bun:"attachments,array"` // Database IDs of any media attachments associated with this status
Attachments []*gtsmodel.MediaAttachment `bun:"attached_media,rel:has-many"` // Attachments corresponding to attachmentIDs
TagIDs []string `bun:"tags,array"` // Database IDs of any tags used in this status
Tags []*gtsmodel.Tag `bun:"attached_tags,m2m:status_to_tags"` // Tags corresponding to tagIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
MentionIDs []string `bun:"mentions,array"` // Database IDs of any mentions in this status
Mentions []*gtsmodel.Mention `bun:"attached_mentions,rel:has-many"` // Mentions corresponding to mentionIDs
EmojiIDs []string `bun:"emojis,array"` // Database IDs of any emojis used in this status
Emojis []*gtsmodel.Emoji `bun:"attached_emojis,m2m:status_to_emojis"` // Emojis corresponding to emojiIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
Local *bool `bun:",nullzero,notnull,default:false"` // is this status from a local account?
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // which account posted this status?
Account *gtsmodel.Account `bun:"rel:belongs-to"` // account corresponding to accountID
AccountURI string `bun:",nullzero,notnull"` // activitypub uri of the owner of this status
InReplyToID string `bun:"type:CHAR(26),nullzero"` // id of the status this status replies to
InReplyToURI string `bun:",nullzero"` // activitypub uri of the status this status is a reply to
InReplyToAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that this status replies to
InReplyTo *Status `bun:"-"` // status corresponding to inReplyToID
InReplyToAccount *gtsmodel.Account `bun:"rel:belongs-to"` // account corresponding to inReplyToAccountID
BoostOfID string `bun:"type:CHAR(26),nullzero"` // id of the status this status is a boost of
BoostOfURI string `bun:"-"` // URI of the status this status is a boost of; field not inserted in the db, just for dereferencing purposes.
BoostOfAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that owns the boosted status
BoostOf *Status `bun:"-"` // status that corresponds to boostOfID
BoostOfAccount *gtsmodel.Account `bun:"rel:belongs-to"` // account that corresponds to boostOfAccountID
ThreadID string `bun:"type:CHAR(26),nullzero"` // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null
PollID string `bun:"type:CHAR(26),nullzero"` //
Poll *gtsmodel.Poll `bun:"-"` //
ContentWarning string `bun:",nullzero"` // cw string for this status
Visibility Visibility `bun:",nullzero,notnull"` // visibility entry for this status
Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive?
Language string `bun:",nullzero"` // what language is this status written in?
CreatedWithApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application was used to create this status?
CreatedWithApplication *gtsmodel.Application `bun:"rel:belongs-to"` // application corresponding to createdWithApplicationID
ActivityStreamsType string `bun:",nullzero,notnull"` // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types. Will probably almost always be Note but who knows!.
Text string `bun:""` // Original text of the status without formatting
Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s)
InteractionPolicy *gtsmodel.InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers.
PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed.
PreApproved bool `bun:"-"` // If true, then status is a reply to or boost wrapper of a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB.
ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves the Announce or Create Activity that this status was/will be attached to.
}
// Visibility represents the visibility granularity of a status.
type Visibility string
const (
// VisibilityNone means nobody can see this.
// It's only used for web status visibility.
VisibilityNone Visibility = "none"
// VisibilityPublic means this status will be visible to everyone on all timelines.
VisibilityPublic Visibility = "public"
// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = "unlocked"
// VisibilityFollowersOnly means this status is viewable to followers only.
VisibilityFollowersOnly Visibility = "followers_only"
// VisibilityMutualsOnly means this status is visible to mutual followers only.
VisibilityMutualsOnly Visibility = "mutuals_only"
// VisibilityDirect means this status is visible only to mentioned recipients.
VisibilityDirect Visibility = "direct"
// VisibilityDefault is used when no other setting can be found.
VisibilityDefault Visibility = VisibilityUnlocked
)

View file

@ -0,0 +1,59 @@
// 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/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"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 {
// Check for 'updated_at' column on media attachments table, else return.
exists, err := doesColumnExist(ctx, tx, "media_attachments", "updated_at")
if err != nil {
return err
} else if !exists {
return nil
}
// Remove 'updated_at' column.
log.Info(ctx, "removing unused updated_at column from media attachments to save space, please wait...")
_, err = tx.NewDropColumn().
Model((*gtsmodel.MediaAttachment)(nil)).
Column("updated_at").
Exec(ctx)
return err
})
}
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,104 @@
// 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"
"fmt"
"reflect"
oldmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20250106114512_replace_statuses_updatedat_with_editedat"
newmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
var newStatus *newmodel.Status
newStatusType := reflect.TypeOf(newStatus)
// Generate new Status.EditedAt column definition from bun.
colDef, err := getBunColumnDef(tx, newStatusType, "EditedAt")
if err != nil {
return fmt.Errorf("error making column def: %w", err)
}
log.Info(ctx, "adding statuses.edited_at column...")
_, err = tx.NewAddColumn().Model(newStatus).
ColumnExpr(colDef).
Exec(ctx)
if err != nil {
return fmt.Errorf("error adding column: %w", err)
}
var whereSQL string
var whereArg []any
// Check for an empty length
// EditIDs JSON array, with different
// SQL depending on connected database.
switch tx.Dialect().Name() {
case dialect.SQLite:
whereSQL = "NOT (json_array_length(?) = 0 OR ? IS NULL)"
whereArg = []any{bun.Ident("edits"), bun.Ident("edits")}
case dialect.PG:
whereSQL = "NOT (CARDINALITY(?) = 0 OR ? IS NULL)"
whereArg = []any{bun.Ident("edits"), bun.Ident("edits")}
default:
panic("unsupported db type")
}
log.Info(ctx, "setting edited_at = updated_at where not empty(edits)...")
res, err := tx.NewUpdate().Model(newStatus).Where(whereSQL, whereArg...).
Set("? = ?",
bun.Ident("edited_at"),
bun.Ident("updated_at"),
).
Exec(ctx)
if err != nil {
return fmt.Errorf("error updating columns: %w", err)
}
count, _ := res.RowsAffected()
log.Infof(ctx, "updated %d statuses", count)
log.Info(ctx, "removing statuses.updated_at column...")
_, err = tx.NewDropColumn().Model((*oldmodel.Status)(nil)).
Column("updated_at").
Exec(ctx)
if err != nil {
return fmt.Errorf("error dropping column: %w", 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,99 @@
// 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
// A policy URI is GoToSocial's internal representation of
// one ActivityPub URI for an Actor or a Collection of Actors,
// specific to the domain of enforcing interaction policies.
//
// A PolicyValue can be stored in the database either as one
// of the Value constants defined below (to save space), OR as
// a full-fledged ActivityPub URI.
//
// A PolicyValue should be translated to the canonical string
// value of the represented URI when federating an item, or
// from the canonical string value of the URI when receiving
// or retrieving an item.
//
// For example, if the PolicyValue `followers` was being
// federated outwards in an interaction policy attached to an
// item created by the actor `https://example.org/users/someone`,
// then it should be translated to their followers URI when sent,
// eg., `https://example.org/users/someone/followers`.
//
// Likewise, if GoToSocial receives an item with an interaction
// policy containing `https://example.org/users/someone/followers`,
// and the item was created by `https://example.org/users/someone`,
// then the followers URI would be converted to `followers`
// for internal storage.
type PolicyValue string
const (
// Stand-in for ActivityPub magic public URI,
// which encompasses every possible Actor URI.
PolicyValuePublic PolicyValue = "public"
// Stand-in for the Followers Collection of
// the item owner's Actor.
PolicyValueFollowers PolicyValue = "followers"
// Stand-in for the Following Collection of
// the item owner's Actor.
PolicyValueFollowing PolicyValue = "following"
// Stand-in for the Mutuals Collection of
// the item owner's Actor.
//
// (TODO: Reserved, currently unused).
PolicyValueMutuals PolicyValue = "mutuals"
// Stand-in for Actor URIs tagged in the item.
PolicyValueMentioned PolicyValue = "mentioned"
// Stand-in for the Actor URI of the item owner.
PolicyValueAuthor PolicyValue = "author"
)
type PolicyValues []PolicyValue
// An InteractionPolicy determines which
// interactions will be accepted for an
// item, and according to what rules.
type InteractionPolicy struct {
// Conditions in which a Like
// interaction will be accepted
// for an item with this policy.
CanLike PolicyRules
// Conditions in which a Reply
// interaction will be accepted
// for an item with this policy.
CanReply PolicyRules
// Conditions in which an Announce
// interaction will be accepted
// for an item with this policy.
CanAnnounce PolicyRules
}
// PolicyRules represents the rules according
// to which a certain interaction is permitted
// to various Actor and Actor Collection URIs.
type PolicyRules struct {
// Always is for PolicyValues who are
// permitted to do an interaction
// without requiring approval.
Always PolicyValues
// WithApproval is for PolicyValues who
// are conditionally permitted to do
// an interaction, pending approval.
WithApproval PolicyValues
}

View file

@ -0,0 +1,114 @@
// 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"
)
// Status represents a user-created 'post' or
// 'status' in the database, either remote or local
type Status struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
FetchedAt time.Time `bun:"type:timestamptz,nullzero"` // when was item (remote) last fetched.
PinnedAt time.Time `bun:"type:timestamptz,nullzero"` // Status was pinned by owning account at this time.
URI string `bun:",unique,nullzero,notnull"` // activitypub URI of this status
URL string `bun:",nullzero"` // web url for viewing this status
Content string `bun:""` // content of this status; likely html-formatted but not guaranteed
AttachmentIDs []string `bun:"attachments,array"` // Database IDs of any media attachments associated with this status
TagIDs []string `bun:"tags,array"` // Database IDs of any tags used in this status
MentionIDs []string `bun:"mentions,array"` // Database IDs of any mentions in this status
EmojiIDs []string `bun:"emojis,array"` // Database IDs of any emojis used in this status
Local *bool `bun:",nullzero,notnull,default:false"` // is this status from a local account?
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // which account posted this status?
AccountURI string `bun:",nullzero,notnull"` // activitypub uri of the owner of this status
InReplyToID string `bun:"type:CHAR(26),nullzero"` // id of the status this status replies to
InReplyToURI string `bun:",nullzero"` // activitypub uri of the status this status is a reply to
InReplyToAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that this status replies to
InReplyTo *Status `bun:"-"` // status corresponding to inReplyToID
BoostOfID string `bun:"type:CHAR(26),nullzero"` // id of the status this status is a boost of
BoostOfURI string `bun:"-"` // URI of the status this status is a boost of; field not inserted in the db, just for dereferencing purposes.
BoostOfAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that owns the boosted status
BoostOf *Status `bun:"-"` // status that corresponds to boostOfID
ThreadID string `bun:"type:CHAR(26),nullzero"` // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null
EditIDs []string `bun:"edits,array"` //
PollID string `bun:"type:CHAR(26),nullzero"` //
ContentWarning string `bun:",nullzero"` // cw string for this status
Visibility Visibility `bun:",nullzero,notnull"` // visibility entry for this status
Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive?
Language string `bun:",nullzero"` // what language is this status written in?
CreatedWithApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application was used to create this status?
ActivityStreamsType string `bun:",nullzero,notnull"` // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types. Will probably almost always be Note but who knows!.
Text string `bun:""` // Original text of the status without formatting
Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s)
InteractionPolicy *InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers.
PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed.
PreApproved bool `bun:"-"` // If true, then status is a reply to or boost wrapper of a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB.
ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves the Announce or Create Activity that this status was/will be attached to.
}
// GetID implements timeline.Timelineable{}.
func (s *Status) GetID() string {
return s.ID
}
// IsLocal returns true if this is a local
// status (ie., originating from this instance).
func (s *Status) IsLocal() bool {
return s.Local != nil && *s.Local
}
// enumType is the type we (at least, should) use
// for database enum types. it is the largest size
// supported by a PostgreSQL SMALLINT, since an
// SQLite SMALLINT is actually variable in size.
type enumType int16
// Visibility represents the
// visibility granularity of a status.
type Visibility enumType
const (
// VisibilityNone means nobody can see this.
// It's only used for web status visibility.
VisibilityNone Visibility = 1
// VisibilityPublic means this status will
// be visible to everyone on all timelines.
VisibilityPublic Visibility = 2
// VisibilityUnlocked means this status will be visible to everyone,
// but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = 3
// VisibilityFollowersOnly means this status is viewable to followers only.
VisibilityFollowersOnly Visibility = 4
// VisibilityMutualsOnly means this status
// is visible to mutual followers only.
VisibilityMutualsOnly Visibility = 5
// VisibilityDirect means this status is
// visible only to mentioned recipients.
VisibilityDirect Visibility = 6
// VisibilityDefault is used when no other setting can be found.
VisibilityDefault Visibility = VisibilityUnlocked
)

View file

@ -0,0 +1,140 @@
// 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"
"fmt"
"slices"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"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 {
var edits []*gtsmodel.StatusEdit
// Select all status edits that
// are not actually connected to
// the status they reference.
if err := tx.NewSelect().
Model(&edits).
Join("JOIN ? AS ? ON ? = ?",
bun.Ident("statuses"),
bun.Ident("status"),
bun.Ident("status.id"),
bun.Ident("status_edit.status_id"),
).
Where("CAST(? AS TEXT) NOT LIKE CONCAT(?, ?, ?)",
bun.Ident("status.edits"),
"%", bun.Ident("status_edit.id"), "%",
).
Column("id", "status_id", "created_at").
Scan(ctx, &edits); err != nil {
return fmt.Errorf("error selecting unlinked edits: %w", err)
}
log.Infof(ctx, "relinking %d unlinked status edits", len(edits))
for _, edit := range edits {
var status gtsmodel.Status
// Select the list of edits
// CURRENTLY attached to the
// status that edit references.
if err := tx.NewSelect().
Model(&status).
Column("edits").
Where("? = ?",
bun.Ident("id"),
edit.StatusID,
).
Scan(ctx); err != nil {
return fmt.Errorf("error selecting status.edits: %w", err)
}
// Select only the ID and creation
// dates of all the other edits that
// are attached to referenced status.
if err := tx.NewSelect().
Model(&status.Edits).
Column("id", "created_at").
Where("? IN (?)",
bun.Ident("id"),
bun.In(status.EditIDs),
).
Scan(ctx); err != nil {
return fmt.Errorf("error selecting other status edits: %w", err)
}
editID := func(e *gtsmodel.StatusEdit) string { return e.ID }
// Append this unlinked edit to status' list
// of edits and then sort edits by creation.
//
// On tiny off-chance we end up with dupes,
// we still deduplicate these status edits.
status.Edits = append(status.Edits, edit)
status.Edits = xslices.DeduplicateFunc(status.Edits, editID)
slices.SortFunc(status.Edits, func(e1, e2 *gtsmodel.StatusEdit) int {
const k = -1 // oldest at 0th, newest at nth
switch c1, c2 := e1.CreatedAt, e2.CreatedAt; {
case c1.Before(c2):
return +k
case c2.Before(c1):
return -k
default:
return 0
}
})
// Extract the IDs from edits to update the status edit IDs.
status.EditIDs = xslices.Gather(nil, status.Edits, editID)
// Update the relevant status
// edit IDs column in database.
if _, err := tx.NewUpdate().
Model(&status).
Column("edits").
Where("? = ?",
bun.Ident("id"),
edit.StatusID,
).
Exec(ctx); err != nil {
return fmt.Errorf("error updating status.edits: %w", 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

@ -19,11 +19,209 @@ package migrations
import (
"context"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"codeberg.org/gruf/go-byteutil"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/dialect/feature"
"github.com/uptrace/bun/dialect/sqltype"
"github.com/uptrace/bun/schema"
)
// convertEnums performs a transaction that converts
// a table's column of our old-style enums (strings) to
// more performant and space-saving integer types.
func convertEnums[OldType ~string, NewType ~int16](
ctx context.Context,
tx bun.Tx,
table string,
column string,
mapping map[OldType]NewType,
defaultValue *NewType,
) error {
if len(mapping) == 0 {
return errors.New("empty mapping")
}
// Generate new column name.
newColumn := column + "_new"
log.Infof(ctx, "converting %s.%s enums; "+
"this may take a while, please don't interrupt!",
table, column,
)
// Ensure a default value.
if defaultValue == nil {
var zero NewType
defaultValue = &zero
}
// Add new column to database.
if _, err := tx.NewAddColumn().
Table(table).
ColumnExpr("? SMALLINT NOT NULL DEFAULT ?",
bun.Ident(newColumn),
*defaultValue).
Exec(ctx); err != nil {
return gtserror.Newf("error adding new column: %w", err)
}
// Get a count of all in table.
total, err := tx.NewSelect().
Table(table).
Count(ctx)
if err != nil {
return gtserror.Newf("error selecting total count: %w", err)
}
var updated int
for old, new := range mapping {
// Update old to new values.
res, err := tx.NewUpdate().
Table(table).
Where("? = ?", bun.Ident(column), old).
Set("? = ?", bun.Ident(newColumn), new).
Exec(ctx)
if err != nil {
return gtserror.Newf("error updating old column values: %w", err)
}
// Count number items updated.
n, _ := res.RowsAffected()
updated += int(n)
}
// Check total updated.
if total != updated {
log.Warnf(ctx, "total=%d does not match updated=%d", total, updated)
}
// Drop the old column from table.
if _, err := tx.NewDropColumn().
Table(table).
ColumnExpr("?", bun.Ident(column)).
Exec(ctx); err != nil {
return gtserror.Newf("error dropping old column: %w", err)
}
// Rename new to old name.
if _, err := tx.NewRaw(
"ALTER TABLE ? RENAME COLUMN ? TO ?",
bun.Ident(table),
bun.Ident(newColumn),
bun.Ident(column),
).Exec(ctx); err != nil {
return gtserror.Newf("error renaming new column: %w", err)
}
return nil
}
// getBunColumnDef generates a column definition string for the SQL table represented by
// Go type, with the SQL column represented by the given Go field name. This ensures when
// adding a new column for table by migration that it will end up as bun would create it.
//
// NOTE: this function must stay in sync with (*bun.CreateTableQuery{}).AppendQuery(),
// specifically where it loops over table fields appending each column definition.
func getBunColumnDef(db bun.IDB, rtype reflect.Type, fieldName string) (string, error) {
d := db.Dialect()
f := d.Features()
// Get bun schema definitions for Go type and its field.
field, table, err := getModelField(db, rtype, fieldName)
if err != nil {
return "", err
}
// Start with reasonable buf.
buf := make([]byte, 0, 64)
// Start with the SQL column name.
buf = append(buf, field.SQLName...)
buf = append(buf, " "...)
// Append the SQL
// type information.
switch {
// Most of the time these two will match, but for the cases where DiscoveredSQLType is dialect-specific,
// e.g. pgdialect would change sqltype.SmallInt to pgTypeSmallSerial for columns that have `bun:",autoincrement"`
case !strings.EqualFold(field.CreateTableSQLType, field.DiscoveredSQLType):
buf = append(buf, field.CreateTableSQLType...)
// For all common SQL types except VARCHAR, both UserDefinedSQLType and DiscoveredSQLType specify the correct type,
// and we needn't modify it. For VARCHAR columns, we will stop to check if a valid length has been set in .Varchar(int).
case !strings.EqualFold(field.CreateTableSQLType, sqltype.VarChar):
buf = append(buf, field.CreateTableSQLType...)
// All else falls back
// to a default varchar.
default:
if d.Name() == dialect.Oracle {
buf = append(buf, "VARCHAR2"...)
} else {
buf = append(buf, sqltype.VarChar...)
}
buf = append(buf, "("...)
buf = strconv.AppendInt(buf, int64(d.DefaultVarcharLen()), 10)
buf = append(buf, ")"...)
}
// Append not null definition if field requires.
if field.NotNull && d.Name() != dialect.Oracle {
buf = append(buf, " NOT NULL"...)
}
// Append autoincrement definition if field requires.
if field.Identity && f.Has(feature.GeneratedIdentity) ||
(field.AutoIncrement && (f.Has(feature.AutoIncrement) || f.Has(feature.Identity))) {
buf = d.AppendSequence(buf, table, field)
}
// Append any default value.
if field.SQLDefault != "" {
buf = append(buf, " DEFAULT "...)
buf = append(buf, field.SQLDefault...)
}
return byteutil.B2S(buf), nil
}
// getModelField returns the uptrace/bun schema details for given Go type and field name.
func getModelField(db bun.IDB, rtype reflect.Type, fieldName string) (*schema.Field, *schema.Table, error) {
// Get the associated table for Go type.
table := db.Dialect().Tables().Get(rtype)
if table == nil {
return nil, nil, fmt.Errorf("no table found for type: %s", rtype)
}
var field *schema.Field
// Look for field matching Go name.
for i := range table.Fields {
if table.Fields[i].GoName == fieldName {
field = table.Fields[i]
break
}
}
if field == nil {
return nil, nil, fmt.Errorf("no bun field found on %s with name: %s", rtype, fieldName)
}
return field, table, nil
}
// doesColumnExist safely checks whether given column exists on table, handling both SQLite and PostgreSQL appropriately.
func doesColumnExist(ctx context.Context, tx bun.Tx, table, col string) (bool, error) {
var n int

View file

@ -26,10 +26,10 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -130,7 +130,7 @@ func (n *notificationDB) GetNotificationsByIDs(ctx context.Context, ids []string
// Reorder the notifs by their
// IDs to ensure in correct order.
getID := func(n *gtsmodel.Notification) string { return n.ID }
util.OrderBy(notifs, ids, getID)
xslices.OrderBy(notifs, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
@ -192,22 +192,19 @@ func (n *notificationDB) PopulateNotification(ctx context.Context, notif *gtsmod
func (n *notificationDB) GetAccountNotifications(
ctx context.Context,
accountID string,
maxID string,
sinceID string,
minID string,
limit int,
types []string,
excludeTypes []string,
page *paging.Page,
types []gtsmodel.NotificationType,
excludeTypes []gtsmodel.NotificationType,
) ([]*gtsmodel.Notification, error) {
// Ensure reasonable
if limit < 0 {
limit = 0
}
// Make educated guess for slice size
var (
notifIDs = make([]string, 0, limit)
frontToBack = true
// Get paging params.
minID = page.GetMin()
maxID = page.GetMax()
limit = page.GetLimit()
order = page.GetOrder()
// Make educated guess for slice size
notifIDs = make([]string, 0, limit)
)
q := n.db.
@ -215,23 +212,14 @@ func (n *notificationDB) GetAccountNotifications(
TableExpr("? AS ?", bun.Ident("notifications"), bun.Ident("notification")).
Column("notification.id")
if maxID == "" {
maxID = id.Highest
}
// Return only notifs LOWER (ie., older) than maxID.
q = q.Where("? < ?", bun.Ident("notification.id"), maxID)
if sinceID != "" {
// Return only notifs HIGHER (ie., newer) than sinceID.
q = q.Where("? > ?", bun.Ident("notification.id"), sinceID)
if maxID != "" {
// Return only notifs LOWER (ie., older) than maxID.
q = q.Where("? < ?", bun.Ident("notification.id"), maxID)
}
if minID != "" {
// Return only notifs HIGHER (ie., newer) than minID.
q = q.Where("? > ?", bun.Ident("notification.id"), minID)
frontToBack = false // page up
}
if len(types) > 0 {
@ -251,12 +239,12 @@ func (n *notificationDB) GetAccountNotifications(
q = q.Limit(limit)
}
if frontToBack {
// Page down.
q = q.Order("notification.id DESC")
} else {
if order == paging.OrderAscending {
// Page up.
q = q.Order("notification.id ASC")
} else {
// Page down.
q = q.Order("notification.id DESC")
}
if err := q.Scan(ctx, &notifIDs); err != nil {
@ -269,11 +257,8 @@ func (n *notificationDB) GetAccountNotifications(
// If we're paging up, we still want notifications
// to be sorted by ID desc, so reverse ids slice.
// https://zchee.github.io/golang-wiki/SliceTricks/#reversing
if !frontToBack {
for l, r := 0, len(notifIDs)-1; l < r; l, r = l+1, r-1 {
notifIDs[l], notifIDs[r] = notifIDs[r], notifIDs[l]
}
if order == paging.OrderAscending {
slices.Reverse(notifIDs)
}
// Fetch notification models by their IDs.
@ -303,7 +288,7 @@ func (n *notificationDB) DeleteNotificationByID(ctx context.Context, id string)
return nil
}
func (n *notificationDB) DeleteNotifications(ctx context.Context, types []string, targetAccountID string, originAccountID string) error {
func (n *notificationDB) DeleteNotifications(ctx context.Context, types []gtsmodel.NotificationType, targetAccountID string, originAccountID string) error {
if targetAccountID == "" && originAccountID == "" {
return gtserror.New("one of targetAccountID or originAccountID must be set")
}

View file

@ -28,6 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -92,10 +93,11 @@ func (suite *NotificationTestSuite) TestGetAccountNotificationsWithSpam() {
notifications, err := suite.db.GetAccountNotifications(
gtscontext.SetBarebones(context.Background()),
testAccount.ID,
id.Highest,
id.Lowest,
"",
20,
&paging.Page{
Min: paging.EitherMinID("", id.Lowest),
Max: paging.MaxID(id.Highest),
Limit: 20,
},
nil,
nil,
)
@ -115,10 +117,11 @@ func (suite *NotificationTestSuite) TestGetAccountNotificationsWithoutSpam() {
notifications, err := suite.db.GetAccountNotifications(
gtscontext.SetBarebones(context.Background()),
testAccount.ID,
id.Highest,
id.Lowest,
"",
20,
&paging.Page{
Min: paging.EitherMinID("", id.Lowest),
Max: paging.MaxID(id.Highest),
Limit: 20,
},
nil,
nil,
)
@ -140,10 +143,11 @@ func (suite *NotificationTestSuite) TestDeleteNotificationsWithSpam() {
notifications, err := suite.db.GetAccountNotifications(
gtscontext.SetBarebones(context.Background()),
testAccount.ID,
id.Highest,
id.Lowest,
"",
20,
&paging.Page{
Min: paging.EitherMinID("", id.Lowest),
Max: paging.MaxID(id.Highest),
Limit: 20,
},
nil,
nil,
)
@ -161,10 +165,11 @@ func (suite *NotificationTestSuite) TestDeleteNotificationsWithSpam() {
notifications, err = suite.db.GetAccountNotifications(
gtscontext.SetBarebones(context.Background()),
testAccount.ID,
id.Highest,
id.Lowest,
"",
20,
&paging.Page{
Min: paging.EitherMinID("", id.Lowest),
Max: paging.MaxID(id.Highest),
Limit: 20,
},
nil,
nil,
)
@ -183,10 +188,11 @@ func (suite *NotificationTestSuite) TestDeleteNotificationsWithTwoAccounts() {
notifications, err := suite.db.GetAccountNotifications(
gtscontext.SetBarebones(context.Background()),
testAccount.ID,
id.Highest,
id.Lowest,
"",
20,
&paging.Page{
Min: paging.EitherMinID("", id.Lowest),
Max: paging.MaxID(id.Highest),
Limit: 20,
},
nil,
nil,
)

View file

@ -21,7 +21,6 @@ import (
"context"
"errors"
"slices"
"time"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
@ -29,7 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -88,12 +87,15 @@ func (p *pollDB) getPoll(ctx context.Context, lookup string, dbQuery func(*gtsmo
func (p *pollDB) GetOpenPolls(ctx context.Context) ([]*gtsmodel.Poll, error) {
var pollIDs []string
// Select all polls with unset `closed_at` time.
// Select all polls with:
// - UNSET `closed_at`
// - SET `expires_at`
if err := p.db.NewSelect().
Table("polls").
Column("polls.id").
Join("JOIN ? ON ? = ?", bun.Ident("statuses"), bun.Ident("polls.id"), bun.Ident("statuses.poll_id")).
Where("? = true", bun.Ident("statuses.local")).
Where("? IS NOT NULL", bun.Ident("polls.expires_at")).
Where("? IS NULL", bun.Ident("polls.closed_at")).
Scan(ctx, &pollIDs); err != nil {
return nil, err
@ -155,17 +157,6 @@ func (p *pollDB) UpdatePoll(ctx context.Context, poll *gtsmodel.Poll, cols ...st
return p.state.Caches.DB.Poll.Store(poll, func() error {
return p.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Update the status' "updated_at" field.
if _, err := tx.NewUpdate().
Table("statuses").
Where("? = ?", bun.Ident("id"), poll.StatusID).
SetColumn("updated_at", "?", time.Now()).
Exec(ctx); err != nil {
return err
}
// Finally, update poll
// columns in database.
_, err := tx.NewUpdate().
Model(poll).
Column(cols...).
@ -315,7 +306,7 @@ func (p *pollDB) GetPollVotes(ctx context.Context, pollID string) ([]*gtsmodel.P
// Reorder the poll votes by their
// IDs to ensure in correct order.
getID := func(v *gtsmodel.PollVote) string { return v.ID }
util.OrderBy(votes, voteIDs, getID)
xslices.OrderBy(votes, voteIDs, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.

View file

@ -27,7 +27,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -127,7 +127,7 @@ func (r *relationshipDB) GetBlocksByIDs(ctx context.Context, ids []string) ([]*g
// Reorder the blocks by their
// IDs to ensure in correct order.
getID := func(b *gtsmodel.Block) string { return b.ID }
util.OrderBy(blocks, ids, getID)
xslices.OrderBy(blocks, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.

View file

@ -28,7 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -103,7 +103,7 @@ func (r *relationshipDB) GetFollowsByIDs(ctx context.Context, ids []string) ([]*
// Reorder the follows by their
// IDs to ensure in correct order.
getID := func(f *gtsmodel.Follow) string { return f.ID }
util.OrderBy(follows, ids, getID)
xslices.OrderBy(follows, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
@ -376,7 +376,7 @@ func (r *relationshipDB) DeleteAccountFollows(ctx context.Context, accountID str
}
// Gather the follow IDs that were deleted for removing related list entries.
followIDs := util.Gather(nil, deleted, func(follow *gtsmodel.Follow) string {
followIDs := xslices.Gather(nil, deleted, func(follow *gtsmodel.Follow) string {
return follow.ID
})

View file

@ -28,7 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -103,7 +103,7 @@ func (r *relationshipDB) GetFollowRequestsByIDs(ctx context.Context, ids []strin
// Reorder the requests by their
// IDs to ensure in correct order.
getID := func(f *gtsmodel.FollowRequest) string { return f.ID }
util.OrderBy(follows, ids, getID)
xslices.OrderBy(follows, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
@ -265,8 +265,8 @@ func (r *relationshipDB) AcceptFollowRequest(ctx context.Context, sourceAccountI
}
// Delete original follow request notification
if err := r.state.DB.DeleteNotifications(ctx, []string{
string(gtsmodel.NotificationFollowRequest),
if err := r.state.DB.DeleteNotifications(ctx, []gtsmodel.NotificationType{
gtsmodel.NotificationFollowRequest,
}, targetAccountID, sourceAccountID); err != nil {
return nil, err
}
@ -281,8 +281,8 @@ func (r *relationshipDB) RejectFollowRequest(ctx context.Context, sourceAccountI
}
// Delete follow request notification
return r.state.DB.DeleteNotifications(ctx, []string{
string(gtsmodel.NotificationFollowRequest),
return r.state.DB.DeleteNotifications(ctx, []gtsmodel.NotificationType{
gtsmodel.NotificationFollowRequest,
}, targetAccountID, sourceAccountID)
}

View file

@ -28,7 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
@ -109,7 +109,7 @@ func (r *relationshipDB) getMutesByIDs(ctx context.Context, ids []string) ([]*gt
// Reorder the mutes by their
// IDs to ensure in correct order.
getID := func(b *gtsmodel.UserMute) string { return b.ID }
util.OrderBy(mutes, ids, getID)
xslices.OrderBy(mutes, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.

View file

@ -21,7 +21,6 @@ import (
"context"
"errors"
"slices"
"time"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
@ -29,7 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -76,7 +75,7 @@ func (s *statusDB) GetStatusesByIDs(ctx context.Context, ids []string) ([]*gtsmo
// Reorder the statuses by their
// IDs to ensure in correct order.
getID := func(s *gtsmodel.Status) string { return s.ID }
util.OrderBy(statuses, ids, getID)
xslices.OrderBy(statuses, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
@ -181,7 +180,7 @@ func (s *statusDB) getStatus(ctx context.Context, lookup string, dbQuery func(*g
func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status) error {
var (
err error
errs = gtserror.NewMultiError(9)
errs gtserror.MultiError
)
if status.Account == nil {
@ -257,7 +256,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if !status.AttachmentsPopulated() {
// Status attachments are out-of-date with IDs, repopulate.
status.Attachments, err = s.state.DB.GetAttachmentsByIDs(
ctx, // these are already barebones
gtscontext.SetBarebones(ctx),
status.AttachmentIDs,
)
if err != nil {
@ -268,7 +267,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if !status.TagsPopulated() {
// Status tags are out-of-date with IDs, repopulate.
status.Tags, err = s.state.DB.GetTags(
ctx,
gtscontext.SetBarebones(ctx),
status.TagIDs,
)
if err != nil {
@ -279,7 +278,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if !status.MentionsPopulated() {
// Status mentions are out-of-date with IDs, repopulate.
status.Mentions, err = s.state.DB.GetMentions(
ctx, // leave fully populated for now
ctx, // TODO: manually populate mentions for places expecting these populated
status.MentionIDs,
)
if err != nil {
@ -290,7 +289,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if !status.EmojisPopulated() {
// Status emojis are out-of-date with IDs, repopulate.
status.Emojis, err = s.state.DB.GetEmojisByIDs(
ctx, // these are already barebones
gtscontext.SetBarebones(ctx),
status.EmojiIDs,
)
if err != nil {
@ -301,7 +300,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if status.CreatedWithApplicationID != "" && status.CreatedWithApplication == nil {
// Populate the status' expected CreatedWithApplication (not always set).
status.CreatedWithApplication, err = s.state.DB.GetApplicationByID(
ctx, // these are already barebones
gtscontext.SetBarebones(ctx),
status.CreatedWithApplicationID,
)
if err != nil {
@ -312,6 +311,23 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
return errs.Combine()
}
func (s *statusDB) PopulateStatusEdits(ctx context.Context, status *gtsmodel.Status) error {
var err error
if !status.EditsPopulated() {
// Status edits are out-of-date with IDs, repopulate.
status.Edits, err = s.state.DB.GetStatusEditsByIDs(
gtscontext.SetBarebones(ctx),
status.EditIDs,
)
if err != nil {
return gtserror.Newf("error populating status edits: %w", err)
}
}
return nil
}
func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error {
return s.state.Caches.DB.Status.Store(status, func() error {
// It is safe to run this database transaction within cache.Store
@ -350,14 +366,14 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error
}
}
// change the status ID of the media attachments to the new status
// change the status ID of the media
// attachments to the current status
for _, a := range status.Attachments {
a.StatusID = status.ID
a.UpdatedAt = time.Now()
if _, err := tx.
NewUpdate().
Model(a).
Column("status_id", "updated_at").
Column("status_id").
Where("? = ?", bun.Ident("media_attachment.id"), a.ID).
Exec(ctx); err != nil {
if !errors.Is(err, db.ErrAlreadyExists) {
@ -384,19 +400,15 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error
}
// Finally, insert the status
_, err := tx.NewInsert().Model(status).Exec(ctx)
_, err := tx.NewInsert().
Model(status).
Exec(ctx)
return err
})
})
}
func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, columns ...string) error {
status.UpdatedAt = time.Now()
if len(columns) > 0 {
// If we're updating by column, ensure "updated_at" is included.
columns = append(columns, "updated_at")
}
return s.state.Caches.DB.Status.Store(status, func() error {
// It is safe to run this database transaction within cache.Store
// as the cache does not attempt a mutex lock until AFTER hook.
@ -434,13 +446,14 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, co
}
}
// change the status ID of the media attachments to the new status
// change the status ID of the media
// attachments to the current status.
for _, a := range status.Attachments {
a.StatusID = status.ID
a.UpdatedAt = time.Now()
if _, err := tx.
NewUpdate().
Model(a).
Column("status_id").
Where("? = ?", bun.Ident("media_attachment.id"), a.ID).
Exec(ctx); err != nil {
if !errors.Is(err, db.ErrAlreadyExists) {
@ -467,8 +480,7 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, co
}
// Finally, update the status
_, err := tx.
NewUpdate().
_, err := tx.NewUpdate().
Model(status).
Column(columns...).
Where("? = ?", bun.Ident("status.id"), status.ID).

View file

@ -28,7 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -95,7 +95,7 @@ func (s *statusBookmarkDB) GetStatusBookmarksByIDs(ctx context.Context, ids []st
// Reorder the bookmarks by their
// IDs to ensure in correct order.
getID := func(b *gtsmodel.StatusBookmark) string { return b.ID }
util.OrderBy(bookmarks, ids, getID)
xslices.OrderBy(bookmarks, ids, getID)
// Populate all loaded bookmarks, removing those we fail
// to populate (removes needing so many later nil checks).

View file

@ -0,0 +1,198 @@
// 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"
"errors"
"slices"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
type statusEditDB struct {
db *bun.DB
state *state.State
}
func (s *statusEditDB) GetStatusEditByID(ctx context.Context, id string) (*gtsmodel.StatusEdit, error) {
// Fetch edit from database cache with loader callback.
edit, err := s.state.Caches.DB.StatusEdit.LoadOne("ID",
func() (*gtsmodel.StatusEdit, error) {
var edit gtsmodel.StatusEdit
// Not cached, load edit
// from database by its ID.
if err := s.db.NewSelect().
Model(&edit).
Where("? = ?", bun.Ident("id"), id).
Scan(ctx); err != nil {
return nil, err
}
return &edit, nil
}, id,
)
if err != nil {
return nil, err
}
if gtscontext.Barebones(ctx) {
// no need to fully populate.
return edit, nil
}
// Further populate the edit fields where applicable.
if err := s.PopulateStatusEdit(ctx, edit); err != nil {
return nil, err
}
return edit, nil
}
func (s *statusEditDB) GetStatusEditsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.StatusEdit, error) {
// Load status edits for IDs via cache loader callbacks.
edits, err := s.state.Caches.DB.StatusEdit.LoadIDs("ID",
ids,
func(uncached []string) ([]*gtsmodel.StatusEdit, error) {
// Preallocate expected length of uncached edits.
edits := make([]*gtsmodel.StatusEdit, 0, len(uncached))
// Perform database query scanning
// the remaining (uncached) edit IDs.
if err := s.db.NewSelect().
Model(&edits).
Where("? IN (?)", bun.Ident("id"), bun.In(uncached)).
Scan(ctx); err != nil {
return nil, err
}
return edits, nil
},
)
if err != nil {
return nil, err
}
// Reorder the edits by their
// IDs to ensure in correct order.
getID := func(e *gtsmodel.StatusEdit) string { return e.ID }
xslices.OrderBy(edits, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
return edits, nil
}
// Populate all loaded edits, removing those we fail to
// populate (removes needing so many nil checks everywhere).
edits = slices.DeleteFunc(edits, func(edit *gtsmodel.StatusEdit) bool {
if err := s.PopulateStatusEdit(ctx, edit); err != nil {
log.Errorf(ctx, "error populating edit %s: %v", edit.ID, err)
return true
}
return false
})
return edits, nil
}
func (s *statusEditDB) PopulateStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error {
var err error
var errs gtserror.MultiError
// For sub-models we only want
// barebones versions of them.
ctx = gtscontext.SetBarebones(ctx)
if !edit.AttachmentsPopulated() {
// Fetch all attachments for status edit's IDs.
edit.Attachments, err = s.state.DB.GetAttachmentsByIDs(
ctx,
edit.AttachmentIDs,
)
if err != nil {
errs.Appendf("error populating edit attachments: %w", err)
}
}
return errs.Combine()
}
func (s *statusEditDB) PutStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error {
return s.state.Caches.DB.StatusEdit.Store(edit, func() error {
_, err := s.db.NewInsert().Model(edit).Exec(ctx)
return err
})
}
func (s *statusEditDB) DeleteStatusEdits(ctx context.Context, ids []string) error {
// Gather necessary fields from
// deleted for cache invalidation.
deleted := make([]*gtsmodel.StatusEdit, 0, len(ids))
// Delete all edits with IDs pertaining
// to given slice, returning status IDs.
if _, err := s.db.NewDelete().
Model(&deleted).
Where("? IN (?)", bun.Ident("id"), bun.In(ids)).
Returning("?", bun.Ident("status_id")).
Exec(ctx); err != nil &&
!errors.Is(err, db.ErrNoEntries) {
return err
}
// Check for no deletes.
if len(deleted) == 0 {
return nil
}
// Invalidate all the cached status edits with IDs.
s.state.Caches.DB.StatusEdit.InvalidateIDs("ID", ids)
// With each invalidate hook mark status ID of
// edit we just called for. We only want to call
// invalidate hooks of edits from unique statuses.
invalidated := make(map[string]struct{}, 1)
// Invalidate the first delete manually, this
// opt negates need for initial hashmap lookup.
s.state.Caches.OnInvalidateStatusEdit(deleted[0])
invalidated[deleted[0].StatusID] = struct{}{}
for _, edit := range deleted {
// Check not already called for status.
_, ok := invalidated[edit.StatusID]
if ok {
continue
}
// Manually call status edit invalidate hook.
s.state.Caches.OnInvalidateStatusEdit(edit)
invalidated[edit.StatusID] = struct{}{}
}
return nil
}

View file

@ -0,0 +1,168 @@
// 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_test
import (
"context"
"errors"
"reflect"
"slices"
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type StatusEditTestSuite struct {
BunDBStandardTestSuite
}
func (suite *StatusEditTestSuite) TestGetStatusEditBy() {
t := suite.T()
// Create a new context for this test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// Sentinel error to mark avoiding a test case.
sentinelErr := errors.New("sentinel")
for _, edit := range suite.testStatusEdits {
for lookup, dbfunc := range map[string]func() (*gtsmodel.StatusEdit, error){
"id": func() (*gtsmodel.StatusEdit, error) {
return suite.db.GetStatusEditByID(ctx, edit.ID)
},
} {
// Clear database caches.
suite.state.Caches.Init()
t.Logf("checking database lookup %q", lookup)
// Perform database function.
checkEdit, err := dbfunc()
if err != nil {
if err == sentinelErr {
continue
}
t.Errorf("error encountered for database lookup %q: %v", lookup, err)
continue
}
// Check received account data.
if !areEditsEqual(edit, checkEdit) {
t.Errorf("edit does not contain expected data: %+v", checkEdit)
continue
}
}
}
}
func (suite *StatusEditTestSuite) TestGetStatusEditsByIDs() {
t := suite.T()
// Create a new context for this test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// editsByStatus returns all test edits by the given status with ID.
editsByStatus := func(status *gtsmodel.Status) []*gtsmodel.StatusEdit {
var edits []*gtsmodel.StatusEdit
for _, edit := range suite.testStatusEdits {
if edit.StatusID == status.ID {
edits = append(edits, edit)
}
}
return edits
}
for _, status := range suite.testStatuses {
// Get test status edit models
// that should be found for status.
check := editsByStatus(status)
// Fetch edits for the slice of IDs attached to status from database.
edits, err := suite.state.DB.GetStatusEditsByIDs(ctx, status.EditIDs)
suite.NoError(err)
// Ensure both slices
// sorted the same.
sortEdits(check)
sortEdits(edits)
// Check whether slices of status edits match.
if !slices.EqualFunc(check, edits, areEditsEqual) {
t.Error("status edit slices do not match")
}
}
}
func (suite *StatusEditTestSuite) TestDeleteStatusEdits() {
// Create a new context for this test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
for _, status := range suite.testStatuses {
// Delete all edits for status with given IDs from database.
err := suite.state.DB.DeleteStatusEdits(ctx, status.EditIDs)
suite.NoError(err)
// Now attempt to fetch these edits from database, should be empty.
edits, err := suite.state.DB.GetStatusEditsByIDs(ctx, status.EditIDs)
suite.NoError(err)
suite.Empty(edits)
}
}
func TestStatusEditTestSuite(t *testing.T) {
suite.Run(t, new(StatusEditTestSuite))
}
func areEditsEqual(e1, e2 *gtsmodel.StatusEdit) bool {
// Clone the 1st status edit.
e1Copy := new(gtsmodel.StatusEdit)
*e1Copy = *e1
e1 = e1Copy
// Clone the 2nd status edit.
e2Copy := new(gtsmodel.StatusEdit)
*e2Copy = *e2
e2 = e2Copy
// Clear populated sub-models.
e1.Attachments = nil
e2.Attachments = nil
// Clear database-set fields.
e1.CreatedAt = time.Time{}
e2.CreatedAt = time.Time{}
return reflect.DeepEqual(*e1, *e2)
}
func sortEdits(edits []*gtsmodel.StatusEdit) {
slices.SortFunc(edits, func(a, b *gtsmodel.StatusEdit) int {
if a.CreatedAt.Before(b.CreatedAt) {
return +1
} else if b.CreatedAt.Before(a.CreatedAt) {
return -1
}
return 0
})
}

View file

@ -31,7 +31,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -155,7 +155,7 @@ func (s *statusFaveDB) GetStatusFaves(ctx context.Context, statusID string) ([]*
// Reorder the statuses by their
// IDs to ensure in correct order.
getID := func(f *gtsmodel.StatusFave) string { return f.ID }
util.OrderBy(faves, faveIDs, getID)
xslices.OrderBy(faves, faveIDs, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
@ -339,7 +339,7 @@ func (s *statusFaveDB) DeleteStatusFaves(ctx context.Context, targetAccountID st
}
// Deduplicate determined status IDs.
statusIDs = util.Deduplicate(statusIDs)
statusIDs = xslices.Deduplicate(statusIDs)
// Invalidate any cached status faves for this status ID.
s.state.Caches.DB.StatusFave.InvalidateIDs("ID", statusIDs)

View file

@ -28,7 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
@ -102,7 +102,7 @@ func (t *tagDB) GetTags(ctx context.Context, ids []string) ([]*gtsmodel.Tag, err
// Reorder the tags by their
// IDs to ensure in correct order.
getID := func(t *gtsmodel.Tag) string { return t.ID }
util.OrderBy(tags, ids, getID)
xslices.OrderBy(tags, ids, getID)
return tags, nil
}
@ -301,5 +301,5 @@ func (t *tagDB) GetAccountIDsFollowingTagIDs(ctx context.Context, tagIDs []strin
// Accounts might be following multiple tags in list,
// but we only want to return each account once.
return util.Deduplicate(accountIDs), nil
return xslices.Deduplicate(accountIDs), nil
}

View file

@ -123,13 +123,8 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI
if maxID == "" || maxID >= id.Highest {
const future = 24 * time.Hour
var err error
// don't return statuses more than 24hr in the future
maxID, err = id.NewULIDFromTime(time.Now().Add(future))
if err != nil {
return nil, err
}
maxID = id.NewULIDFromTime(time.Now().Add(future))
}
// return only statuses LOWER (ie., older) than maxID
@ -223,13 +218,8 @@ func (t *timelineDB) GetPublicTimeline(ctx context.Context, maxID string, sinceI
if maxID == "" || maxID >= id.Highest {
const future = 24 * time.Hour
var err error
// don't return statuses more than 24hr in the future
maxID, err = id.NewULIDFromTime(time.Now().Add(future))
if err != nil {
return nil, err
}
maxID = id.NewULIDFromTime(time.Now().Add(future))
}
// return only statuses LOWER (ie., older) than maxID
@ -409,13 +399,8 @@ func (t *timelineDB) GetListTimeline(
if maxID == "" || maxID >= id.Highest {
const future = 24 * time.Hour
var err error
// don't return statuses more than 24hr in the future
maxID, err = id.NewULIDFromTime(time.Now().Add(future))
if err != nil {
return nil, err
}
maxID = id.NewULIDFromTime(time.Now().Add(future))
}
// return only statuses LOWER (ie., older) than maxID
@ -508,13 +493,8 @@ func (t *timelineDB) GetTagTimeline(
if maxID == "" || maxID >= id.Highest {
const future = 24 * time.Hour
var err error
// don't return statuses more than 24hr in the future
maxID, err = id.NewULIDFromTime(time.Now().Add(future))
if err != nil {
return nil, err
}
maxID = id.NewULIDFromTime(time.Now().Add(future))
}
// return only statuses LOWER (ie., older) than maxID

View file

@ -37,10 +37,7 @@ type TimelineTestSuite struct {
func getFutureStatus() *gtsmodel.Status {
theDistantFuture := time.Now().Add(876600 * time.Hour)
id, err := id.NewULIDFromTime(theDistantFuture)
if err != nil {
panic(err)
}
id := id.NewULIDFromTime(theDistantFuture)
return &gtsmodel.Status{
ID: id,
@ -53,7 +50,6 @@ func getFutureStatus() *gtsmodel.Status {
MentionIDs: []string{},
EmojiIDs: []string{},
CreatedAt: theDistantFuture,
UpdatedAt: theDistantFuture,
Local: util.Ptr(true),
AccountURI: "http://localhost:8080/users/admin",
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
@ -74,7 +70,8 @@ func (suite *TimelineTestSuite) publicCount() int {
var publicCount int
for _, status := range suite.testStatuses {
if status.Visibility == gtsmodel.VisibilityPublic &&
status.BoostOfID == "" {
status.BoostOfID == "" &&
!util.PtrOrZero(status.PendingApproval) {
publicCount++
}
}
@ -181,7 +178,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineIgnoreExclusive() {
if err != nil {
suite.FailNow(err.Error())
}
suite.checkStatuses(s, id.Highest, id.Lowest, 8)
suite.checkStatuses(s, id.Highest, id.Lowest, 9)
// Remove admin account from the exclusive list.
listEntry := suite.testListEntries["local_account_1_list_1_entry_2"]
@ -195,7 +192,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineIgnoreExclusive() {
if err != nil {
suite.FailNow(err.Error())
}
suite.checkStatuses(s, id.Highest, id.Lowest, 12)
suite.checkStatuses(s, id.Highest, id.Lowest, 13)
}
func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() {
@ -227,7 +224,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() {
suite.FailNow(err.Error())
}
suite.checkStatuses(s, id.Highest, id.Lowest, 8)
suite.checkStatuses(s, id.Highest, id.Lowest, 9)
}
func (suite *TimelineTestSuite) TestGetHomeTimelineWithFutureStatus() {
@ -280,8 +277,8 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineFromHighest() {
}
suite.checkStatuses(s, id.Highest, id.Lowest, 5)
suite.Equal("01J2M1HPFSS54S60Y0KYV23KJE", s[0].ID)
suite.Equal("01G36SF3V6Y6V5BF9P4R7PQG7G", s[len(s)-1].ID)
suite.Equal("01JDPZEZ77X1NX0TY9M10BK1HM", s[0].ID)
suite.Equal("01HEN2RZ8BG29Y5Z9VJC73HZW7", s[len(s)-1].ID)
}
func (suite *TimelineTestSuite) TestGetListTimelineNoParams() {
@ -295,7 +292,7 @@ func (suite *TimelineTestSuite) TestGetListTimelineNoParams() {
suite.FailNow(err.Error())
}
suite.checkStatuses(s, id.Highest, id.Lowest, 12)
suite.checkStatuses(s, id.Highest, id.Lowest, 13)
}
func (suite *TimelineTestSuite) TestGetListTimelineMaxID() {
@ -310,8 +307,8 @@ func (suite *TimelineTestSuite) TestGetListTimelineMaxID() {
}
suite.checkStatuses(s, id.Highest, id.Lowest, 5)
suite.Equal("01HEN2PRXT0TF4YDRA64FZZRN7", s[0].ID)
suite.Equal("01FF25D5Q0DH7CHD57CTRS6WK0", s[len(s)-1].ID)
suite.Equal("01JDPZEZ77X1NX0TY9M10BK1HM", s[0].ID)
suite.Equal("01FN3VJGFH10KR7S2PB0GFJZYG", s[len(s)-1].ID)
}
func (suite *TimelineTestSuite) TestGetListTimelineMinID() {