mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-11-03 17:32:27 -06:00 
			
		
		
		
	
		
			
				
	
	
		
			402 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			402 lines
		
	
	
	
		
			11 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 "code.superseriousbusiness.org/gotosocial/internal/api/model"
 | 
						|
	"code.superseriousbusiness.org/gotosocial/internal/db"
 | 
						|
	"code.superseriousbusiness.org/gotosocial/internal/gtserror"
 | 
						|
	"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
 | 
						|
	"code.superseriousbusiness.org/gotosocial/internal/media"
 | 
						|
	"code.superseriousbusiness.org/gotosocial/internal/regexes"
 | 
						|
	"code.superseriousbusiness.org/gotosocial/internal/storage"
 | 
						|
	"code.superseriousbusiness.org/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 media size (small, static, original).
 | 
						|
	mediaSize, err := parseSize(form.MediaSize)
 | 
						|
	if err != nil {
 | 
						|
		err := gtserror.Newf("media size %s not valid", form.MediaSize)
 | 
						|
		return nil, gtserror.NewErrorNotFound(err)
 | 
						|
	}
 | 
						|
 | 
						|
	// Parse media type (emoji, header, avatar, attachment).
 | 
						|
	mediaType, err := parseType(form.MediaType)
 | 
						|
	if err != nil {
 | 
						|
		err := gtserror.Newf("media type %s not valid", form.MediaType)
 | 
						|
		return nil, gtserror.NewErrorNotFound(err)
 | 
						|
	}
 | 
						|
 | 
						|
	// Parse media ID from file name.
 | 
						|
	mediaID, _, err := parseFileName(form.FileName)
 | 
						|
	if err != nil {
 | 
						|
		err := gtserror.Newf("media file name %s not valid", form.FileName)
 | 
						|
		return nil, gtserror.NewErrorNotFound(err)
 | 
						|
	}
 | 
						|
 | 
						|
	// Get the account that owns the media
 | 
						|
	// and make sure it's not suspended.
 | 
						|
	acctID := form.AccountID
 | 
						|
	acct, err := p.state.DB.GetAccountByID(ctx, acctID)
 | 
						|
	if err != nil {
 | 
						|
		err := gtserror.Newf("db error getting account %s: %w", acctID, err)
 | 
						|
		return nil, gtserror.NewErrorNotFound(err)
 | 
						|
	}
 | 
						|
 | 
						|
	if acct.IsSuspended() {
 | 
						|
		err := gtserror.Newf("account %s is suspended", acctID)
 | 
						|
		return nil, gtserror.NewErrorNotFound(err)
 | 
						|
	}
 | 
						|
 | 
						|
	// If requester was authenticated, ensure media
 | 
						|
	// owner and requester don't block each other.
 | 
						|
	if requester != nil {
 | 
						|
		blocked, err := p.state.DB.IsEitherBlocked(ctx, requester.ID, acctID)
 | 
						|
		if err != nil {
 | 
						|
			err := gtserror.Newf("db error checking block between %s and %s: %w", acctID, requester.ID, err)
 | 
						|
			return nil, gtserror.NewErrorNotFound(err)
 | 
						|
		}
 | 
						|
 | 
						|
		if blocked {
 | 
						|
			err := gtserror.Newf("block exists between %s and %s", acctID, requester.ID)
 | 
						|
			return nil, gtserror.NewErrorNotFound(err)
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// The way we store emojis is a bit 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,
 | 
						|
			acctID,
 | 
						|
			mediaSize,
 | 
						|
			mediaID,
 | 
						|
		)
 | 
						|
 | 
						|
	case media.TypeAttachment, media.TypeHeader, media.TypeAvatar:
 | 
						|
		return p.getAttachmentContent(ctx,
 | 
						|
			requester,
 | 
						|
			acctID,
 | 
						|
			mediaSize,
 | 
						|
			mediaID,
 | 
						|
		)
 | 
						|
 | 
						|
	default:
 | 
						|
		err := gtserror.Newf("media type %s not recognized", mediaType)
 | 
						|
		return nil, gtserror.NewErrorNotFound(err)
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func (p *Processor) getAttachmentContent(
 | 
						|
	ctx context.Context,
 | 
						|
	requester *gtsmodel.Account,
 | 
						|
	acctID string,
 | 
						|
	sizeStr media.Size,
 | 
						|
	mediaID string,
 | 
						|
) (
 | 
						|
	*apimodel.Content,
 | 
						|
	gtserror.WithCode,
 | 
						|
) {
 | 
						|
	// Get attachment with given ID from the database.
 | 
						|
	attach, err := p.state.DB.GetAttachmentByID(ctx, mediaID)
 | 
						|
	if err != nil && !errors.Is(err, db.ErrNoEntries) {
 | 
						|
		err := gtserror.Newf("db error getting attachment %s: %w", mediaID, err)
 | 
						|
		return nil, gtserror.NewErrorInternalError(err)
 | 
						|
	}
 | 
						|
 | 
						|
	if attach == nil {
 | 
						|
		const text = "media not found"
 | 
						|
		return nil, gtserror.NewErrorNotFound(errors.New(text), text)
 | 
						|
	}
 | 
						|
 | 
						|
	// Ensure the account
 | 
						|
	// actually owns the media.
 | 
						|
	if attach.AccountID != acctID {
 | 
						|
		const text = "media was not owned by passed account id"
 | 
						|
		return nil, gtserror.NewErrorNotFound(errors.New(text) /* no help text! */)
 | 
						|
	}
 | 
						|
 | 
						|
	// Unknown file types indicate no *locally*
 | 
						|
	// stored data we can serve. Handle separately.
 | 
						|
	if attach.Type == gtsmodel.FileTypeUnknown {
 | 
						|
		return handleUnknown(attach)
 | 
						|
	}
 | 
						|
 | 
						|
	// If requester was provided, use their username
 | 
						|
	// to create a transport to potentially re-fetch
 | 
						|
	// the media. Else falls back to instance account.
 | 
						|
	var requestUser string
 | 
						|
	if requester != nil {
 | 
						|
		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{}
 | 
						|
 | 
						|
	// 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,
 | 
						|
	acctID string,
 | 
						|
	sizeStr media.Size,
 | 
						|
	emojiID string,
 | 
						|
) (
 | 
						|
	*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(
 | 
						|
		acctID,
 | 
						|
		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.RecacheEmoji(
 | 
						|
		ctx,
 | 
						|
		emoji,
 | 
						|
	)
 | 
						|
	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 {
 | 
						|
		err := gtserror.Newf("file not found at %s", path)
 | 
						|
		const text = "file not found"
 | 
						|
		return nil, gtserror.NewErrorNotFound(err, text)
 | 
						|
	}
 | 
						|
 | 
						|
	// Return with stream.
 | 
						|
	content.Content = rc
 | 
						|
	return content, nil
 | 
						|
}
 | 
						|
 | 
						|
// handles serving Content for "unknown" file
 | 
						|
// type, ie., a file we couldn't cache (this time).
 | 
						|
func handleUnknown(
 | 
						|
	attach *gtsmodel.MediaAttachment,
 | 
						|
) (*apimodel.Content, gtserror.WithCode) {
 | 
						|
	if attach.RemoteURL == "" {
 | 
						|
		err := gtserror.Newf("empty remote url for %s", attach.ID)
 | 
						|
		return nil, gtserror.NewErrorInternalError(err)
 | 
						|
	}
 | 
						|
 | 
						|
	// Parse media remote URL to valid URL object.
 | 
						|
	remoteURL, err := url.Parse(attach.RemoteURL)
 | 
						|
	if err != nil {
 | 
						|
		err := gtserror.Newf("invalid remote url for %s: %w", attach.ID, err)
 | 
						|
		return nil, gtserror.NewErrorInternalError(err)
 | 
						|
	}
 | 
						|
 | 
						|
	if remoteURL == nil {
 | 
						|
		err := gtserror.Newf("nil remote url for %s", attach.ID)
 | 
						|
		return nil, gtserror.NewErrorInternalError(err)
 | 
						|
	}
 | 
						|
 | 
						|
	// Just forward the request to the remote URL,
 | 
						|
	// since this is a type we couldn't process.
 | 
						|
	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
 | 
						|
}
 | 
						|
 | 
						|
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)
 | 
						|
}
 | 
						|
 | 
						|
// Extract the mediaID and file extension from
 | 
						|
// a string like "01J3CTH8CZ6ATDNMG6CPRC36XE.gif"
 | 
						|
func parseFileName(s string) (string, string, error) {
 | 
						|
	spl := strings.Split(s, ".")
 | 
						|
	if len(spl) != 2 || spl[0] == "" || spl[1] == "" {
 | 
						|
		return "", "", errors.New("file name not splittable on '.'")
 | 
						|
	}
 | 
						|
 | 
						|
	var (
 | 
						|
		mediaID  = spl[0]
 | 
						|
		mediaExt = spl[1]
 | 
						|
	)
 | 
						|
 | 
						|
	if !regexes.ULID.MatchString(mediaID) {
 | 
						|
		return "", "", fmt.Errorf("%s not a valid ULID", mediaID)
 | 
						|
	}
 | 
						|
 | 
						|
	return mediaID, mediaExt, nil
 | 
						|
}
 |