Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 75 additions & 10 deletions pkg/api/methods/media_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ package methods
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"os"

"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
Expand Down Expand Up @@ -52,6 +54,16 @@ var imageTypeTags = map[string]string{
"fanart": tags.PropertyTypeTag(tags.TagPropertyImageFanart),
}

type mediaBinaryTooLargeError struct {
path string
size int64
max int64
}

func (e *mediaBinaryTooLargeError) Error() string {
return fmt.Sprintf("media binary %q is too large: %d bytes exceeds %d byte limit", e.path, e.size, e.max)
}

// resolveImageTypeTag converts a short image type name to the full property TypeTag.
func resolveImageTypeTag(t string) (string, bool) {
typeTag, ok := imageTypeTags[t]
Expand Down Expand Up @@ -176,6 +188,57 @@ func imagePrefs(topLevel, itemLevel []string) []string {
return defaultImageTypes
}

func readMediaBinaryFile(path string, maxBytes int64) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat media binary file %q: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("media binary file %q is not a regular file", path)
}
if info.Size() > maxBytes {
return nil, &mediaBinaryTooLargeError{path: path, size: info.Size(), max: maxBytes}
}

f, err := os.Open(path) // #nosec G304 -- path comes from indexed media metadata and is size-checked before read.
if err != nil {
return nil, fmt.Errorf("open media binary file %q: %w", path, err)
}
defer func() {
if closeErr := f.Close(); closeErr != nil {
log.Warn().Err(closeErr).Str("path", path).Msg("media.image: failed to close image file")
}
}()

data, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
if err != nil {
return nil, fmt.Errorf("read media binary file %q: %w", path, err)
}
if int64(len(data)) > maxBytes {
return nil, &mediaBinaryTooLargeError{path: path, size: int64(len(data)), max: maxBytes}
}
return data, nil
}

func mediaImageReadError(path string, err error) error {
var tooLarge *mediaBinaryTooLargeError
if errors.As(err, &tooLarge) {
return models.ClientErrf("media.image: image file too large: %s", tooLarge.Error())
}
return fmt.Errorf("media.image: read image file %q: %w", path, err)
}

func propertyBlobTooLarge(prop *database.MediaProperty) bool {
return prop.BlobDBID != nil && len(prop.Binary) == 0 && prop.BlobSize > database.MaxMediaPropertyBinaryBytes
}

func mediaImageBlobTooLargeError(prop *database.MediaProperty) error {
return models.ClientErrf(
"media.image: image blob too large for %s: %d bytes exceeds %d byte limit",
prop.TypeTag, prop.BlobSize, database.MaxMediaPropertyBinaryBytes,
)
}

