[feature] Refetch emojis when they change on remote instances (#905)

* select emoji using image_static_url

* use updated on AP emojis

* allow refetch of updated emojis

* cheeky workaround for test

* clean up old files for refreshed emoji

* check error for originalPostData

* shorten GetEmojiByStaticImageURL

* delete kirby (sorry nintendo)
This commit is contained in:
tobi 2022-10-13 15:16:24 +02:00 committed by GitHub
commit 70d65b683f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 413 additions and 74 deletions

View file

@ -69,7 +69,9 @@ type Manager interface {
// uri is the ActivityPub URI/ID of the emoji.
//
// ai is optional and can be nil. Any additional information about the emoji provided will be put in the database.
ProcessEmoji(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo) (*ProcessingEmoji, error)
//
// If refresh is true, this indicates that the emoji image has changed and should be updated.
ProcessEmoji(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo, refresh bool) (*ProcessingEmoji, error)
// RecacheMedia refetches, reprocesses, and recaches an existing attachment that has been uncached via pruneRemote.
RecacheMedia(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, attachmentID string) (*ProcessingMedia, error)
@ -164,8 +166,8 @@ func (m *manager) ProcessMedia(ctx context.Context, data DataFunc, postData Post
return processingMedia, nil
}
func (m *manager) ProcessEmoji(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo) (*ProcessingEmoji, error) {
processingEmoji, err := m.preProcessEmoji(ctx, data, postData, shortcode, id, uri, ai)
func (m *manager) ProcessEmoji(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo, refresh bool) (*ProcessingEmoji, error) {
processingEmoji, err := m.preProcessEmoji(ctx, data, postData, shortcode, id, uri, ai, refresh)
if err != nil {
return nil, err
}

View file

@ -55,7 +55,7 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlocking() {
emojiID := "01GDQ9G782X42BAMFASKP64343"
emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343"
processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, nil, "rainbow_test", emojiID, emojiURI, nil)
processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, nil, "rainbow_test", emojiID, emojiURI, nil, false)
suite.NoError(err)
// do a blocking call to fetch the emoji
@ -101,6 +101,99 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlocking() {
suite.Equal(processedStaticBytesExpected, processedStaticBytes)
}
func (suite *ManagerTestSuite) TestEmojiProcessBlockingRefresh() {
ctx := context.Background()
// we're going to 'refresh' the remote 'yell' emoji by changing the image url to the pixellated gts logo
originalEmoji := suite.testEmojis["yell"]
emojiToUpdate := &gtsmodel.Emoji{}
*emojiToUpdate = *originalEmoji
newImageRemoteURL := "http://fossbros-anonymous.io/some/image/path.png"
oldEmojiImagePath := emojiToUpdate.ImagePath
oldEmojiImageStaticPath := emojiToUpdate.ImageStaticPath
data := func(_ context.Context) (io.Reader, int64, error) {
b, err := os.ReadFile("./test/gts_pixellated-original.png")
if err != nil {
panic(err)
}
return bytes.NewBuffer(b), int64(len(b)), nil
}
emojiID := emojiToUpdate.ID
emojiURI := emojiToUpdate.URI
processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, nil, "yell", emojiID, emojiURI, &media.AdditionalEmojiInfo{
CreatedAt: &emojiToUpdate.CreatedAt,
Domain: &emojiToUpdate.Domain,
ImageRemoteURL: &newImageRemoteURL,
}, true)
suite.NoError(err)
// do a blocking call to fetch the emoji
emoji, err := processingEmoji.LoadEmoji(ctx)
suite.NoError(err)
suite.NotNil(emoji)
// make sure it's got the stuff set on it that we expect
suite.Equal(emojiID, emoji.ID)
// file meta should be correctly derived from the image
suite.Equal("image/png", emoji.ImageContentType)
suite.Equal("image/png", emoji.ImageStaticContentType)
suite.Equal(10296, emoji.ImageFileSize)
// now make sure the emoji is in the database
dbEmoji, err := suite.db.GetEmojiByID(ctx, emojiID)
suite.NoError(err)
suite.NotNil(dbEmoji)
// make sure the processed emoji file is in storage
processedFullBytes, err := suite.storage.Get(ctx, emoji.ImagePath)
suite.NoError(err)
suite.NotEmpty(processedFullBytes)
// load the processed bytes from our test folder, to compare
processedFullBytesExpected, err := os.ReadFile("./test/gts_pixellated-original.png")
suite.NoError(err)
suite.NotEmpty(processedFullBytesExpected)
// the bytes in storage should be what we expected
suite.Equal(processedFullBytesExpected, processedFullBytes)
// now do the same for the thumbnail and make sure it's what we expected
processedStaticBytes, err := suite.storage.Get(ctx, emoji.ImageStaticPath)
suite.NoError(err)
suite.NotEmpty(processedStaticBytes)
processedStaticBytesExpected, err := os.ReadFile("./test/gts_pixellated-static.png")
suite.NoError(err)
suite.NotEmpty(processedStaticBytesExpected)
suite.Equal(processedStaticBytesExpected, processedStaticBytes)
// most fields should be different on the emoji now from what they were before
suite.Equal(originalEmoji.ID, dbEmoji.ID)
suite.NotEqual(originalEmoji.ImageRemoteURL, dbEmoji.ImageRemoteURL)
suite.NotEqual(originalEmoji.ImageURL, dbEmoji.ImageURL)
suite.NotEqual(originalEmoji.ImageStaticURL, dbEmoji.ImageStaticURL)
suite.NotEqual(originalEmoji.ImageFileSize, dbEmoji.ImageFileSize)
suite.NotEqual(originalEmoji.ImageStaticFileSize, dbEmoji.ImageStaticFileSize)
suite.NotEqual(originalEmoji.ImagePath, dbEmoji.ImagePath)
suite.NotEqual(originalEmoji.ImageStaticPath, dbEmoji.ImageStaticPath)
suite.NotEqual(originalEmoji.ImageStaticPath, dbEmoji.ImageStaticPath)
suite.NotEqual(originalEmoji.UpdatedAt, dbEmoji.UpdatedAt)
suite.NotEqual(originalEmoji.ImageUpdatedAt, dbEmoji.ImageUpdatedAt)
// the old image files should no longer be in storage
_, err = suite.storage.Get(ctx, oldEmojiImagePath)
suite.ErrorIs(err, storage.ErrNotFound)
_, err = suite.storage.Get(ctx, oldEmojiImageStaticPath)
suite.ErrorIs(err, storage.ErrNotFound)
}
func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLarge() {
ctx := context.Background()
@ -116,7 +209,7 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLarge() {
emojiID := "01GDQ9G782X42BAMFASKP64343"
emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343"
processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, nil, "big_panda", emojiID, emojiURI, nil)
processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, nil, "big_panda", emojiID, emojiURI, nil, false)
suite.NoError(err)
// do a blocking call to fetch the emoji
@ -140,7 +233,7 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLargeNoSizeGiven() {
emojiID := "01GDQ9G782X42BAMFASKP64343"
emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343"
processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, nil, "big_panda", emojiID, emojiURI, nil)
processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, nil, "big_panda", emojiID, emojiURI, nil, false)
suite.NoError(err)
// do a blocking call to fetch the emoji
@ -165,7 +258,7 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingNoFileSizeGiven() {
emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343"
// process the media with no additional info provided
processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, nil, "rainbow_test", emojiID, emojiURI, nil)
processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, nil, "rainbow_test", emojiID, emojiURI, nil, false)
suite.NoError(err)
// do a blocking call to fetch the emoji

