mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-30 23:42:25 -05: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
 | |
| }
 |