diff --git a/cmd/gotosocial/action/admin/media/prune/all.go b/cmd/gotosocial/action/admin/media/prune/all.go index 7642fe928..90c08c7db 100644 --- a/cmd/gotosocial/action/admin/media/prune/all.go +++ b/cmd/gotosocial/action/admin/media/prune/all.go @@ -50,7 +50,7 @@ var All action.GTSAction = func(ctx context.Context) error { // Perform the actual pruning with logging. prune.cleaner.Media().All(ctx, days) - prune.cleaner.Emoji().All(ctx) + prune.cleaner.Emoji().All(ctx, days) // Perform a cleanup of storage (for removed local dirs). if err := prune.storage.Storage.Clean(ctx); err != nil { diff --git a/internal/cleaner/cleaner.go b/internal/cleaner/cleaner.go index ee1e4785f..70497c10e 100644 --- a/internal/cleaner/cleaner.go +++ b/internal/cleaner/cleaner.go @@ -61,19 +61,19 @@ func (c *Cleaner) Media() *Media { return &c.media } -// checkFiles checks for each of the provided files, and calls onMissing() if any of them are missing. Returns true if missing. -func (c *Cleaner) checkFiles(ctx context.Context, onMissing func() error, files ...string) (bool, error) { +// haveFiles returns whether all of the provided files exist within current storage. +func (c *Cleaner) haveFiles(ctx context.Context, files ...string) (bool, error) { for _, file := range files { // Check whether each file exists in storage. have, err := c.state.Storage.Has(ctx, file) if err != nil { return false, gtserror.Newf("error checking storage for %s: %w", file, err) } else if !have { - // Missing files, perform hook. - return true, onMissing() + // Missing file(s). + return false, nil } } - return false, nil + return true, nil } // removeFiles removes the provided files, returning the number of them returned. @@ -129,7 +129,7 @@ func scheduleJobs(c *Cleaner) { c.state.Workers.Scheduler.Schedule(sched.NewJob(func(start time.Time) { log.Info(nil, "starting media clean") c.Media().All(doneCtx, config.GetMediaRemoteCacheDays()) - c.Emoji().All(doneCtx) + c.Emoji().All(doneCtx, config.GetMediaRemoteCacheDays()) log.Infof(nil, "finished media clean after %s", time.Since(start)) }).EveryAt(midnight, day)) } diff --git a/internal/cleaner/cleaner_test.go b/internal/cleaner/cleaner_test.go new file mode 100644 index 000000000..d23dac504 --- /dev/null +++ b/internal/cleaner/cleaner_test.go @@ -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 . + +package cleaner_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/cleaner" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type CleanerTestSuite struct { + state state.State + cleaner *cleaner.Cleaner + emojis map[string]*gtsmodel.Emoji + suite.Suite +} + +func TestCleanerTestSuite(t *testing.T) { + suite.Run(t, &CleanerTestSuite{}) +} + +func (suite *CleanerTestSuite) SetupSuite() { + testrig.InitTestConfig() + testrig.InitTestLog() +} + +func (suite *CleanerTestSuite) SetupTest() { + // Initialize gts caches. + suite.state.Caches.Init() + + // Ensure scheduler started (even if unused). + suite.state.Workers.Scheduler.Start(nil) + + // Initialize test database. + _ = testrig.NewTestDB(&suite.state) + testrig.StandardDBSetup(suite.state.DB, nil) + + // Initialize test storage (in-memory). + suite.state.Storage = testrig.NewInMemoryStorage() + + // Initialize test cleaner instance. + suite.cleaner = cleaner.New(&suite.state) + + // Allocate new test model emojis. + suite.emojis = testrig.NewTestEmojis() +} + +func (suite *CleanerTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.state.DB) +} + +// mapvals extracts a slice of values from the values contained within the map. +func mapvals[Key comparable, Val any](m map[Key]Val) []Val { + var i int + vals := make([]Val, len(m)) + for _, val := range m { + vals[i] = val + i++ + } + return vals +} diff --git a/internal/cleaner/emoji.go b/internal/cleaner/emoji.go index 35e579171..d2baec7e8 100644 --- a/internal/cleaner/emoji.go +++ b/internal/cleaner/emoji.go @@ -20,6 +20,7 @@ package cleaner import ( "context" "errors" + "time" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" @@ -36,22 +37,26 @@ type Emoji struct { // All will execute all cleaner.Emoji utilities synchronously, including output logging. // Context will be checked for `gtscontext.DryRun()` in order to actually perform the action. -func (e *Emoji) All(ctx context.Context) { - e.LogPruneMissing(ctx) +func (e *Emoji) All(ctx context.Context, maxRemoteDays int) { + t := time.Now().Add(-24 * time.Hour * time.Duration(maxRemoteDays)) + e.LogUncacheRemote(ctx, t) e.LogFixBroken(ctx) + e.LogPruneUnused(ctx) + e.LogFixCacheStates(ctx) + _ = e.state.Storage.Storage.Clean(ctx) } -// LogPruneMissing performs emoji.PruneMissing(...), logging the start and outcome. -func (e *Emoji) LogPruneMissing(ctx context.Context) { - log.Info(ctx, "start") - if n, err := e.PruneMissing(ctx); err != nil { +// LogUncacheRemote performs Emoji.UncacheRemote(...), logging the start and outcome. +func (e *Emoji) LogUncacheRemote(ctx context.Context, olderThan time.Time) { + log.Infof(ctx, "start older than: %s", olderThan.Format(time.Stamp)) + if n, err := e.UncacheRemote(ctx, olderThan); err != nil { log.Error(ctx, err) } else { - log.Infof(ctx, "pruned: %d", n) + log.Infof(ctx, "uncached: %d", n) } } -// LogFixBroken performs emoji.FixBroken(...), logging the start and outcome. +// LogFixBroken performs Emoji.FixBroken(...), logging the start and outcome. func (e *Emoji) LogFixBroken(ctx context.Context) { log.Info(ctx, "start") if n, err := e.FixBroken(ctx); err != nil { @@ -61,20 +66,43 @@ func (e *Emoji) LogFixBroken(ctx context.Context) { } } -// PruneMissing will delete emoji with missing files from the database and storage driver. -// Context will be checked for `gtscontext.DryRun()` to perform the action. NOTE: this function -// should be updated to match media.FixCacheStat() if we ever support emoji uncaching. -func (e *Emoji) PruneMissing(ctx context.Context) (int, error) { - var ( - total int - maxID string - ) +// LogPruneUnused performs Emoji.PruneUnused(...), logging the start and outcome. +func (e *Emoji) LogPruneUnused(ctx context.Context) { + log.Info(ctx, "start") + if n, err := e.PruneUnused(ctx); err != nil { + log.Error(ctx, err) + } else { + log.Infof(ctx, "pruned: %d", n) + } +} + +// LogFixCacheStates performs Emoji.FixCacheStates(...), logging the start and outcome. +func (e *Emoji) LogFixCacheStates(ctx context.Context) { + log.Info(ctx, "start") + if n, err := e.FixCacheStates(ctx); err != nil { + log.Error(ctx, err) + } else { + log.Infof(ctx, "fixed: %d", n) + } +} + +// UncacheRemote will uncache all remote emoji older than given input time. Context +// will be checked for `gtscontext.DryRun()` in order to actually perform the action. +func (e *Emoji) UncacheRemote(ctx context.Context, olderThan time.Time) (int, error) { + var total int + + // Drop time by a minute to improve search, + // (i.e. make it olderThan inclusive search). + olderThan = olderThan.Add(-time.Minute) + + // Store recent time. + mostRecent := olderThan for { - // Fetch the next batch of emoji media up to next ID. - emojis, err := e.state.DB.GetEmojis(ctx, maxID, selectLimit) + // Fetch the next batch of cached emojis older than last-set time. + emojis, err := e.state.DB.GetCachedEmojisOlderThan(ctx, olderThan, selectLimit) if err != nil && !errors.Is(err, db.ErrNoEntries) { - return total, gtserror.Newf("error getting emojis: %w", err) + return total, gtserror.Newf("error getting remote emoji: %w", err) } if len(emojis) == 0 { @@ -82,17 +110,20 @@ func (e *Emoji) PruneMissing(ctx context.Context) (int, error) { break } - // Use last as the next 'maxID' value. - maxID = emojis[len(emojis)-1].ID + // Use last created-at as the next 'olderThan' value. + olderThan = emojis[len(emojis)-1].CreatedAt for _, emoji := range emojis { - // Check / fix missing emoji media. - fixed, err := e.pruneMissing(ctx, emoji) + // Check / uncache each remote emoji. + uncached, err := e.uncacheRemote(ctx, + mostRecent, + emoji, + ) if err != nil { return total, err } - if fixed { + if uncached { // Update // count. total++ @@ -145,22 +176,197 @@ func (e *Emoji) FixBroken(ctx context.Context) (int, error) { return total, nil } -func (e *Emoji) pruneMissing(ctx context.Context, emoji *gtsmodel.Emoji) (bool, error) { - return e.checkFiles(ctx, func() error { - // Emoji missing files, delete it. - // NOTE: if we ever support uncaching - // of emojis, change to e.uncache(). - // In that case we should also rename - // this function to match the media - // equivalent -> fixCacheState(). - log.WithContext(ctx). - WithField("emoji", emoji.ID). - Debug("deleting due to missing emoji") - return e.delete(ctx, emoji) - }, +// PruneUnused will delete all unused emoji media from the database and storage driver. +// Context will be checked for `gtscontext.DryRun()` to perform the action. NOTE: this function +// should be updated to match media.FixCacheStat() if we ever support emoji uncaching. +func (e *Emoji) PruneUnused(ctx context.Context) (int, error) { + var ( + total int + maxID string + ) + + for { + // Fetch the next batch of emoji media up to next ID. + emojis, err := e.state.DB.GetRemoteEmojis(ctx, maxID, selectLimit) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return total, gtserror.Newf("error getting remote emojis: %w", err) + } + + if len(emojis) == 0 { + // reached end. + break + } + + // Use last as the next 'maxID' value. + maxID = emojis[len(emojis)-1].ID + + for _, emoji := range emojis { + // Check / prune unused emoji media. + fixed, err := e.pruneUnused(ctx, emoji) + if err != nil { + return total, err + } + + if fixed { + // Update + // count. + total++ + } + } + } + + return total, nil +} + +// FixCacheStatus will check all emoji for up-to-date cache status (i.e. in storage driver). +// Context will be checked for `gtscontext.DryRun()` to perform the action. NOTE: this function +// should be updated to match media.FixCacheStat() if we ever support emoji uncaching. +func (e *Emoji) FixCacheStates(ctx context.Context) (int, error) { + var ( + total int + maxID string + ) + + for { + // Fetch the next batch of emoji media up to next ID. + emojis, err := e.state.DB.GetRemoteEmojis(ctx, maxID, selectLimit) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return total, gtserror.Newf("error getting remote emojis: %w", err) + } + + if len(emojis) == 0 { + // reached end. + break + } + + // Use last as the next 'maxID' value. + maxID = emojis[len(emojis)-1].ID + + for _, emoji := range emojis { + // Check / fix required emoji cache states. + fixed, err := e.fixCacheState(ctx, emoji) + if err != nil { + return total, err + } + + if fixed { + // Update + // count. + total++ + } + } + } + + return total, nil +} + +func (e *Emoji) pruneUnused(ctx context.Context, emoji *gtsmodel.Emoji) (bool, error) { + // Start a log entry for emoji. + l := log.WithContext(ctx). + WithField("emoji", emoji.ID) + + // Load any related accounts using this emoji. + accounts, err := e.getRelatedAccounts(ctx, emoji) + if err != nil { + return false, err + } else if len(accounts) > 0 { + l.Debug("skipping as account emoji in use") + return false, nil + } + + // Load any related statuses using this emoji. + statuses, err := e.getRelatedStatuses(ctx, emoji) + if err != nil { + return false, err + } else if len(statuses) > 0 { + l.Debug("skipping as status emoji in use") + return false, nil + } + + // Check not recently created, give it some time to be "used" again. + if time.Now().Add(-24 * time.Hour * 7).Before(emoji.CreatedAt) { + l.Debug("skipping due to recently created") + return false, nil + } + + // Emoji totally unused, delete it. + l.Debug("deleting unused emoji") + return true, e.delete(ctx, emoji) +} + +func (e *Emoji) fixCacheState(ctx context.Context, emoji *gtsmodel.Emoji) (bool, error) { + // Start a log entry for emoji. + l := log.WithContext(ctx). + WithField("emoji", emoji.ID) + + // Check whether files exist. + exist, err := e.haveFiles(ctx, emoji.ImageStaticPath, emoji.ImagePath, ) + if err != nil { + return false, err + } + + switch { + case *emoji.Cached && !exist: + // Mark as uncached if expected files don't exist. + l.Debug("cached=true exists=false => marking uncached") + return true, e.uncache(ctx, emoji) + + case !*emoji.Cached && exist: + // Remove files if we don't expect them to exist. + l.Debug("cached=false exists=true => removing files") + _, err := e.removeFiles(ctx, + emoji.ImageStaticPath, + emoji.ImagePath, + ) + return true, err + + default: + return false, nil + } +} + +func (e *Emoji) uncacheRemote(ctx context.Context, after time.Time, emoji *gtsmodel.Emoji) (bool, error) { + if !*emoji.Cached { + // Already uncached. + return false, nil + } + + // Start a log entry for emoji. + l := log.WithContext(ctx). + WithField("emoji", emoji.ID) + + // Load any related accounts using this emoji. + accounts, err := e.getRelatedAccounts(ctx, emoji) + if err != nil { + return false, err + } + + for _, account := range accounts { + if account.FetchedAt.After(after) { + l.Debug("skipping due to recently fetched account") + return false, nil + } + } + + // Load any related statuses using this emoji. + statuses, err := e.getRelatedStatuses(ctx, emoji) + if err != nil { + return false, err + } + + for _, status := range statuses { + if status.FetchedAt.After(after) { + l.Debug("skipping due to recently fetched status") + return false, nil + } + } + + // This emoji is too old, uncache it. + l.Debug("uncaching old remote emoji") + return true, e.uncache(ctx, emoji) } func (e *Emoji) fixBroken(ctx context.Context, emoji *gtsmodel.Emoji) (bool, error) { @@ -214,6 +420,47 @@ func (e *Emoji) getRelatedCategory(ctx context.Context, emoji *gtsmodel.Emoji) ( return category, false, nil } +func (e *Emoji) getRelatedAccounts(ctx context.Context, emoji *gtsmodel.Emoji) ([]*gtsmodel.Account, error) { + accounts, err := e.state.DB.GetAccountsUsingEmoji(ctx, emoji.ID) + if err != nil { + return nil, gtserror.Newf("error fetching accounts using emoji %s: %w", emoji.ID, err) + } + return accounts, nil +} + +func (e *Emoji) getRelatedStatuses(ctx context.Context, emoji *gtsmodel.Emoji) ([]*gtsmodel.Status, error) { + statuses, err := e.state.DB.GetStatusesUsingEmoji(ctx, emoji.ID) + if err != nil { + return nil, gtserror.Newf("error fetching statuses using emoji %s: %w", emoji.ID, err) + } + return statuses, nil +} + +func (e *Emoji) uncache(ctx context.Context, emoji *gtsmodel.Emoji) error { + if gtscontext.DryRun(ctx) { + // Dry run, do nothing. + return nil + } + + // Remove emoji and static. + _, err := e.removeFiles(ctx, + emoji.ImagePath, + emoji.ImageStaticPath, + ) + if err != nil { + return gtserror.Newf("error removing emoji files: %w", err) + } + + // Update emoji to reflect that we no longer have it cached. + log.Debugf(ctx, "marking emoji as uncached: %s", emoji.ID) + emoji.Cached = func() *bool { i := false; return &i }() + if err := e.state.DB.UpdateEmoji(ctx, emoji, "cached"); err != nil { + return gtserror.Newf("error updating emoji: %w", err) + } + + return nil +} + func (e *Emoji) delete(ctx context.Context, emoji *gtsmodel.Emoji) error { if gtscontext.DryRun(ctx) { // Dry run, do nothing. diff --git a/internal/cleaner/emoji_test.go b/internal/cleaner/emoji_test.go new file mode 100644 index 000000000..81fde6e48 --- /dev/null +++ b/internal/cleaner/emoji_test.go @@ -0,0 +1,402 @@ +package cleaner_test + +import ( + "context" + "errors" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (suite *CleanerTestSuite) TestEmojiUncacheRemote() { + suite.testEmojiUncacheRemote( + context.Background(), + mapvals(suite.emojis), + ) +} + +func (suite *CleanerTestSuite) TestEmojiUncacheRemoteDryRun() { + suite.testEmojiUncacheRemote( + gtscontext.SetDryRun(context.Background()), + mapvals(suite.emojis), + ) +} + +func (suite *CleanerTestSuite) TestEmojiFixBroken() { + suite.testEmojiFixBroken( + context.Background(), + mapvals(suite.emojis), + ) +} + +func (suite *CleanerTestSuite) TestEmojiFixBrokenDryRun() { + suite.testEmojiFixBroken( + gtscontext.SetDryRun(context.Background()), + mapvals(suite.emojis), + ) +} + +func (suite *CleanerTestSuite) TestEmojiPruneUnused() { + suite.testEmojiPruneUnused( + context.Background(), + mapvals(suite.emojis), + ) +} + +func (suite *CleanerTestSuite) TestEmojiPruneUnusedDryRun() { + suite.testEmojiPruneUnused( + gtscontext.SetDryRun(context.Background()), + mapvals(suite.emojis), + ) +} + +func (suite *CleanerTestSuite) TestEmojiFixCacheStates() { + suite.testEmojiFixCacheStates( + context.Background(), + mapvals(suite.emojis), + ) +} + +func (suite *CleanerTestSuite) TestEmojiFixCacheStatesDryRun() { + suite.testEmojiFixCacheStates( + gtscontext.SetDryRun(context.Background()), + mapvals(suite.emojis), + ) +} + +func (suite *CleanerTestSuite) testEmojiUncacheRemote(ctx context.Context, emojis []*gtsmodel.Emoji) { + var uncacheIDs []string + + // Test state. + t := suite.T() + + // Get max remote cache days to keep. + days := config.GetMediaRemoteCacheDays() + olderThan := time.Now().Add(-24 * time.Hour * time.Duration(days)) + + for _, emoji := range emojis { + // Check whether this emoji should be uncached. + ok, err := suite.shouldUncacheEmoji(ctx, emoji, olderThan) + if err != nil { + t.Fatalf("error checking whether emoji should be uncached: %v", err) + } + + if ok { + // Mark this emoji ID as to be uncached. + uncacheIDs = append(uncacheIDs, emoji.ID) + } + } + + // Attempt to uncache remote emojis. + found, err := suite.cleaner.Emoji().UncacheRemote(ctx, olderThan) + if err != nil { + t.Errorf("error uncaching remote emojis: %v", err) + return + } + + // Check expected were uncached. + if found != len(uncacheIDs) { + t.Errorf("expected %d emojis to be uncached, %d were", len(uncacheIDs), found) + return + } + + if gtscontext.DryRun(ctx) { + // nothing else to test. + return + } + + for _, id := range uncacheIDs { + // Fetch the emoji by ID that should now be uncached. + emoji, err := suite.state.DB.GetEmojiByID(ctx, id) + if err != nil { + t.Fatalf("error fetching emoji from database: %v", err) + } + + // Check cache state. + if *emoji.Cached { + t.Errorf("emoji %s@%s should have been uncached", emoji.Shortcode, emoji.Domain) + } + + // Check that the emoji files in storage have been deleted. + if ok, err := suite.state.Storage.Has(ctx, emoji.ImagePath); err != nil { + t.Fatalf("error checking storage for emoji: %v", err) + } else if ok { + t.Errorf("emoji %s@%s image path should not exist", emoji.Shortcode, emoji.Domain) + } else if ok, err := suite.state.Storage.Has(ctx, emoji.ImageStaticPath); err != nil { + t.Fatalf("error checking storage for emoji: %v", err) + } else if ok { + t.Errorf("emoji %s@%s image static path should not exist", emoji.Shortcode, emoji.Domain) + } + } +} + +func (suite *CleanerTestSuite) shouldUncacheEmoji(ctx context.Context, emoji *gtsmodel.Emoji, after time.Time) (bool, error) { + if emoji.ImageRemoteURL == "" { + // Local emojis are never uncached. + return false, nil + } + + if emoji.Cached == nil || !*emoji.Cached { + // Emoji is already uncached. + return false, nil + } + + // Get related accounts using this emoji (if any). + accounts, err := suite.state.DB.GetAccountsUsingEmoji(ctx, emoji.ID) + if err != nil { + return false, err + } + + // Check if accounts are recently updated. + for _, account := range accounts { + if account.FetchedAt.After(after) { + return false, nil + } + } + + // Get related statuses using this emoji (if any). + statuses, err := suite.state.DB.GetStatusesUsingEmoji(ctx, emoji.ID) + if err != nil { + return false, err + } + + // Check if statuses are recently updated. + for _, status := range statuses { + if status.FetchedAt.After(after) { + return false, nil + } + } + + return true, nil +} + +func (suite *CleanerTestSuite) testEmojiFixBroken(ctx context.Context, emojis []*gtsmodel.Emoji) { + var fixIDs []string + + // Test state. + t := suite.T() + + for _, emoji := range emojis { + // Check whether this emoji should be fixed. + ok, err := suite.shouldFixBrokenEmoji(ctx, emoji) + if err != nil { + t.Fatalf("error checking whether emoji should be fixed: %v", err) + } + + if ok { + // Mark this emoji ID as to be fixed. + fixIDs = append(fixIDs, emoji.ID) + } + } + + // Attempt to fix broken emojis. + found, err := suite.cleaner.Emoji().FixBroken(ctx) + if err != nil { + t.Errorf("error fixing broken emojis: %v", err) + return + } + + // Check expected were fixed. + if found != len(fixIDs) { + t.Errorf("expected %d emojis to be fixed, %d were", len(fixIDs), found) + return + } + + if gtscontext.DryRun(ctx) { + // nothing else to test. + return + } + + for _, id := range fixIDs { + // Fetch the emoji by ID that should now be fixed. + emoji, err := suite.state.DB.GetEmojiByID(ctx, id) + if err != nil { + t.Fatalf("error fetching emoji from database: %v", err) + } + + // Ensure category was cleared. + if emoji.CategoryID != "" { + t.Errorf("emoji %s@%s should have empty category", emoji.Shortcode, emoji.Domain) + } + } +} + +func (suite *CleanerTestSuite) shouldFixBrokenEmoji(ctx context.Context, emoji *gtsmodel.Emoji) (bool, error) { + if emoji.CategoryID == "" { + // no category issue. + return false, nil + } + + // Get the related category for this emoji. + category, err := suite.state.DB.GetEmojiCategory(ctx, emoji.CategoryID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return false, nil + } + + return (category == nil), nil +} + +func (suite *CleanerTestSuite) testEmojiPruneUnused(ctx context.Context, emojis []*gtsmodel.Emoji) { + var pruneIDs []string + + // Test state. + t := suite.T() + + for _, emoji := range emojis { + // Check whether this emoji should be pruned. + ok, err := suite.shouldPruneEmoji(ctx, emoji) + if err != nil { + t.Fatalf("error checking whether emoji should be pruned: %v", err) + } + + if ok { + // Mark this emoji ID as to be pruned. + pruneIDs = append(pruneIDs, emoji.ID) + } + } + + // Attempt to prune emojis. + found, err := suite.cleaner.Emoji().PruneUnused(ctx) + if err != nil { + t.Errorf("error fixing broken emojis: %v", err) + return + } + + // Check expected were pruned. + if found != len(pruneIDs) { + t.Errorf("expected %d emojis to be pruned, %d were", len(pruneIDs), found) + return + } + + if gtscontext.DryRun(ctx) { + // nothing else to test. + return + } + + for _, id := range pruneIDs { + // Fetch the emoji by ID that should now be pruned. + emoji, err := suite.state.DB.GetEmojiByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + t.Fatalf("error fetching emoji from database: %v", err) + } + + // Ensure gone. + if emoji != nil { + t.Errorf("emoji %s@%s should have been pruned", emoji.Shortcode, emoji.Domain) + } + } +} + +func (suite *CleanerTestSuite) shouldPruneEmoji(ctx context.Context, emoji *gtsmodel.Emoji) (bool, error) { + if emoji.ImageRemoteURL == "" { + // Local emojis are never pruned. + return false, nil + } + + // Get related accounts using this emoji (if any). + accounts, err := suite.state.DB.GetAccountsUsingEmoji(ctx, emoji.ID) + if err != nil { + return false, err + } else if len(accounts) > 0 { + return false, nil + } + + // Get related statuses using this emoji (if any). + statuses, err := suite.state.DB.GetStatusesUsingEmoji(ctx, emoji.ID) + if err != nil { + return false, err + } else if len(statuses) > 0 { + return false, nil + } + + return true, nil +} + +func (suite *CleanerTestSuite) testEmojiFixCacheStates(ctx context.Context, emojis []*gtsmodel.Emoji) { + var fixIDs []string + + // Test state. + t := suite.T() + + for _, emoji := range emojis { + // Check whether this emoji should be fixed. + ok, err := suite.shouldFixEmojiCacheState(ctx, emoji) + if err != nil { + t.Fatalf("error checking whether emoji should be fixed: %v", err) + } + + if ok { + // Mark this emoji ID as to be fixed. + fixIDs = append(fixIDs, emoji.ID) + } + } + + // Attempt to fix broken emoji cache states. + found, err := suite.cleaner.Emoji().FixCacheStates(ctx) + if err != nil { + t.Errorf("error fixing broken emojis: %v", err) + return + } + + // Check expected were fixed. + if found != len(fixIDs) { + t.Errorf("expected %d emojis to be fixed, %d were", len(fixIDs), found) + return + } + + if gtscontext.DryRun(ctx) { + // nothing else to test. + return + } + + for _, id := range fixIDs { + // Fetch the emoji by ID that should now be fixed. + emoji, err := suite.state.DB.GetEmojiByID(ctx, id) + if err != nil { + t.Fatalf("error fetching emoji from database: %v", err) + } + + // Ensure emoji cache state has been fixed. + ok, err := suite.shouldFixEmojiCacheState(ctx, emoji) + if err != nil { + t.Fatalf("error checking whether emoji should be fixed: %v", err) + } else if ok { + t.Errorf("emoji %s@%s cache state should have been fixed", emoji.Shortcode, emoji.Domain) + } + } +} + +func (suite *CleanerTestSuite) shouldFixEmojiCacheState(ctx context.Context, emoji *gtsmodel.Emoji) (bool, error) { + // Check whether emoji image path exists. + haveImage, err := suite.state.Storage.Has(ctx, emoji.ImagePath) + if err != nil { + return false, err + } + + // Check whether emoji static path exists. + haveStatic, err := suite.state.Storage.Has(ctx, emoji.ImageStaticPath) + if err != nil { + return false, err + } + + switch exists := (haveImage && haveStatic); { + case emoji.Cached != nil && + *emoji.Cached && !exists: + // (cached can be nil in tests) + // Cached but missing files. + return true, nil + + case emoji.Cached != nil && + !*emoji.Cached && exists: + // (cached can be nil in tests) + // Uncached but unexpected files. + return true, nil + + default: + // No cache state issue. + return false, nil + } +} diff --git a/internal/cleaner/media.go b/internal/cleaner/media.go index 51a0aea6d..8b11a30bf 100644 --- a/internal/cleaner/media.go +++ b/internal/cleaner/media.go @@ -96,9 +96,9 @@ func (m *Media) PruneOrphaned(ctx context.Context) (int, error) { // All media files in storage will have path fitting: {$account}/{$type}/{$size}/{$id}.{$ext} if err := m.state.Storage.WalkKeys(ctx, func(ctx context.Context, path string) error { + // Check for our expected fileserver path format. if !regexes.FilePath.MatchString(path) { - // This is not our expected media - // path format, skip this one. + log.Warn(ctx, "unexpected storage item: %s", path) return nil } @@ -177,10 +177,10 @@ func (m *Media) UncacheRemote(ctx context.Context, olderThan time.Time) (int, er mostRecent := olderThan for { - // Fetch the next batch of attachments older than last-set time. - attachments, err := m.state.DB.GetRemoteOlderThan(ctx, olderThan, selectLimit) + // Fetch the next batch of cached attachments older than last-set time. + attachments, err := m.state.DB.GetCachedAttachmentsOlderThan(ctx, olderThan, selectLimit) if err != nil && !errors.Is(err, db.ErrNoEntries) { - return total, gtserror.Newf("error getting remote media: %w", err) + return total, gtserror.Newf("error getting remote attachments: %w", err) } if len(attachments) == 0 { @@ -220,9 +220,9 @@ func (m *Media) FixCacheStates(ctx context.Context) (int, error) { for { // Fetch the next batch of media attachments up to next max ID. - attachments, err := m.state.DB.GetAttachments(ctx, maxID, selectLimit) + attachments, err := m.state.DB.GetRemoteAttachments(ctx, maxID, selectLimit) if err != nil && !errors.Is(err, db.ErrNoEntries) { - return total, gtserror.Newf("error getting avatars / headers: %w", err) + return total, gtserror.Newf("error getting remote attachments: %w", err) } if len(attachments) == 0 { @@ -323,7 +323,7 @@ func (m *Media) pruneUnused(ctx context.Context, media *gtsmodel.MediaAttachment l := log.WithContext(ctx). WithField("media", media.ID) - // Check whether we have the required account for media. + // Check whether we have the required account for media. account, missing, err := m.getRelatedAccount(ctx, media) if err != nil { return false, err @@ -367,14 +367,6 @@ func (m *Media) pruneUnused(ctx context.Context, media *gtsmodel.MediaAttachment } func (m *Media) fixCacheState(ctx context.Context, media *gtsmodel.MediaAttachment) (bool, error) { - if !*media.Cached { - // We ignore uncached media, a - // false negative is a much better - // situation than a false positive, - // re-cache will just overwrite it. - return false, nil - } - // Start a log entry for media. l := log.WithContext(ctx). WithField("media", media.ID) @@ -397,15 +389,33 @@ func (m *Media) fixCacheState(ctx context.Context, media *gtsmodel.MediaAttachme return false, nil } - // So we know this a valid cached media entry. - // Check that we have the files on disk required.... - return m.checkFiles(ctx, func() error { - l.Debug("uncaching due to missing media") - return m.uncache(ctx, media) - }, + // Check whether files exist. + exist, err := m.haveFiles(ctx, media.Thumbnail.Path, media.File.Path, ) + if err != nil { + return false, err + } + + switch { + case *media.Cached && !exist: + // Mark as uncached if expected files don't exist. + l.Debug("cached=true exists=false => uncaching") + return true, m.uncache(ctx, media) + + case !*media.Cached && exist: + // Remove files if we don't expect them to exist. + l.Debug("cached=false exists=true => deleting") + _, err := m.removeFiles(ctx, + media.Thumbnail.Path, + media.File.Path, + ) + return true, err + + default: + return false, nil + } } func (m *Media) uncacheRemote(ctx context.Context, after time.Time, media *gtsmodel.MediaAttachment) (bool, error) { diff --git a/internal/db/account.go b/internal/db/account.go index 4a08918b0..2e113c35e 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -73,6 +73,9 @@ type Account interface { // GetAccountFaves fetches faves/likes created by the target accountID. GetAccountFaves(ctx context.Context, accountID string) ([]*gtsmodel.StatusFave, Error) + // GetAccountsUsingEmoji fetches all account models using emoji with given ID stored in their 'emojis' column. + GetAccountsUsingEmoji(ctx context.Context, emojiID string) ([]*gtsmodel.Account, error) + // GetAccountStatusesCount is a shortcut for the common action of counting statuses produced by accountID. CountAccountStatuses(ctx context.Context, accountID string) (int, Error) diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 17339732e..179db6bb3 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -56,6 +56,27 @@ func (a *accountDB) GetAccountByID(ctx context.Context, id string) (*gtsmodel.Ac ) } +func (a *accountDB) GetAccountsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Account, error) { + accounts := make([]*gtsmodel.Account, 0, len(ids)) + + for _, id := range ids { + // Attempt to fetch account from DB. + account, err := a.GetAccountByID( + gtscontext.SetBarebones(ctx), + id, + ) + if err != nil { + log.Errorf(ctx, "error getting account %q: %v", id, err) + continue + } + + // Append account to return slice. + accounts = append(accounts, account) + } + + return accounts, nil +} + func (a *accountDB) GetAccountByURI(ctx context.Context, uri string) (*gtsmodel.Account, db.Error) { return a.getAccount( ctx, @@ -444,6 +465,34 @@ func (a *accountDB) GetAccountCustomCSSByUsername(ctx context.Context, username return account.CustomCSS, nil } +func (a *accountDB) GetAccountsUsingEmoji(ctx context.Context, emojiID string) ([]*gtsmodel.Account, error) { + var accountIDs []string + + // Create SELECT account query. + q := a.conn.NewSelect(). + Table("accounts"). + Column("id") + + // Append a WHERE LIKE clause to the query + // that checks the `emoji` column for any + // text containing this specific emoji ID. + // + // The reason we do this instead of doing a + // `WHERE ? IN (emojis)` is that the latter + // ends up being much MUCH slower, and the + // database stores this ID-array-column as + // text anyways, allowing a simple LIKE query. + q = whereLike(q, "emojis", emojiID) + + // Execute the query, scanning destination into accountIDs. + if _, err := q.Exec(ctx, &accountIDs); err != nil { + return nil, a.conn.ProcessError(err) + } + + // Convert account IDs into account objects. + return a.GetAccountsByIDs(ctx, accountIDs) +} + func (a *accountDB) GetAccountFaves(ctx context.Context, accountID string) ([]*gtsmodel.StatusFave, db.Error) { faves := new([]*gtsmodel.StatusFave) diff --git a/internal/db/bundb/emoji.go b/internal/db/bundb/emoji.go index 60c140264..321b5c0e7 100644 --- a/internal/db/bundb/emoji.go +++ b/internal/db/bundb/emoji.go @@ -126,12 +126,20 @@ func (e *emojiDB) DeleteEmojiByID(ctx context.Context, id string) db.Error { return err } - // Select all accounts using this emoji. - if _, err := tx.NewSelect(). + // Prepare SELECT accounts query. + aq := tx.NewSelect(). Table("accounts"). - Column("id"). - Where("? IN (emojis)", id). - Exec(ctx, &accountIDs); err != nil { + Column("id") + + // Append a WHERE LIKE clause to the query + // that checks the `emoji` column for any + // text containing this specific emoji ID. + // + // (see GetStatusesUsingEmoji() for details.) + aq = whereLike(aq, "emojis", id) + + // Select all accounts using this emoji into accountIDss. + if _, err := aq.Exec(ctx, &accountIDs); err != nil { return err } @@ -162,12 +170,20 @@ func (e *emojiDB) DeleteEmojiByID(ctx context.Context, id string) db.Error { } } - // Select all statuses using this emoji. - if _, err := tx.NewSelect(). + // Prepare SELECT statuses query. + sq := tx.NewSelect(). Table("statuses"). - Column("id"). - Where("? IN (emojis)", id). - Exec(ctx, &statusIDs); err != nil { + Column("id") + + // Append a WHERE LIKE clause to the query + // that checks the `emoji` column for any + // text containing this specific emoji ID. + // + // (see GetStatusesUsingEmoji() for details.) + sq = whereLike(sq, "emojis", id) + + // Select all statuses using this emoji into statusIDs. + if _, err := sq.Exec(ctx, &statusIDs); err != nil { return err } @@ -328,7 +344,7 @@ func (e *emojiDB) GetEmojisBy(ctx context.Context, domain string, includeDisable } func (e *emojiDB) GetEmojis(ctx context.Context, maxID string, limit int) ([]*gtsmodel.Emoji, error) { - emojiIDs := []string{} + var emojiIDs []string q := e.conn.NewSelect(). Table("emojis"). @@ -336,7 +352,7 @@ func (e *emojiDB) GetEmojis(ctx context.Context, maxID string, limit int) ([]*gt Order("id DESC") if maxID != "" { - q = q.Where("? < ?", bun.Ident("id"), maxID) + q = q.Where("id < ?", maxID) } if limit != 0 { @@ -350,6 +366,52 @@ func (e *emojiDB) GetEmojis(ctx context.Context, maxID string, limit int) ([]*gt return e.GetEmojisByIDs(ctx, emojiIDs) } +func (e *emojiDB) GetRemoteEmojis(ctx context.Context, maxID string, limit int) ([]*gtsmodel.Emoji, error) { + var emojiIDs []string + + q := e.conn.NewSelect(). + Table("emojis"). + Column("id"). + Where("domain IS NOT NULL"). + Order("id DESC") + + if maxID != "" { + q = q.Where("id < ?", maxID) + } + + if limit != 0 { + q = q.Limit(limit) + } + + if err := q.Scan(ctx, &emojiIDs); err != nil { + return nil, e.conn.ProcessError(err) + } + + return e.GetEmojisByIDs(ctx, emojiIDs) +} + +func (e *emojiDB) GetCachedEmojisOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.Emoji, error) { + var emojiIDs []string + + q := e.conn.NewSelect(). + Table("emojis"). + Column("id"). + Where("cached = true"). + Where("domain IS NOT NULL"). + Where("created_at < ?", olderThan). + Order("created_at DESC") + + if limit != 0 { + q = q.Limit(limit) + } + + if err := q.Scan(ctx, &emojiIDs); err != nil { + return nil, e.conn.ProcessError(err) + } + + return e.GetEmojisByIDs(ctx, emojiIDs) +} + func (e *emojiDB) GetUseableEmojis(ctx context.Context) ([]*gtsmodel.Emoji, db.Error) { emojiIDs := []string{} diff --git a/internal/db/bundb/media.go b/internal/db/bundb/media.go index 80a4f8bbe..c190df44a 100644 --- a/internal/db/bundb/media.go +++ b/internal/db/bundb/media.go @@ -232,29 +232,6 @@ func (m *mediaDB) DeleteAttachment(ctx context.Context, id string) error { return m.conn.ProcessError(err) } -func (m *mediaDB) GetRemoteOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.MediaAttachment, db.Error) { - attachmentIDs := []string{} - - q := m.conn. - NewSelect(). - TableExpr("? AS ?", bun.Ident("media_attachments"), bun.Ident("media_attachment")). - Column("media_attachment.id"). - Where("? = ?", bun.Ident("media_attachment.cached"), true). - Where("? < ?", bun.Ident("media_attachment.created_at"), olderThan). - Where("? IS NOT NULL", bun.Ident("media_attachment.remote_url")). - Order("media_attachment.created_at DESC") - - if limit != 0 { - q = q.Limit(limit) - } - - if err := q.Scan(ctx, &attachmentIDs); err != nil { - return nil, m.conn.ProcessError(err) - } - - return m.GetAttachmentsByIDs(ctx, attachmentIDs) -} - func (m *mediaDB) CountRemoteOlderThan(ctx context.Context, olderThan time.Time) (int, db.Error) { q := m.conn. NewSelect(). @@ -273,7 +250,7 @@ func (m *mediaDB) CountRemoteOlderThan(ctx context.Context, olderThan time.Time) } func (m *mediaDB) GetAttachments(ctx context.Context, maxID string, limit int) ([]*gtsmodel.MediaAttachment, error) { - attachmentIDs := []string{} + attachmentIDs := make([]string, 0, limit) q := m.conn.NewSelect(). Table("media_attachments"). @@ -281,7 +258,7 @@ func (m *mediaDB) GetAttachments(ctx context.Context, maxID string, limit int) ( Order("id DESC") if maxID != "" { - q = q.Where("? < ?", bun.Ident("id"), maxID) + q = q.Where("id < ?", maxID) } if limit != 0 { @@ -295,8 +272,55 @@ func (m *mediaDB) GetAttachments(ctx context.Context, maxID string, limit int) ( return m.GetAttachmentsByIDs(ctx, attachmentIDs) } +func (m *mediaDB) GetRemoteAttachments(ctx context.Context, maxID string, limit int) ([]*gtsmodel.MediaAttachment, error) { + attachmentIDs := make([]string, 0, limit) + + q := m.conn.NewSelect(). + Table("media_attachments"). + Column("id"). + Where("remote_url IS NOT NULL"). + Order("id DESC") + + if maxID != "" { + q = q.Where("id < ?", maxID) + } + + if limit != 0 { + q = q.Limit(limit) + } + + if err := q.Scan(ctx, &attachmentIDs); err != nil { + return nil, m.conn.ProcessError(err) + } + + return m.GetAttachmentsByIDs(ctx, attachmentIDs) +} + +func (m *mediaDB) GetCachedAttachmentsOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.MediaAttachment, db.Error) { + attachmentIDs := make([]string, 0, limit) + + q := m.conn. + NewSelect(). + Table("media_attachments"). + Column("id"). + Where("cached = true"). + Where("remote_url IS NOT NULL"). + Where("created_at < ?", olderThan). + Order("created_at DESC") + + if limit != 0 { + q = q.Limit(limit) + } + + if err := q.Scan(ctx, &attachmentIDs); err != nil { + return nil, m.conn.ProcessError(err) + } + + return m.GetAttachmentsByIDs(ctx, attachmentIDs) +} + func (m *mediaDB) GetAvatarsAndHeaders(ctx context.Context, maxID string, limit int) ([]*gtsmodel.MediaAttachment, db.Error) { - attachmentIDs := []string{} + attachmentIDs := make([]string, 0, limit) q := m.conn.NewSelect(). TableExpr("? AS ?", bun.Ident("media_attachments"), bun.Ident("media_attachment")). @@ -324,7 +348,7 @@ func (m *mediaDB) GetAvatarsAndHeaders(ctx context.Context, maxID string, limit } func (m *mediaDB) GetLocalUnattachedOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.MediaAttachment, db.Error) { - attachmentIDs := []string{} + attachmentIDs := make([]string, 0, limit) q := m.conn. NewSelect(). diff --git a/internal/db/bundb/media_test.go b/internal/db/bundb/media_test.go index 6b419022a..59b927119 100644 --- a/internal/db/bundb/media_test.go +++ b/internal/db/bundb/media_test.go @@ -38,7 +38,7 @@ func (suite *MediaTestSuite) TestGetAttachmentByID() { } func (suite *MediaTestSuite) TestGetOlder() { - attachments, err := suite.db.GetRemoteOlderThan(context.Background(), time.Now(), 20) + attachments, err := suite.db.GetCachedAttachmentsOlderThan(context.Background(), time.Now(), 20) suite.NoError(err) suite.Len(attachments, 2) } diff --git a/internal/db/bundb/migrations/20230724100000_emoji_cleanup.go b/internal/db/bundb/migrations/20230724100000_emoji_cleanup.go new file mode 100644 index 000000000..0ee501395 --- /dev/null +++ b/internal/db/bundb/migrations/20230724100000_emoji_cleanup.go @@ -0,0 +1,55 @@ +// 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 . + +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 emojis ADD COLUMN cached BOOLEAN DEFAULT false") + + if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) { + return err + } + + if _, err := db.NewUpdate(). + Table("emojis"). + Where("disabled = false"). + Set("cached = true"). + Exec(ctx); err != nil { + return err + } + + return nil + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/bundb/report.go b/internal/db/bundb/report.go index e017a8906..ee8aa1cb3 100644 --- a/internal/db/bundb/report.go +++ b/internal/db/bundb/report.go @@ -149,7 +149,7 @@ func (r *reportDB) getReport(ctx context.Context, lookup string, dbQuery func(*g if len(report.StatusIDs) > 0 { // Fetch reported statuses - report.Statuses, err = r.state.DB.GetStatuses(ctx, report.StatusIDs) + report.Statuses, err = r.state.DB.GetStatusesByIDs(ctx, report.StatusIDs) if err != nil { return nil, fmt.Errorf("error getting status mentions: %w", err) } diff --git a/internal/db/bundb/search.go b/internal/db/bundb/search.go index c05ebb8b1..1d7eefd48 100644 --- a/internal/db/bundb/search.go +++ b/internal/db/bundb/search.go @@ -19,7 +19,6 @@ package bundb import ( "context" - "strings" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" @@ -61,40 +60,6 @@ type searchDB struct { state *state.State } -// replacer is a thread-safe string replacer which escapes -// common SQLite + Postgres `LIKE` wildcard chars using the -// escape character `\`. Initialized as a var in this package -// so it can be reused. -var replacer = strings.NewReplacer( - `\`, `\\`, // Escape char. - `%`, `\%`, // Zero or more char. - `_`, `\_`, // Exactly one char. -) - -// whereSubqueryLike appends a WHERE clause to the -// given SelectQuery q, which searches for matches -// of searchQuery in the given subQuery using LIKE. -func whereSubqueryLike( - q *bun.SelectQuery, - subQuery *bun.SelectQuery, - searchQuery string, -) *bun.SelectQuery { - // Escape existing wildcard + escape - // chars in the search query string. - searchQuery = replacer.Replace(searchQuery) - - // Add our own wildcards back in; search - // zero or more chars around the query. - searchQuery = `%` + searchQuery + `%` - - // Append resulting WHERE - // clause to the main query. - return q.Where( - "(?) LIKE ? ESCAPE ?", - subQuery, searchQuery, `\`, - ) -} - // Query example (SQLite): // // SELECT "account"."id" FROM "accounts" AS "account" @@ -167,7 +132,7 @@ func (s *searchDB) SearchForAccounts( // Search using LIKE for matches of query // string within accountText subquery. - q = whereSubqueryLike(q, accountTextSubq, query) + q = whereLike(q, accountTextSubq, query) if limit > 0 { // Limit amount of accounts returned. @@ -345,7 +310,7 @@ func (s *searchDB) SearchForStatuses( // Search using LIKE for matches of query // string within statusText subquery. - q = whereSubqueryLike(q, statusTextSubq, query) + q = whereLike(q, statusTextSubq, query) if limit > 0 { // Limit amount of statuses returned. diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go index 0dffbabcc..ccfc9fd4b 100644 --- a/internal/db/bundb/status.go +++ b/internal/db/bundb/status.go @@ -58,18 +58,18 @@ func (s *statusDB) GetStatusByID(ctx context.Context, id string) (*gtsmodel.Stat ) } -func (s *statusDB) GetStatuses(ctx context.Context, ids []string) ([]*gtsmodel.Status, db.Error) { +func (s *statusDB) GetStatusesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Status, error) { statuses := make([]*gtsmodel.Status, 0, len(ids)) for _, id := range ids { - // Attempt fetch from DB + // Attempt to fetch status from DB. status, err := s.GetStatusByID(ctx, id) if err != nil { log.Errorf(ctx, "error getting status %q: %v", id, err) continue } - // Append status + // Append status to return slice. statuses = append(statuses, status) } @@ -429,6 +429,34 @@ func (s *statusDB) DeleteStatusByID(ctx context.Context, id string) db.Error { }) } +func (s *statusDB) GetStatusesUsingEmoji(ctx context.Context, emojiID string) ([]*gtsmodel.Status, error) { + var statusIDs []string + + // Create SELECT status query. + q := s.conn.NewSelect(). + Table("statuses"). + Column("id") + + // Append a WHERE LIKE clause to the query + // that checks the `emoji` column for any + // text containing this specific emoji ID. + // + // The reason we do this instead of doing a + // `WHERE ? IN (emojis)` is that the latter + // ends up being much MUCH slower, and the + // database stores this ID-array-column as + // text anyways, allowing a simple LIKE query. + q = whereLike(q, "emojis", emojiID) + + // Execute the query, scanning destination into statusIDs. + if _, err := q.Exec(ctx, &statusIDs); err != nil { + return nil, s.conn.ProcessError(err) + } + + // Convert status IDs into status objects. + return s.GetStatusesByIDs(ctx, statusIDs) +} + func (s *statusDB) GetStatusParents(ctx context.Context, status *gtsmodel.Status, onlyDirect bool) ([]*gtsmodel.Status, db.Error) { if onlyDirect { // Only want the direct parent, no further than first level diff --git a/internal/db/bundb/status_test.go b/internal/db/bundb/status_test.go index cab7501f9..a69608796 100644 --- a/internal/db/bundb/status_test.go +++ b/internal/db/bundb/status_test.go @@ -50,13 +50,13 @@ func (suite *StatusTestSuite) TestGetStatusByID() { suite.True(*status.Likeable) } -func (suite *StatusTestSuite) TestGetStatusesByID() { +func (suite *StatusTestSuite) TestGetStatusesByIDs() { ids := []string{ suite.testStatuses["local_account_1_status_1"].ID, suite.testStatuses["local_account_2_status_3"].ID, } - statuses, err := suite.db.GetStatuses(context.Background(), ids) + statuses, err := suite.db.GetStatusesByIDs(context.Background(), ids) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/db/bundb/util.go b/internal/db/bundb/util.go index 06bb289d3..bdd45d1e7 100644 --- a/internal/db/bundb/util.go +++ b/internal/db/bundb/util.go @@ -18,10 +18,46 @@ package bundb import ( + "strings" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/uptrace/bun" ) +// likeEscaper is a thread-safe string replacer which escapes +// common SQLite + Postgres `LIKE` wildcard chars using the +// escape character `\`. Initialized as a var in this package +// so it can be reused. +var likeEscaper = strings.NewReplacer( + `\`, `\\`, // Escape char. + `%`, `\%`, // Zero or more char. + `_`, `\_`, // Exactly one char. +) + +// whereSubqueryLike appends a WHERE clause to the +// given SelectQuery, which searches for matches +// of `search` in the given subQuery using LIKE. +func whereLike( + query *bun.SelectQuery, + subject interface{}, + search string, +) *bun.SelectQuery { + // Escape existing wildcard + escape + // chars in the search query string. + search = likeEscaper.Replace(search) + + // Add our own wildcards back in; search + // zero or more chars around the query. + search = `%` + search + `%` + + // Append resulting WHERE + // clause to the main query. + return query.Where( + "(?) LIKE ? ESCAPE ?", + subject, search, `\`, + ) +} + // updateWhere parses []db.Where and adds it to the given update query. func updateWhere(q *bun.UpdateQuery, where []db.Where) { for _, w := range where { diff --git a/internal/db/emoji.go b/internal/db/emoji.go index 5dcad9ece..67d7f7232 100644 --- a/internal/db/emoji.go +++ b/internal/db/emoji.go @@ -19,6 +19,7 @@ package db import ( "context" + "time" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) @@ -40,8 +41,16 @@ type Emoji interface { GetEmojisByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Emoji, Error) // GetUseableEmojis gets all emojis which are useable by accounts on this instance. GetUseableEmojis(ctx context.Context) ([]*gtsmodel.Emoji, Error) - // GetEmojis ... + + // GetEmojis fetches all emojis with IDs less than 'maxID', up to a maximum of 'limit' emojis. GetEmojis(ctx context.Context, maxID string, limit int) ([]*gtsmodel.Emoji, error) + + // GetRemoteEmojis fetches all remote emojis with IDs less than 'maxID', up to a maximum of 'limit' emojis. + GetRemoteEmojis(ctx context.Context, maxID string, limit int) ([]*gtsmodel.Emoji, error) + + // GetCachedEmojisOlderThan fetches all cached remote emojis with 'updated_at' greater than 'olderThan', up to a maximum of 'limit' emojis. + GetCachedEmojisOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.Emoji, error) + // GetEmojisBy gets emojis based on given parameters. Useful for admin actions. GetEmojisBy(ctx context.Context, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) ([]*gtsmodel.Emoji, error) // GetEmojiByID gets a specific emoji by its database ID. diff --git a/internal/db/media.go b/internal/db/media.go index 01bca1748..5fb18a8fe 100644 --- a/internal/db/media.go +++ b/internal/db/media.go @@ -44,12 +44,12 @@ type Media interface { // GetAttachments ... GetAttachments(ctx context.Context, maxID string, limit int) ([]*gtsmodel.MediaAttachment, error) - // GetRemoteOlderThan gets limit n remote media attachments (including avatars and headers) older than the given - // olderThan time. These will be returned in order of attachment.created_at descending (newest to oldest in other words). - // - // The selected media attachments will be those with both a URL and a RemoteURL filled in. - // In other words, media attachments that originated remotely, and that we currently have cached locally. - GetRemoteOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.MediaAttachment, Error) + // GetRemoteAttachments ... + GetRemoteAttachments(ctx context.Context, maxID string, limit int) ([]*gtsmodel.MediaAttachment, error) + + // GetCachedAttachmentsOlderThan gets limit n remote attachments (including avatars and headers) older than + // the given time. These will be returned in order of attachment.created_at descending (i.e. newest to oldest). + GetCachedAttachmentsOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.MediaAttachment, Error) // CountRemoteOlderThan is like GetRemoteOlderThan, except instead of getting limit n attachments, // it just counts how many remote attachments in the database (including avatars and headers) meet diff --git a/internal/db/status.go b/internal/db/status.go index fdce19094..c0e330260 100644 --- a/internal/db/status.go +++ b/internal/db/status.go @@ -28,9 +28,6 @@ type Status interface { // GetStatusByID returns one status from the database, with no rel fields populated, only their linking ID / URIs GetStatusByID(ctx context.Context, id string) (*gtsmodel.Status, Error) - // GetStatuses gets a slice of statuses corresponding to the given status IDs. - GetStatuses(ctx context.Context, ids []string) ([]*gtsmodel.Status, Error) - // GetStatusByURI returns one status from the database, with no rel fields populated, only their linking ID / URIs GetStatusByURI(ctx context.Context, uri string) (*gtsmodel.Status, Error) @@ -58,6 +55,12 @@ type Status interface { // CountStatusFaves returns the amount of faves/likes recorded for a status, or an error if something goes wrong CountStatusFaves(ctx context.Context, status *gtsmodel.Status) (int, Error) + // GetStatuses gets a slice of statuses corresponding to the given status IDs. + GetStatusesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Status, error) + + // GetStatusesUsingEmoji fetches all status models using emoji with given ID stored in their 'emojis' column. + GetStatusesUsingEmoji(ctx context.Context, emojiID string) ([]*gtsmodel.Status, error) + // GetStatusParents gets the parent statuses of a given status. // // If onlyDirect is true, only the immediate parent will be returned. diff --git a/internal/gtsmodel/emoji.go b/internal/gtsmodel/emoji.go index 1e21c7d1e..0fcc3247b 100644 --- a/internal/gtsmodel/emoji.go +++ b/internal/gtsmodel/emoji.go @@ -42,4 +42,5 @@ type Emoji struct { VisibleInPicker *bool `validate:"-" bun:",nullzero,notnull,default:true"` // Is this emoji visible in the admin emoji picker? Category *EmojiCategory `validate:"-" bun:"rel:belongs-to"` // In which emoji category is this emoji visible? CategoryID string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // ID of the category this emoji belongs to. + Cached *bool `validate:"-" bun:",nullzero,notnull,default:false"` } diff --git a/internal/media/manager.go b/internal/media/manager.go index 1d673128a..afe686cb9 100644 --- a/internal/media/manager.go +++ b/internal/media/manager.go @@ -51,12 +51,7 @@ type Manager struct { state *state.State } -// NewManager returns a media manager with the given db and underlying storage. -// -// A worker pool will also be initialized for the manager, to ensure that only -// a limited number of media will be processed in parallel. The numbers of workers -// is determined from the $GOMAXPROCS environment variable (usually no. CPU cores). -// See internal/concurrency.NewWorkerPool() documentation for further information. +// NewManager returns a media manager with given state. func NewManager(state *state.State) *Manager { m := &Manager{state: state} return m @@ -159,7 +154,7 @@ func (m *Manager) PreProcessMedia(ctx context.Context, data DataFunc, accountID return processingMedia, nil } -// PreProcessMediaRecache refetches, reprocesses, and recaches an existing attachment that has been uncached via pruneRemote. +// PreProcessMediaRecache refetches, reprocesses, and recaches an existing attachment that has been uncached via cleaner pruning. // // Note: unlike ProcessMedia, this will NOT queue the media to be asychronously processed. func (m *Manager) PreProcessMediaRecache(ctx context.Context, data DataFunc, attachmentID string) (*ProcessingMedia, error) { @@ -209,17 +204,18 @@ func (m *Manager) ProcessMedia(ctx context.Context, data DataFunc, accountID str // // Note: unlike ProcessEmoji, this will NOT queue the emoji to be asynchronously processed. func (m *Manager) PreProcessEmoji(ctx context.Context, data DataFunc, shortcode string, emojiID string, uri string, ai *AdditionalEmojiInfo, refresh bool) (*ProcessingEmoji, error) { - instanceAccount, err := m.state.DB.GetInstanceAccount(ctx, "") - if err != nil { - return nil, gtserror.Newf("error fetching this instance account from the db: %s", err) - } - var ( newPathID string emoji *gtsmodel.Emoji now = time.Now() ) + // Fetch the local instance account for emoji path generation. + instanceAcc, err := m.state.DB.GetInstanceAccount(ctx, "") + if err != nil { + return nil, gtserror.Newf("error fetching instance account: %w", err) + } + if refresh { // Look for existing emoji by given ID. emoji, err = m.state.DB.GetEmojiByID(ctx, emojiID) @@ -261,8 +257,8 @@ func (m *Manager) PreProcessEmoji(ctx context.Context, data DataFunc, shortcode } // store + serve static image at new path ID - emoji.ImageStaticURL = uris.GenerateURIForAttachment(instanceAccount.ID, string(TypeEmoji), string(SizeStatic), newPathID, mimePng) - emoji.ImageStaticPath = fmt.Sprintf("%s/%s/%s/%s.%s", instanceAccount.ID, TypeEmoji, SizeStatic, newPathID, mimePng) + emoji.ImageStaticURL = uris.GenerateURIForAttachment(instanceAcc.ID, string(TypeEmoji), string(SizeStatic), newPathID, mimePng) + emoji.ImageStaticPath = fmt.Sprintf("%s/%s/%s/%s.%s", instanceAcc.ID, TypeEmoji, SizeStatic, newPathID, mimePng) emoji.Shortcode = shortcode emoji.URI = uri @@ -278,12 +274,12 @@ func (m *Manager) PreProcessEmoji(ctx context.Context, data DataFunc, shortcode Domain: "", // assume our own domain unless told otherwise ImageRemoteURL: "", ImageStaticRemoteURL: "", - ImageURL: "", // we don't know yet - ImageStaticURL: uris.GenerateURIForAttachment(instanceAccount.ID, string(TypeEmoji), string(SizeStatic), emojiID, mimePng), // all static emojis are encoded as png - ImagePath: "", // we don't know yet - ImageStaticPath: fmt.Sprintf("%s/%s/%s/%s.%s", instanceAccount.ID, TypeEmoji, SizeStatic, emojiID, mimePng), // all static emojis are encoded as png - ImageContentType: "", // we don't know yet - ImageStaticContentType: mimeImagePng, // all static emojis are encoded as png + ImageURL: "", // we don't know yet + ImageStaticURL: uris.GenerateURIForAttachment(instanceAcc.ID, string(TypeEmoji), string(SizeStatic), emojiID, mimePng), // all static emojis are encoded as png + ImagePath: "", // we don't know yet + ImageStaticPath: fmt.Sprintf("%s/%s/%s/%s.%s", instanceAcc.ID, TypeEmoji, SizeStatic, emojiID, mimePng), // all static emojis are encoded as png + ImageContentType: "", // we don't know yet + ImageStaticContentType: mimeImagePng, // all static emojis are encoded as png ImageFileSize: 0, ImageStaticFileSize: 0, Disabled: &disabled, @@ -329,9 +325,8 @@ func (m *Manager) PreProcessEmoji(ctx context.Context, data DataFunc, shortcode } processingEmoji := &ProcessingEmoji{ - instAccID: instanceAccount.ID, emoji: emoji, - refresh: refresh, + existing: refresh, newPathID: newPathID, dataFn: data, mgr: m, @@ -340,6 +335,26 @@ func (m *Manager) PreProcessEmoji(ctx context.Context, data DataFunc, shortcode return processingEmoji, nil } +// PreProcessEmojiRecache refetches, reprocesses, and recaches an existing emoji that has been uncached via cleaner pruning. +// +// Note: unlike ProcessEmoji, this will NOT queue the emoji to be asychronously processed. +func (m *Manager) PreProcessEmojiRecache(ctx context.Context, data DataFunc, emojiID string) (*ProcessingEmoji, error) { + // get the existing emoji from the database. + emoji, err := m.state.DB.GetEmojiByID(ctx, emojiID) + if err != nil { + return nil, err + } + + processingEmoji := &ProcessingEmoji{ + emoji: emoji, + dataFn: data, + existing: true, // inidcate recache + mgr: m, + } + + return processingEmoji, nil +} + // ProcessEmoji will call PreProcessEmoji, followed by queuing the emoji to be processing in the emoji worker queue. func (m *Manager) ProcessEmoji(ctx context.Context, data DataFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo, refresh bool) (*ProcessingEmoji, error) { // Create a new processing emoji object for this emoji request. diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go index d3a1edbf8..1c7e60144 100644 --- a/internal/media/processingemoji.go +++ b/internal/media/processingemoji.go @@ -31,16 +31,16 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/regexes" "github.com/superseriousbusiness/gotosocial/internal/uris" ) // ProcessingEmoji represents an emoji currently processing. It exposes // various functions for retrieving data from the process. type ProcessingEmoji struct { - instAccID string // instance account ID emoji *gtsmodel.Emoji // processing emoji details - refresh bool // whether this is an existing emoji being refreshed - newPathID string // new emoji path ID to use if refreshed + existing bool // indicates whether this is an existing emoji ID being refreshed / recached + newPathID string // new emoji path ID to use when being refreshed dataFn DataFunc // load-data function, returns media stream done bool // done is set when process finishes with non ctx canceled type error proc runners.Processor // proc helps synchronize only a singular running processing instance @@ -121,24 +121,9 @@ func (p *ProcessingEmoji) load(ctx context.Context) (*gtsmodel.Emoji, bool, erro return err } - if p.refresh { - columns := []string{ - "image_remote_url", - "image_static_remote_url", - "image_url", - "image_static_url", - "image_path", - "image_static_path", - "image_content_type", - "image_file_size", - "image_static_file_size", - "image_updated_at", - "shortcode", - "uri", - } - - // Existing emoji we're refreshing, so only need to update. - err = p.mgr.state.DB.UpdateEmoji(ctx, p.emoji, columns...) + if p.existing { + // Existing emoji we're updating, so only update. + err = p.mgr.state.DB.UpdateEmoji(ctx, p.emoji) return err } @@ -217,7 +202,7 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { var pathID string - if p.refresh { + if p.newPathID != "" { // This is a refreshed emoji with a new // path ID that this will be stored under. pathID = p.newPathID @@ -226,10 +211,13 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { pathID = p.emoji.ID } + // Determine instance account ID from already generated image static path. + instanceAccID := regexes.FilePath.FindStringSubmatch(p.emoji.ImageStaticPath)[1] + // Calculate emoji file path. p.emoji.ImagePath = fmt.Sprintf( "%s/%s/%s/%s.%s", - p.instAccID, + instanceAccID, TypeEmoji, SizeOriginal, pathID, @@ -258,12 +246,13 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { if err := p.mgr.state.Storage.Delete(ctx, p.emoji.ImagePath); err != nil { log.Errorf(ctx, "error removing too-large-emoji from storage: %v", err) } + return gtserror.Newf("calculated emoji size %s greater than max allowed %s", size, maxSize) } // Fill in remaining attachment data now it's stored. p.emoji.ImageURL = uris.GenerateURIForAttachment( - p.instAccID, + instanceAccID, string(TypeEmoji), string(SizeOriginal), pathID, @@ -271,6 +260,10 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { ) p.emoji.ImageContentType = info.MIME.Value p.emoji.ImageFileSize = int(sz) + p.emoji.Cached = func() *bool { + ok := true + return &ok + }() return nil } @@ -297,6 +290,7 @@ func (p *ProcessingEmoji) finish(ctx context.Context) error { // This shouldn't already exist, but we do a check as it's worth logging. if have, _ := p.mgr.state.Storage.Has(ctx, p.emoji.ImageStaticPath); have { log.Warnf(ctx, "static emoji already exists at storage path: %s", p.emoji.ImagePath) + // Attempt to remove static existing emoji at storage path (might be broken / out-of-date) if err := p.mgr.state.Storage.Delete(ctx, p.emoji.ImageStaticPath); err != nil { return gtserror.Newf("error removing static emoji from storage: %v", err) diff --git a/internal/processing/admin/media.go b/internal/processing/admin/media.go index a457487b8..13dcb7d28 100644 --- a/internal/processing/admin/media.go +++ b/internal/processing/admin/media.go @@ -58,7 +58,7 @@ func (p *Processor) MediaPrune(ctx context.Context, mediaRemoteCacheDays int) gt go func() { ctx := context.Background() p.cleaner.Media().All(ctx, mediaRemoteCacheDays) - p.cleaner.Emoji().All(ctx) + p.cleaner.Emoji().All(ctx, mediaRemoteCacheDays) }() return nil diff --git a/internal/processing/fromclientapi.go b/internal/processing/fromclientapi.go index 159f09d1b..412403c44 100644 --- a/internal/processing/fromclientapi.go +++ b/internal/processing/fromclientapi.go @@ -982,7 +982,7 @@ func (p *Processor) federateReport(ctx context.Context, report *gtsmodel.Report) } if len(report.StatusIDs) > 0 && len(report.Statuses) == 0 { - statuses, err := p.state.DB.GetStatuses(ctx, report.StatusIDs) + statuses, err := p.state.DB.GetStatusesByIDs(ctx, report.StatusIDs) if err != nil { return fmt.Errorf("federateReport: error getting report statuses from database: %w", err) } diff --git a/internal/processing/media/getfile.go b/internal/processing/media/getfile.go index 27b08600e..386c3a9a2 100644 --- a/internal/processing/media/getfile.go +++ b/internal/processing/media/getfile.go @@ -118,7 +118,7 @@ func (p *Processor) getAttachmentContent(ctx context.Context, requestingAccount // retrieve attachment from the database and do basic checks on it a, err := p.state.DB.GetAttachmentByID(ctx, wantedMediaID) if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("attachment %s could not be taken from the db: %s", wantedMediaID, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("attachment %s could not be taken from the db: %w", wantedMediaID, err)) } if a.AccountID != owningAccountID { @@ -131,7 +131,7 @@ func (p *Processor) getAttachmentContent(ctx context.Context, requestingAccount // 2. we need to fetch it again using a transport and the media manager remoteMediaIRI, err := url.Parse(a.RemoteURL) if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error parsing remote media iri %s: %s", a.RemoteURL, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error parsing remote media iri %s: %w", a.RemoteURL, err)) } // use an empty string as requestingUsername to use the instance account, unless the request for this @@ -151,24 +151,24 @@ func (p *Processor) getAttachmentContent(ctx context.Context, requestingAccount // recache operation -> holding open a media worker. // ] - dataFn := func(innerCtx context.Context) (io.ReadCloser, int64, error) { - t, err := p.transportController.NewTransportForUsername(innerCtx, requestingUsername) + dataFn := func(ctx context.Context) (io.ReadCloser, int64, error) { + t, err := p.transportController.NewTransportForUsername(ctx, requestingUsername) if err != nil { return nil, 0, err } - return t.DereferenceMedia(gtscontext.SetFastFail(innerCtx), remoteMediaIRI) + return t.DereferenceMedia(gtscontext.SetFastFail(ctx), remoteMediaIRI) } // Start recaching this media with the prepared data function. processingMedia, err := p.mediaManager.PreProcessMediaRecache(ctx, dataFn, wantedMediaID) if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error recaching media: %s", err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error recaching media: %w", err)) } // Load attachment and block until complete a, err = processingMedia.LoadAttachment(ctx) if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error loading recached attachment: %s", err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error loading recached attachment: %w", err)) } } @@ -205,17 +205,53 @@ func (p *Processor) getEmojiContent(ctx context.Context, fileName string, owning // for using the static URL rather than full size url // is that static emojis are always encoded as png, // so this is more reliable than using full size url - imageStaticURL := uris.GenerateURIForAttachment(owningAccountID, string(media.TypeEmoji), string(media.SizeStatic), fileName, "png") + imageStaticURL := uris.GenerateURIForAttachment( + owningAccountID, + string(media.TypeEmoji), + string(media.SizeStatic), + fileName, + "png", + ) e, err := p.state.DB.GetEmojiByStaticURL(ctx, imageStaticURL) if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s could not be taken from the db: %s", fileName, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s could not be taken from the db: %w", fileName, err)) } if *e.Disabled { return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s has been disabled", fileName)) } + if !*e.Cached { + // if we don't have it cached, then we can assume two things: + // 1. this is remote emoji, since local emoji should never be uncached + // 2. we need to fetch it again using a transport and the media manager + remoteURL, err := url.Parse(e.ImageRemoteURL) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error parsing remote emoji iri %s: %w", e.ImageRemoteURL, err)) + } + + dataFn := func(ctx context.Context) (io.ReadCloser, int64, error) { + t, err := p.transportController.NewTransportForUsername(ctx, "") + if err != nil { + return nil, 0, err + } + return t.DereferenceMedia(gtscontext.SetFastFail(ctx), remoteURL) + } + + // Start recaching this emoji with the prepared data function. + processingEmoji, err := p.mediaManager.PreProcessEmojiRecache(ctx, dataFn, e.ID) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error recaching emoji: %w", err)) + } + + // Load attachment and block until complete + e, err = processingEmoji.LoadEmoji(ctx) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error loading recached emoji: %w", err)) + } + } + switch emojiSize { case media.SizeOriginal: emojiContent.ContentType = e.ImageContentType diff --git a/internal/processing/report/create.go b/internal/processing/report/create.go index 9faffd2d4..a6cce8e80 100644 --- a/internal/processing/report/create.go +++ b/internal/processing/report/create.go @@ -51,7 +51,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form } // fetch statuses by IDs given in the report form (noop if no statuses given) - statuses, err := p.state.DB.GetStatuses(ctx, form.StatusIDs) + statuses, err := p.state.DB.GetStatusesByIDs(ctx, form.StatusIDs) if err != nil { err = fmt.Errorf("db error fetching report target statuses: %w", err) return nil, gtserror.NewErrorInternalError(err) diff --git a/internal/regexes/regexes.go b/internal/regexes/regexes.go index 88c832508..347a8a98b 100644 --- a/internal/regexes/regexes.go +++ b/internal/regexes/regexes.go @@ -70,7 +70,7 @@ const ( statusesPath = userPathPrefix + `/` + statuses + `/(` + ulid + `)$` blockPath = userPathPrefix + `/` + blocks + `/(` + ulid + `)$` reportPath = `^/?` + reports + `/(` + ulid + `)$` - filePath = `^/?(` + ulid + `)/([a-z]+)/([a-z]+)/(` + ulid + `)\.([a-z]+)$` + filePath = `^/?(` + ulid + `)/([a-z]+)/([a-z]+)/(` + ulid + `)\.([a-z0-9]+)$` ) var ( diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ea8184881..588c586d8 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -97,6 +97,9 @@ func (d *Driver) Has(ctx context.Context, key string) (bool, error) { func (d *Driver) WalkKeys(ctx context.Context, walk func(context.Context, string) error) error { return d.Storage.WalkKeys(ctx, storage.WalkKeysOptions{ WalkFn: func(ctx context.Context, entry storage.Entry) error { + if entry.Key == "store.lock" { + return nil // skip this. + } return walk(ctx, entry.Key) }, }) diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 17b8047e9..03d0bfcab 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -1122,7 +1122,7 @@ func (c *converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo statuses := make([]*apimodel.Status, 0, len(r.StatusIDs)) if len(r.StatusIDs) != 0 && len(r.Statuses) == 0 { - r.Statuses, err = c.db.GetStatuses(ctx, r.StatusIDs) + r.Statuses, err = c.db.GetStatusesByIDs(ctx, r.StatusIDs) if err != nil { return nil, fmt.Errorf("ReportToAdminAPIReport: error getting statuses from the db: %w", err) } diff --git a/test/run-postgres.sh b/test/run-postgres.sh new file mode 100755 index 000000000..54e4970ed --- /dev/null +++ b/test/run-postgres.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +set -e + +DB_NAME='postgres' +DB_USER='postgres' +DB_PASS='postgres' +DB_PORT=5432 + +# Start postgres container +CID=$(docker run --detach \ + --env "POSTGRES_DB=${DB_NAME}" \ + --env "POSTGRES_USER=${DB_USER}" \ + --env "POSTGRES_PASSWORD=${DB_PASS}" \ + --env "POSTGRES_HOST_AUTH_METHOD=trust" \ + --env "PGHOST=0.0.0.0" \ + --env "PGPORT=${DB_PORT}" \ + 'postgres:latest') + +# On exit kill the container +trap "docker kill ${CID}" exit + +sleep 5 +#docker exec "$CID" psql --user "$DB_USER" --password "$DB_PASS" -c "CREATE DATABASE \"${DB_NAME}\" WITH LOCALE \"C.UTF-8\" TEMPLATE \"template0\";" +docker exec "$CID" psql --user "$DB_USER" --password "$DB_PASS" -c "GRANT ALL PRIVILEGES ON DATABASE \"${DB_NAME}\" TO \"${DB_USER}\";" + +# Get running container IP +IP=$(docker container inspect "${CID}" \ + --format '{{ .NetworkSettings.IPAddress }}') + +GTS_DB_TYPE=postgres \ +GTS_DB_ADDRESS=${IP} \ +GTS_DB_PORT=${DB_PORT} \ +GTS_DB_USER=${DB_USER} \ +GTS_DB_PASSWORD=${DB_PASS} \ +GTS_DB_DATABASE=${DB_NAME} \ +go test ./... -p 1 ${@} \ No newline at end of file diff --git a/test/run-sqlite.sh b/test/run-sqlite.sh new file mode 100755 index 000000000..fb5502432 --- /dev/null +++ b/test/run-sqlite.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -e + +GTS_DB_TYPE=sqlite \ +GTS_DB_ADDRESS=':memory:' \ +go test ./... ${@} \ No newline at end of file diff --git a/testrig/testmodels.go b/testrig/testmodels.go index c55b80e60..837869c83 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -1161,6 +1161,7 @@ func NewTestEmojis() map[string]*gtsmodel.Emoji { URI: "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ", VisibleInPicker: TrueBool(), CategoryID: "01GGQ8V4993XK67B2JB396YFB7", + Cached: FalseBool(), }, "yell": { ID: "01GD5KP5CQEE1R3X43Y1EHS2CW", @@ -1183,6 +1184,7 @@ func NewTestEmojis() map[string]*gtsmodel.Emoji { URI: "http://fossbros-anonymous.io/emoji/01GD5KP5CQEE1R3X43Y1EHS2CW", VisibleInPicker: FalseBool(), CategoryID: "", + Cached: FalseBool(), }, } }