func selectMediaImage(
ctx context.Context,
db database.MediaDBI,
Expand Down Expand Up @@ -220,14 +283,15 @@ func selectMediaImage(
}

binary := prop.Binary
if len(binary) == 0 && propertyBlobTooLarge(&prop) {
return models.MediaImageResponse{}, mediaImageBlobTooLargeError(&prop)
}
if len(binary) == 0 && prop.Text != "" {
var readErr error
binary, readErr = os.ReadFile(prop.Text)
binary, readErr = readMediaBinaryFile(prop.Text, database.MaxMediaPropertyBinaryBytes)
if readErr != nil {
if !os.IsNotExist(readErr) {
return models.MediaImageResponse{}, fmt.Errorf(
"media.image: read image file %q: %w", prop.Text, readErr,
)
if !errors.Is(readErr, os.ErrNotExist) {
return models.MediaImageResponse{}, mediaImageReadError(prop.Text, readErr)
}

// File is gone — remove the stale property.
Expand All @@ -252,13 +316,14 @@ func selectMediaImage(
}
prop = titleProp
binary = prop.Binary
if len(binary) == 0 && propertyBlobTooLarge(&prop) {
return models.MediaImageResponse{}, mediaImageBlobTooLargeError(&prop)
}
if len(binary) == 0 && prop.Text != "" {
binary, readErr = os.ReadFile(prop.Text)
binary, readErr = readMediaBinaryFile(prop.Text, database.MaxMediaPropertyBinaryBytes)
if readErr != nil {
if !os.IsNotExist(readErr) {
return models.MediaImageResponse{}, fmt.Errorf(
"media.image: read image file %q: %w", prop.Text, readErr,
)
if !errors.Is(readErr, os.ErrNotExist) {
return models.MediaImageResponse{}, mediaImageReadError(prop.Text, readErr)
}

delErr := db.DeleteMediaTitleProperty(ctx, row.Title.DBID, prop.TypeTagDBID)
Expand Down
60 changes: 60 additions & 0 deletions pkg/api/methods/media_image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,3 +467,63 @@ func TestHandleMediaImage_FileReadError_FallsBackToNextPref(t *testing.T) {
assert.Equal(t, screenshotData, decoded)
mockDB.AssertExpectations(t)
}

func TestHandleMediaImage_OversizedFileReturnsClientError(t *testing.T) {
t.Parallel()

dir := t.TempDir()
largePath := filepath.Join(dir, "large_boxart.png")
file, err := os.Create(largePath) // #nosec G304 -- test path is created under t.TempDir().
require.NoError(t, err)
require.NoError(t, file.Truncate(database.MaxMediaPropertyBinaryBytes+1))
require.NoError(t, file.Close())

mockDB := testhelpers.NewMockMediaDBI()
row := makeMediaFullRow(9, 90)
expectMediaImageResolve(mockDB, row)
mockDB.On("GetMediaProperties", mock.Anything, int64(9)).
Return([]database.MediaProperty{}, nil)
mockDB.On("GetMediaTitleProperties", mock.Anything, int64(90)).
Return([]database.MediaProperty{{
TypeTag: "property:image-boxart",
ContentType: "image/png",
Text: largePath,
TypeTagDBID: 88,
}}, nil)

env := makeMediaImageEnv(t, mockDB, mediaImageParams(row, `"imageTypes": ["boxart"]`))
_, err = HandleMediaImage(env)
require.Error(t, err)

var clientErr *models.ClientError
require.ErrorAs(t, err, &clientErr)
assert.Contains(t, err.Error(), "image file too large")
mockDB.AssertExpectations(t)
}

func TestHandleMediaImage_OversizedBlobReturnsClientError(t *testing.T) {
t.Parallel()

mockDB := testhelpers.NewMockMediaDBI()
row := makeMediaFullRow(10, 100)
blobID := int64(123)
expectMediaImageResolve(mockDB, row)
mockDB.On("GetMediaProperties", mock.Anything, int64(10)).
Return([]database.MediaProperty{}, nil)
mockDB.On("GetMediaTitleProperties", mock.Anything, int64(100)).
Return([]database.MediaProperty{{
BlobDBID: &blobID,
TypeTag: "property:image-boxart",
ContentType: "image/png",
BlobSize: database.MaxMediaPropertyBinaryBytes + 1,
}}, nil)

env := makeMediaImageEnv(t, mockDB, mediaImageParams(row, `"imageTypes": ["boxart"]`))
_, err := HandleMediaImage(env)
require.Error(t, err)

var clientErr *models.ClientError
require.ErrorAs(t, err, &clientErr)
assert.Contains(t, err.Error(), "image blob too large")
mockDB.AssertExpectations(t)
}
38 changes: 37 additions & 1 deletion pkg/api/methods/media_meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ func TestHandleMediaMeta_BinaryPropertyBase64Encoded(t *testing.T) {
mockDB.On("GetMediaTitleTagsByMediaTitleDBID", mock.Anything, int64(30)).Return([]database.TagInfo{}, nil)
mockDB.On("GetMediaProperties", mock.Anything, int64(3)).
Return([]database.MediaProperty{
{TypeTag: "property:image", ContentType: "image/png", Binary: blobData},
{TypeTag: "property:image", ContentType: "image/png", Binary: blobData, BlobSize: int64(len(blobData))},
}, nil)
mockDB.On("GetMediaTitleProperties", mock.Anything, int64(30)).Return([]database.MediaProperty{}, nil)

Expand All @@ -165,6 +165,42 @@ func TestHandleMediaMeta_BinaryPropertyBase64Encoded(t *testing.T) {
mockDB.AssertExpectations(t)
}

func TestHandleMediaMeta_OversizedBinaryPropertyDataOmitted(t *testing.T) {
t.Parallel()

mockDB := testhelpers.NewMockMediaDBI()
blobID := int64(99)

row := makeMediaFullRow(4, 40)
expectMediaMetaResolve(mockDB, row)
mockDB.On("GetMediaTagsByMediaDBID", mock.Anything, int64(4)).Return([]database.TagInfo{}, nil)
mockDB.On("GetMediaTitleTagsByMediaTitleDBID", mock.Anything, int64(40)).Return([]database.TagInfo{}, nil)
mockDB.On("GetMediaProperties", mock.Anything, int64(4)).
Return([]database.MediaProperty{
{
TypeTag: "property:image",
ContentType: "image/png",
BlobDBID: &blobID,
BlobSize: database.MaxMediaPropertyBinaryBytes + 1,
},
}, nil)
mockDB.On("GetMediaTitleProperties", mock.Anything, int64(40)).Return([]database.MediaProperty{}, nil)

env := makeMediaMetaEnv(t, mockDB, mediaMetaParams(row))
result, err := HandleMediaMeta(env)
require.NoError(t, err)

resp, ok := result.(models.MediaMetaResponse)
require.True(t, ok)
require.Contains(t, resp.Media.Properties, "property:image")
prop := resp.Media.Properties["property:image"]
assert.Nil(t, prop.Data)
assert.Equal(t, "image/png", prop.ContentType)
assert.NotNil(t, prop.Extension)
assert.Equal(t, "png", *prop.Extension)
mockDB.AssertExpectations(t)
}

func TestHandleMediaMeta_BatchByMediaIDPartialSuccess(t *testing.T) {
t.Parallel()

Expand Down
16 changes: 12 additions & 4 deletions pkg/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ type TagType struct {
IsExclusive bool
}

// MaxMediaPropertyBinaryBytes caps decoded binary property payloads hydrated
// into memory. Larger blobs remain addressable via BlobDBID/BlobSize but Binary
// is left nil so API handlers can return a controlled error instead of OOMing.
const MaxMediaPropertyBinaryBytes = 16 * 1024 * 1024

// MediaProperty is a static content property attached to a MediaTitle or Media
// record. Properties are fetched for display, not filtered by value.
//
Expand All @@ -174,15 +179,16 @@ type TagType struct {
// To associate binary data with a property, call UpsertMediaBlob first to obtain
// a BlobDBID, then set that field before upserting the property.
// For reads: TypeTag is populated from the joined Tags row; TypeTagDBID is also
// set. ContentType and Binary are hydrated from the MediaBlobs JOIN and are
// read-only — do not set them for writes.
// set. ContentType, BlobSize, and Binary are hydrated from the MediaBlobs JOIN
// and are read-only — do not set them for writes.
type MediaProperty struct {
BlobDBID *int64
TypeTag string
Text string
ContentType string
Binary []byte
TypeTagDBID int64
BlobSize int64
}

// MediaBlob is a row from the MediaBlobs content-addressed store.
Expand Down Expand Up @@ -737,14 +743,16 @@ type MediaDBI interface {
FindMediaTitleBySystemAndSlug(ctx context.Context, systemDBID int64, slug string) (*MediaTitle, error)

// GetMediaTitleProperties returns all properties for a MediaTitle row,
// with TypeTagDBID resolved to the tag value string.
// with TypeTagDBID resolved to the tag value string. Binary blobs are capped
// at MaxMediaPropertyBinaryBytes; larger blobs populate BlobSize but not Binary.
GetMediaTitleProperties(ctx context.Context, mediaTitleDBID int64) ([]MediaProperty, error)
GetMediaTitlePropertiesByMediaTitleDBIDs(
ctx context.Context, mediaTitleDBIDs []int64,
) (map[int64][]MediaProperty, error)

// GetMediaProperties returns all properties for a Media row,
// with TypeTagDBID resolved to the tag value string.
// with TypeTagDBID resolved to the tag value string. Binary blobs are capped
// at MaxMediaPropertyBinaryBytes; larger blobs populate BlobSize but not Binary.
GetMediaProperties(ctx context.Context, mediaDBID int64) ([]MediaProperty, error)
GetMediaPropertiesByMediaDBIDs(ctx context.Context, mediaDBIDs []int64) (map[int64][]MediaProperty, error)

Expand Down
Loading
Loading