View file

@ -35,6 +35,7 @@ type MediaStandardTestSuite struct {
manager media.Manager
testAttachments map[string]*gtsmodel.MediaAttachment
testAccounts map[string]*gtsmodel.Account
testEmojis map[string]*gtsmodel.Emoji
}
func (suite *MediaStandardTestSuite) SetupSuite() {
@ -50,6 +51,7 @@ func (suite *MediaStandardTestSuite) SetupTest() {
testrig.StandardDBSetup(suite.db, nil)
suite.testAttachments = testrig.NewTestAttachments()
suite.testAccounts = testrig.NewTestAccounts()
suite.testEmojis = testrig.NewTestEmojis()
suite.manager = testrig.NewTestMediaManager(suite.db, suite.storage)
}

View file

@ -21,6 +21,7 @@ package media
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
@ -28,9 +29,11 @@ import (
"sync/atomic"
"time"
gostore "codeberg.org/gruf/go-store/storage"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/internal/uris"
@ -71,6 +74,11 @@ type ProcessingEmoji struct {
// track whether this emoji has already been put in the databse
insertedInDB bool
// is this a refresh of an existing emoji?
refresh bool
// if it is a refresh, which alternate ID should we use in the storage and URL paths?
newPathID string
}
// EmojiID returns the ID of the underlying emoji without blocking processing.
@ -94,8 +102,28 @@ func (p *ProcessingEmoji) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error
// store the result in the database before returning it
if !p.insertedInDB {
if err := p.database.PutEmoji(ctx, p.emoji); err != nil {
return nil, err
if p.refresh {
columns := []string{
"updated_at",
"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",
"uri",
}
if _, err := p.database.UpdateEmoji(ctx, p.emoji, columns...); err != nil {
return nil, err
}
} else {
if err := p.database.PutEmoji(ctx, p.emoji); err != nil {
return nil, err
}
}
p.insertedInDB = true
}
@ -203,8 +231,14 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
// set some additional fields on the emoji now that
// we know more about what the underlying image actually is
p.emoji.ImageURL = uris.GenerateURIForAttachment(p.instanceAccountID, string(TypeEmoji), string(SizeOriginal), p.emoji.ID, extension)
p.emoji.ImagePath = fmt.Sprintf("%s/%s/%s/%s.%s", p.instanceAccountID, TypeEmoji, SizeOriginal, p.emoji.ID, extension)
var pathID string
if p.refresh {
pathID = p.newPathID
} else {
pathID = p.emoji.ID
}
p.emoji.ImageURL = uris.GenerateURIForAttachment(p.instanceAccountID, string(TypeEmoji), string(SizeOriginal), pathID, extension)
p.emoji.ImagePath = fmt.Sprintf("%s/%s/%s/%s.%s", p.instanceAccountID, TypeEmoji, SizeOriginal, pathID, extension)
p.emoji.ImageContentType = contentType
// concatenate the first bytes with the existing bytes still in the reader (thanks Mara)
@ -251,39 +285,87 @@ func (p *ProcessingEmoji) store(ctx context.Context) error {
return nil
}
func (m *manager) preProcessEmoji(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo) (*ProcessingEmoji, error) {
func (m *manager) preProcessEmoji(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, shortcode string, emojiID string, uri string, ai *AdditionalEmojiInfo, refresh bool) (*ProcessingEmoji, error) {
instanceAccount, err := m.db.GetInstanceAccount(ctx, "")
if err != nil {
return nil, fmt.Errorf("preProcessEmoji: error fetching this instance account from the db: %s", err)
}
disabled := false
visibleInPicker := true
var newPathID string
var emoji *gtsmodel.Emoji
if refresh {
emoji, err = m.db.GetEmojiByID(ctx, emojiID)
if err != nil {
return nil, fmt.Errorf("preProcessEmoji: error fetching emoji to refresh from the db: %s", err)
}
// populate initial fields on the emoji -- some of these will be overwritten as we proceed
emoji := &gtsmodel.Emoji{
ID: id,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Shortcode: 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), id, 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, id, 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,
ImageUpdatedAt: time.Now(),
Disabled: &disabled,
URI: uri,
VisibleInPicker: &visibleInPicker,
CategoryID: "",
// if this is a refresh, we will end up with new images
// stored for this emoji, so we can use the postData function
// to perform clean up of the old images from storage
originalPostData := postData
originalImagePath := emoji.ImagePath
originalImageStaticPath := emoji.ImageStaticPath
postData = func(ctx context.Context) error {
// trigger the original postData function if it was provided
if originalPostData != nil {
if err := originalPostData(ctx); err != nil {
return err
}
}
l := log.WithField("shortcode@domain", emoji.Shortcode+"@"+emoji.Domain)
l.Debug("postData: cleaning up old emoji files for refreshed emoji")
if err := m.storage.Delete(ctx, originalImagePath); err != nil && !errors.Is(err, gostore.ErrNotFound) {
l.Errorf("postData: error cleaning up old emoji image at %s for refreshed emoji: %s", originalImagePath, err)
}
if err := m.storage.Delete(ctx, originalImageStaticPath); err != nil && !errors.Is(err, gostore.ErrNotFound) {
l.Errorf("postData: error cleaning up old emoji static image at %s for refreshed emoji: %s", originalImageStaticPath, err)
}
return nil
}
newPathID, err = id.NewRandomULID()
if err != nil {
return nil, fmt.Errorf("preProcessEmoji: error generating alternateID for emoji refresh: %s", err)
}
// 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)
// update these fields as we go
emoji.URI = uri
} else {
disabled := false
visibleInPicker := true
// populate initial fields on the emoji -- some of these will be overwritten as we proceed
emoji = &gtsmodel.Emoji{
ID: emojiID,
CreatedAt: time.Now(),
Shortcode: 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
ImageFileSize: 0,
ImageStaticFileSize: 0,
Disabled: &disabled,
URI: uri,
VisibleInPicker: &visibleInPicker,
CategoryID: "",
}
}
emoji.ImageUpdatedAt = time.Now()
emoji.UpdatedAt = time.Now()
// check if we have additional info to add to the emoji,
// and overwrite some of the emoji fields if so
if ai != nil {
@ -324,6 +406,8 @@ func (m *manager) preProcessEmoji(ctx context.Context, data DataFunc, postData P
staticState: int32(received),
database: m.db,
storage: m.storage,
refresh: refresh,
newPathID: newPathID,
}
return processingEmoji, nil

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,010 B

View file

@ -98,8 +98,8 @@ type AdditionalMediaInfo struct {
FocusY *float32
}
// AdditionalMediaInfo represents additional information
// that should be added to an emoji when processing it.
// AdditionalEmojiInfo represents additional information
// that should be taken into account when processing an emoji.
type AdditionalEmojiInfo struct {
// Time that this emoji was created; defaults to time.Now().
CreatedAt *time.Time