mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-12-01 09:13:32 -06:00
[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:
parent
3ca7164455
commit
70d65b683f
22 changed files with 413 additions and 74 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 := >smodel.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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 := >smodel.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 = >smodel.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
|
||||
|
|
|
|||
BIN
internal/media/test/gts_pixellated-original.png
Normal file
BIN
internal/media/test/gts_pixellated-original.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
internal/media/test/gts_pixellated-static.png
Normal file
BIN
internal/media/test/gts_pixellated-static.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1,010 B |
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue