mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-12-23 10:42:12 +00:00
21bb324156
* start updating media manager interface ready for storing attachments / emoji right away * store emoji and media as uncached immediately, then (re-)cache on Processing{}.Load() * remove now unused media workers * fix tests and issues * fix another test! * fix emoji activitypub uri setting behaviour, fix remainder of test compilation issues * fix more tests * fix (most of) remaining tests, add debouncing to repeatedly failing media / emojis * whoops, rebase issue * remove kim's whacky experiments * do some reshuffling, ensure emoji uri gets set * ensure marked as not cached on cleanup * tweaks to media / emoji processing to handle context canceled better * ensure newly fetched emojis actually get set in returned slice * use different varnames to be a bit more obvious * move emoji refresh rate limiting to dereferencer * add exported dereferencer functions for remote media, use these for recaching in processor * add check for nil attachment in updateAttachment() * remove unused emoji and media fields + columns * see previous commit * fix old migrations expecting image_updated_at to exists (from copies of old models) * remove freshness checking code (seems to be broken...) * fix error arg causing nil ptr exception * finish documentating functions with comments, slight tweaks to media / emoji deref error logic * remove some extra unneeded boolean checking * finish writing documentation (code comments) for exported media manager methods * undo changes to migration snapshot gtsmodels, updated failing migration to have its own snapshot * move doesColumnExist() to util.go in migrations package
360 lines
10 KiB
Go
360 lines
10 KiB
Go
// GoToSocial
|
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
package media
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
|
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
|
"github.com/superseriousbusiness/gotosocial/internal/uris"
|
|
)
|
|
|
|
// GetFile retrieves a file from storage and streams it back
|
|
// to the caller via an io.reader embedded in *apimodel.Content.
|
|
func (p *Processor) GetFile(
|
|
ctx context.Context,
|
|
requester *gtsmodel.Account,
|
|
form *apimodel.GetContentRequestForm,
|
|
) (*apimodel.Content, gtserror.WithCode) {
|
|
// parse the form fields
|
|
mediaSize, err := parseSize(form.MediaSize)
|
|
if err != nil {
|
|
return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not valid", form.MediaSize))
|
|
}
|
|
|
|
mediaType, err := parseType(form.MediaType)
|
|
if err != nil {
|
|
return nil, gtserror.NewErrorNotFound(fmt.Errorf("media type %s not valid", form.MediaType))
|
|
}
|
|
|
|
spl := strings.Split(form.FileName, ".")
|
|
if len(spl) != 2 || spl[0] == "" || spl[1] == "" {
|
|
return nil, gtserror.NewErrorNotFound(fmt.Errorf("file name %s not parseable", form.FileName))
|
|
}
|
|
wantedMediaID := spl[0]
|
|
owningAccountID := form.AccountID
|
|
|
|
// get the account that owns the media and make sure it's not suspended
|
|
owningAccount, err := p.state.DB.GetAccountByID(ctx, owningAccountID)
|
|
if err != nil {
|
|
return nil, gtserror.NewErrorNotFound(fmt.Errorf("account with id %s could not be selected from the db: %s", owningAccountID, err))
|
|
}
|
|
if !owningAccount.SuspendedAt.IsZero() {
|
|
return nil, gtserror.NewErrorNotFound(fmt.Errorf("account with id %s is suspended", owningAccountID))
|
|
}
|
|
|
|
// make sure the requesting account and the media account don't block each other
|
|
if requester != nil {
|
|
blocked, err := p.state.DB.IsEitherBlocked(ctx, requester.ID, owningAccountID)
|
|
if err != nil {
|
|
return nil, gtserror.NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", owningAccountID, requester.ID, err))
|
|
}
|
|
if blocked {
|
|
return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", owningAccountID, requester.ID))
|
|
}
|
|
}
|
|
|
|
// the way we store emojis is a little different from the way we store other attachments,
|
|
// so we need to take different steps depending on the media type being requested
|
|
switch mediaType {
|
|
case media.TypeEmoji:
|
|
return p.getEmojiContent(ctx,
|
|
owningAccountID,
|
|
wantedMediaID,
|
|
mediaSize,
|
|
)
|
|
case media.TypeAttachment, media.TypeHeader, media.TypeAvatar:
|
|
return p.getAttachmentContent(ctx,
|
|
requester,
|
|
owningAccountID,
|
|
wantedMediaID,
|
|
mediaSize,
|
|
)
|
|
default:
|
|
return nil, gtserror.NewErrorNotFound(fmt.Errorf("media type %s not recognized", mediaType))
|
|
}
|
|
}
|
|
|
|
func (p *Processor) getAttachmentContent(
|
|
ctx context.Context,
|
|
requester *gtsmodel.Account,
|
|
ownerID string,
|
|
mediaID string,
|
|
sizeStr media.Size,
|
|
) (
|
|
*apimodel.Content,
|
|
gtserror.WithCode,
|
|
) {
|
|
// Search for media with given ID in the database.
|
|
attach, err := p.state.DB.GetAttachmentByID(ctx, mediaID)
|
|
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
|
err := gtserror.Newf("error fetching media from database: %w", err)
|
|
return nil, gtserror.NewErrorInternalError(err)
|
|
}
|
|
|
|
if attach == nil {
|
|
const text = "media not found"
|
|
return nil, gtserror.NewErrorNotFound(errors.New(text), text)
|
|
}
|
|
|
|
// Ensure the 'owner' owns media.
|
|
if attach.AccountID != ownerID {
|
|
const text = "media was not owned by passed account id"
|
|
return nil, gtserror.NewErrorNotFound(errors.New(text) /* no help text! */)
|
|
}
|
|
|
|
var remoteURL *url.URL
|
|
if attach.RemoteURL != "" {
|
|
|
|
// Parse media remote URL to valid URL object.
|
|
remoteURL, err = url.Parse(attach.RemoteURL)
|
|
if err != nil {
|
|
err := gtserror.Newf("invalid media remote url %s: %w", attach.RemoteURL, err)
|
|
return nil, gtserror.NewErrorInternalError(err)
|
|
}
|
|
}
|
|
|
|
// Uknown file types indicate no *locally*
|
|
// stored data we can serve. Handle separately.
|
|
if attach.Type == gtsmodel.FileTypeUnknown {
|
|
if remoteURL == nil {
|
|
err := gtserror.Newf("missing remote url for unknown type media %s: %w", attach.ID, err)
|
|
return nil, gtserror.NewErrorInternalError(err)
|
|
}
|
|
|
|
// If this is an "Unknown" file type, ie., one we
|
|
// tried to process and couldn't, or one we refused
|
|
// to process because it wasn't supported, then we
|
|
// can skip a lot of steps here by simply forwarding
|
|
// the request to the remote URL.
|
|
url := &storage.PresignedURL{
|
|
URL: remoteURL,
|
|
|
|
// We might manage to cache the media
|
|
// at some point, so set a low-ish expiry.
|
|
Expiry: time.Now().Add(2 * time.Hour),
|
|
}
|
|
|
|
return &apimodel.Content{URL: url}, nil
|
|
}
|
|
|
|
var requestUser string
|
|
|
|
if requester != nil {
|
|
// Set requesting acc username.
|
|
requestUser = requester.Username
|
|
}
|
|
|
|
// Ensure that stored media is cached.
|
|
// (this handles local media / recaches).
|
|
attach, err = p.federator.RefreshMedia(
|
|
ctx,
|
|
requestUser,
|
|
attach,
|
|
media.AdditionalMediaInfo{},
|
|
false,
|
|
)
|
|
if err != nil {
|
|
err := gtserror.Newf("error recaching media: %w", err)
|
|
return nil, gtserror.NewErrorNotFound(err)
|
|
}
|
|
|
|
// Start preparing API content model.
|
|
apiContent := &apimodel.Content{
|
|
ContentUpdated: attach.UpdatedAt,
|
|
}
|
|
|
|
// Retrieve appropriate
|
|
// size file from storage.
|
|
switch sizeStr {
|
|
|
|
case media.SizeOriginal:
|
|
apiContent.ContentType = attach.File.ContentType
|
|
apiContent.ContentLength = int64(attach.File.FileSize)
|
|
return p.getContent(ctx,
|
|
attach.File.Path,
|
|
apiContent,
|
|
)
|
|
|
|
case media.SizeSmall:
|
|
apiContent.ContentType = attach.Thumbnail.ContentType
|
|
apiContent.ContentLength = int64(attach.Thumbnail.FileSize)
|
|
return p.getContent(ctx,
|
|
attach.Thumbnail.Path,
|
|
apiContent,
|
|
)
|
|
|
|
default:
|
|
const text = "invalid media attachment size"
|
|
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
|
|
}
|
|
}
|
|
|
|
func (p *Processor) getEmojiContent(
|
|
ctx context.Context,
|
|
|
|
ownerID string,
|
|
emojiID string,
|
|
sizeStr media.Size,
|
|
) (
|
|
*apimodel.Content,
|
|
gtserror.WithCode,
|
|
) {
|
|
// Reconstruct static emoji image URL to search for it.
|
|
// As refreshed emojis use a newly generated path ID to
|
|
// differentiate them (cache-wise) from the original.
|
|
staticURL := uris.URIForAttachment(
|
|
ownerID,
|
|
string(media.TypeEmoji),
|
|
string(media.SizeStatic),
|
|
emojiID,
|
|
"png",
|
|
)
|
|
|
|
// Search for emoji with given static URL in the database.
|
|
emoji, err := p.state.DB.GetEmojiByStaticURL(ctx, staticURL)
|
|
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
|
err := gtserror.Newf("error fetching emoji from database: %w", err)
|
|
return nil, gtserror.NewErrorInternalError(err)
|
|
}
|
|
|
|
if emoji == nil {
|
|
const text = "emoji not found"
|
|
return nil, gtserror.NewErrorNotFound(errors.New(text), text)
|
|
}
|
|
|
|
if *emoji.Disabled {
|
|
const text = "emoji has been disabled"
|
|
return nil, gtserror.NewErrorNotFound(errors.New(text), text)
|
|
}
|
|
|
|
// Ensure that stored emoji is cached.
|
|
// (this handles local emoji / recaches).
|
|
emoji, err = p.federator.RefreshEmoji(
|
|
ctx,
|
|
emoji,
|
|
media.AdditionalEmojiInfo{},
|
|
false,
|
|
)
|
|
if err != nil {
|
|
err := gtserror.Newf("error recaching emoji: %w", err)
|
|
return nil, gtserror.NewErrorNotFound(err)
|
|
}
|
|
|
|
// Start preparing API content model.
|
|
apiContent := &apimodel.Content{}
|
|
|
|
// Retrieve appropriate
|
|
// size file from storage.
|
|
switch sizeStr {
|
|
|
|
case media.SizeOriginal:
|
|
apiContent.ContentType = emoji.ImageContentType
|
|
apiContent.ContentLength = int64(emoji.ImageFileSize)
|
|
return p.getContent(ctx,
|
|
emoji.ImagePath,
|
|
apiContent,
|
|
)
|
|
|
|
case media.SizeStatic:
|
|
apiContent.ContentType = emoji.ImageStaticContentType
|
|
apiContent.ContentLength = int64(emoji.ImageStaticFileSize)
|
|
return p.getContent(ctx,
|
|
emoji.ImageStaticPath,
|
|
apiContent,
|
|
)
|
|
|
|
default:
|
|
const text = "invalid media attachment size"
|
|
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
|
|
}
|
|
}
|
|
|
|
// getContent performs the final file fetching of
|
|
// stored content at path in storage. This is
|
|
// populated in the apimodel.Content{} and returned.
|
|
// (note: this also handles un-proxied S3 storage).
|
|
func (p *Processor) getContent(
|
|
ctx context.Context,
|
|
path string,
|
|
content *apimodel.Content,
|
|
) (
|
|
*apimodel.Content,
|
|
gtserror.WithCode,
|
|
) {
|
|
// If running on S3 storage with proxying disabled then
|
|
// just fetch pre-signed URL instead of the content.
|
|
if url := p.state.Storage.URL(ctx, path); url != nil {
|
|
content.URL = url
|
|
return content, nil
|
|
}
|
|
|
|
// Fetch file stream for the stored media at path.
|
|
rc, err := p.state.Storage.GetStream(ctx, path)
|
|
if err != nil && !storage.IsNotFound(err) {
|
|
err := gtserror.Newf("error getting file %s from storage: %w", path, err)
|
|
return nil, gtserror.NewErrorInternalError(err)
|
|
}
|
|
|
|
// Ensure found.
|
|
if rc == nil {
|
|
const text = "file not found"
|
|
return nil, gtserror.NewErrorNotFound(errors.New(text), text)
|
|
}
|
|
|
|
// Return with stream.
|
|
content.Content = rc
|
|
return content, nil
|
|
}
|
|
|
|
func parseType(s string) (media.Type, error) {
|
|
switch s {
|
|
case string(media.TypeAttachment):
|
|
return media.TypeAttachment, nil
|
|
case string(media.TypeHeader):
|
|
return media.TypeHeader, nil
|
|
case string(media.TypeAvatar):
|
|
return media.TypeAvatar, nil
|
|
case string(media.TypeEmoji):
|
|
return media.TypeEmoji, nil
|
|
}
|
|
return "", fmt.Errorf("%s not a recognized media.Type", s)
|
|
}
|
|
|
|
func parseSize(s string) (media.Size, error) {
|
|
switch s {
|
|
case string(media.SizeSmall):
|
|
return media.SizeSmall, nil
|
|
case string(media.SizeOriginal):
|
|
return media.SizeOriginal, nil
|
|
case string(media.SizeStatic):
|
|
return media.SizeStatic, nil
|
|
}
|
|
return "", fmt.Errorf("%s not a recognized media.Size", s)
|
|
}
|