mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-12-22 18:22:11 +00:00
23fc70f4e6
* add support for extracting Updated field from Statusable implementers * add support for status edits in the database, and update status dereferencer to handle them * remove unused AdditionalInfo{}.CreatedAt * remove unused AdditionalEmojiInfo{}.CreatedAt * update new mention creation to use status.UpdatedAt * remove mention.UpdatedAt, fixes related to NewULIDFromTime() change * add migration to remove Mention{}.UpdatedAt field * add migration to add the StatusEdit{} table * start adding tests, add delete function for status edits * add more of status edit migrations, fill in more of the necessary edit delete functionality * remove unused function * allow generating gotosocial compatible ulid via CLI with `go run ./cmd/gen-ulid` * add StatusEdit{} test models * fix new statusedits sql * use model instead of table name * actually remove the Mention.UpdatedAt field... * fix tests now new models are added, add more status edit DB tests * fix panic wording * add test for deleting status edits * don't automatically set `updated_at` field on updated statuses * flesh out more of the dereferencer status edit tests, ensure updated at field set on outgoing AS statuses * remove media_attachments.updated_at column * fix up more tests, further complete the dereferencer status edit tests * update more status serialization tests not expecting 'updated' AS property * gah!! json serialization tests!! * undo some gtscontext wrapping changes * more serialization test fixing 🥲 * more test fixing, ensure the edit.status_id field is actually set 🤦 * fix status edit test * grrr linter * add edited_at field to apimodel status * remove the choice of paging on the timeline public filtered test (otherwise it needs updating every time you add statuses ...) * ensure that status.updated_at always fits chronologically * fix more serialization tests ... * add more code comments * fix envparsing * update swagger file * properly handle media description changes during status edits * slight formatting tweak * code comment
403 lines
11 KiB
Go
403 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 "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/regexes"
|
|
"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 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
|
|
}
|