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
68 changes: 68 additions & 0 deletions blob/dynamodb/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

blobpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/blob/v1"
commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1"
moderationpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/moderation/v1"

"github.com/code-payments/flipcash2-server/blob"
)
Expand All @@ -39,6 +40,9 @@ const (
attrImageBlurhash = "image_blurhash" // S, present only on READY images
attrExpiresAt = "expires_at" // N, Unix seconds; TTL on non-READY blobs

attrRejectionReason = "rejection_reason" // N, present only on REJECTED blobs
attrFlaggedCategory = "flagged_category" // N, present only on REJECTED-by-moderation blobs

renditionsByParentGSI = "renditions_by_parent"

blobKeyPrefix = "blob#"
Expand Down Expand Up @@ -191,6 +195,10 @@ func (s *store) GetRenditions(ctx context.Context, parentID *blobpb.BlobId) ([]*
}

func (s *store) Advance(ctx context.Context, id *blobpb.BlobId, to blob.State, image *blob.ImageMetadata) (bool, error) {
if to == blob.StateRejected {
return false, blob.ErrCannotAdvanceToRejected
}

names := map[string]string{"#state": attrState}
values := map[string]types.AttributeValue{
":to": avInt(int(to)),
Expand Down Expand Up @@ -238,6 +246,50 @@ func (s *store) Advance(ctx context.Context, id *blobpb.BlobId, to blob.State, i
return true, nil
}

func (s *store) Reject(ctx context.Context, id *blobpb.BlobId, rejection *blob.RejectionMetadata) (bool, error) {
names := map[string]string{"#state": attrState}
values := map[string]types.AttributeValue{
":to": avInt(int(blob.StateRejected)),
":ready": avInt(int(blob.StateReady)),
":rejected": avInt(int(blob.StateRejected)),
}

update := "SET #state = :to"
if rejection != nil {
update += fmt.Sprintf(", %s = :reason, %s = :cat", attrRejectionReason, attrFlaggedCategory)
values[":reason"] = avInt(int(rejection.Reason))
values[":cat"] = avInt(int(rejection.FlaggedCategory))
}
// REJECTED keeps the TTL set at creation: a rejected record is a tombstone the
// client can read for the reason, then DynamoDB reclaims it. Only READY clears
// the TTL (in Advance).

_, err := s.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{
TableName: aws.String(s.table),
Key: map[string]types.AttributeValue{attrPK: avS(blobPK(id))},
UpdateExpression: aws.String(update),
// Reject only a non-terminal blob; never overwrite a terminal state.
ConditionExpression: aws.String(fmt.Sprintf("attribute_exists(%s) AND #state <> :ready AND #state <> :rejected", attrPK)),
ExpressionAttributeNames: names,
ExpressionAttributeValues: values,
// Distinguish "no such blob" from "already terminal" on failure.
ReturnValuesOnConditionCheckFailure: types.ReturnValuesOnConditionCheckFailureAllOld,
})
if err != nil {
var ccf *types.ConditionalCheckFailedException
if errors.As(err, &ccf) {
// No old item means the blob does not exist; otherwise it was already
// terminal and rejecting is an idempotent no-op.
if len(ccf.Item) == 0 {
return false, blob.ErrNotFound
}
return false, nil
}
return false, err
}
return true, nil
}

func toItem(b *blob.Blob) map[string]types.AttributeValue {
item := map[string]types.AttributeValue{
attrPK: avS(blobPK(b.ID)),
Expand Down Expand Up @@ -316,6 +368,22 @@ func fromItem(item map[string]types.AttributeValue) (*blob.Blob, error) {
}
}

if _, ok := item[attrRejectionReason]; ok {
reason, err := intAttr(item, attrRejectionReason)
if err != nil {
return nil, err
}
b.Rejection = &blob.RejectionMetadata{Reason: blob.RejectionReason(reason)}
// flagged_category is present only for a moderation rejection.
if _, ok := item[attrFlaggedCategory]; ok {
category, err := intAttr(item, attrFlaggedCategory)
if err != nil {
return nil, err
}
b.Rejection.FlaggedCategory = moderationpb.FlaggedCategory(category)
}
}

return b, nil
}

Expand Down
48 changes: 42 additions & 6 deletions blob/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package blob
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"image"
"image/jpeg"
Expand All @@ -20,6 +21,22 @@ import (
_ "golang.org/x/image/webp"
)

// InspectImage failure categories. They are wrapped into the descriptive error
// it returns so finalization can classify a rejection into the right
// RejectionReason without re-deriving why the bytes were unacceptable. A failure
// carrying none of these is an internal processing fault.
var (
// ErrImageCorrupt means the bytes could not be read or decoded as an image.
ErrImageCorrupt = errors.New("image is corrupt or undecodable")

// ErrImageUnsupportedType means the bytes are a kind the service does not
// accept — an unsupported format, or an animated image.
ErrImageUnsupportedType = errors.New("unsupported image type")

// ErrImageTooLarge means the image's pixel dimensions exceed the limits.
ErrImageTooLarge = errors.New("image exceeds dimension limits")
)

const (
// blurhashComponentsX and blurhashComponentsY control the BlurHash detail. A
// symmetric 4x4 is aspect-agnostic — a sensible neutral default for images of
Expand All @@ -40,6 +57,22 @@ const (
// starve the short axis (e.g. a 10:1 panorama → 64x6).
blurhashMinShortDimension = 16

// MaxOriginalImageSizeBytes bounds the declared size of an ORIGINAL image upload.
// It is pinned into the upload policy, so storage rejects anything larger before a
// single byte lands.
MaxOriginalImageSizeBytes = 8 * 1024 * 1024 // 8 MiB

// maxImageDimension bounds an image's width and height. It is set to the format
// ceiling — the largest dimension JPEG and GIF can even encode (their size
// fields are 16-bit); PNG permits more but the pixel cap below bounds it, and
// WebP is lower still at 16,383. So this is only a format-sanity bound, not a
// real resource limit: the total pixel cap is the actual memory guard, and
// unlike a per-axis limit it judges an image by its area, not its shape — so an
// extreme aspect ratio (a wide panorama, a long screenshot) is accepted as long
// as it fits the pixel budget, never rejected for its shape alone. Checked from
// the header before the full image is decoded.
maxImageDimension = 65_535

// maxImagePixels bounds the total pixel count (width × height) the server will
// decode. It is checked from the image header via DecodeConfig before the full
// image is decoded, so a small compressed file cannot expand into an enormous
Expand Down Expand Up @@ -95,32 +128,35 @@ func InspectImage(data []byte) (*ImageInspection, error) {
// declares enormous dimensions.
config, headerFormat, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to read image header: %w", err)
return nil, fmt.Errorf("failed to read image header: %v: %w", err, ErrImageCorrupt)
}
if int64(config.Width)*int64(config.Height) > maxImagePixels {
return nil, fmt.Errorf("image dimensions %dx%d exceed the %d pixel limit", config.Width, config.Height, maxImagePixels)
return nil, fmt.Errorf("image dimensions %dx%d exceed the %d pixel limit: %w", config.Width, config.Height, maxImagePixels, ErrImageTooLarge)
}
if config.Width > maxImageDimension || config.Height > maxImageDimension {
return nil, fmt.Errorf("image dimensions %dx%d exceed the %d per-axis limit: %w", config.Width, config.Height, maxImageDimension, ErrImageTooLarge)
}
// Reject animated images: only the first frame would be inspected and
// moderated, but the whole animation would be served — a moderation bypass.
// Detected from the container structure, so no extra frames are decoded.
if isImageAnimated(headerFormat, data) {
return nil, fmt.Errorf("animated %s images are not supported", headerFormat)
return nil, fmt.Errorf("animated %s images are not supported: %w", headerFormat, ErrImageUnsupportedType)
}

img, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
return nil, fmt.Errorf("failed to decode image: %v: %w", err, ErrImageCorrupt)
}

mimeType, ok := imageFormatToMimeType[format]
if !ok {
return nil, fmt.Errorf("unsupported image format %q", format)
return nil, fmt.Errorf("unsupported image format %q: %w", format, ErrImageUnsupportedType)
}

bounds := img.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width <= 0 || height <= 0 {
return nil, fmt.Errorf("image has invalid dimensions %dx%d", width, height)
return nil, fmt.Errorf("image has invalid dimensions %dx%d: %w", width, height, ErrImageCorrupt)
}

hash, err := blurhash.Encode(blurhashComponentsX, blurhashComponentsY, downscaleForBlurhash(img))
Expand Down
23 changes: 23 additions & 0 deletions blob/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,29 @@ func TestIsImageAnimated(t *testing.T) {
}
}

func TestInspectImageAcceptsExtremeAspectRatio(t *testing.T) {
// A wide, panorama-style image: comfortably under the pixel cap but far past
// any per-axis ceiling tied to a normal photo's dimensions. It must be judged
// on area, not shape, so it is accepted.
var buf bytes.Buffer
require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 13000, 100))))

inspection, err := InspectImage(buf.Bytes())
require.NoError(t, err)
require.EqualValues(t, 13000, inspection.Metadata.Width)
require.EqualValues(t, 100, inspection.Metadata.Height)
}

func TestInspectImageRejectsOversizedDimensions(t *testing.T) {
// A 1px-tall strip is cheap in total pixels (so it clears the pixel-count cap)
// but exceeds the per-axis format ceiling, isolating the dimension check.
var buf bytes.Buffer
require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, maxImageDimension+1, 1))))

_, err := InspectImage(buf.Bytes())
require.ErrorContains(t, err, "exceed")
}

func TestInspectImageRejectsAnimated(t *testing.T) {
t.Run("static gif is accepted", func(t *testing.T) {
inspection, err := InspectImage(encodeGIF(t, 1))
Expand Down
27 changes: 27 additions & 0 deletions blob/memory/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ func (m *memory) GetRenditions(_ context.Context, parentID *blobpb.BlobId) ([]*b
}

func (m *memory) Advance(_ context.Context, id *blobpb.BlobId, to blob.State, image *blob.ImageMetadata) (bool, error) {
if to == blob.StateRejected {
return false, blob.ErrCannotAdvanceToRejected
}

m.Lock()
defer m.Unlock()

Expand All @@ -103,6 +107,29 @@ func (m *memory) Advance(_ context.Context, id *blobpb.BlobId, to blob.State, im
return true, nil
}

func (m *memory) Reject(_ context.Context, id *blobpb.BlobId, rejection *blob.RejectionMetadata) (bool, error) {
m.Lock()
defer m.Unlock()

b, ok := m.blobs[string(id.Value)]
if !ok {
return false, blob.ErrNotFound
}

// Never overwrite a terminal blob; a concurrent or replayed reject is an
// idempotent no-op that defers to the committed state.
if b.State.Terminal() {
return false, nil
}

b.State = blob.StateRejected
if rejection != nil {
rejectionCopy := *rejection
b.Rejection = &rejectionCopy
}
return true, nil
}

func (m *memory) reset() {
m.Lock()
defer m.Unlock()
Expand Down
68 changes: 68 additions & 0 deletions blob/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

blobpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/blob/v1"
commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1"
moderationpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/moderation/v1"
"github.com/google/uuid"
)

Expand All @@ -14,6 +15,11 @@ var (

// ErrExists is returned when a blob with the given id already exists.
ErrExists = errors.New("blob already exists")

// ErrCannotAdvanceToRejected is returned by Store.Advance when StateRejected is
// passed as the target. Rejection is terminal and carries metadata, so it is
// reached only through Store.Reject, never Advance.
ErrCannotAdvanceToRejected = errors.New("cannot advance to rejected state; use Reject")
)

// RenditionType identifies which rendition of a piece of media a blob holds.
Expand Down Expand Up @@ -124,6 +130,64 @@ func (s State) ToBlobStatus() blobpb.BlobStatus {
}
}

// RejectionReason is the internal mirror of blobpb.RejectionReason: why a blob's
// uploaded bytes failed finalization. It is set only on a StateRejected blob, is
// immutable thereafter, and is its own type (persisted as its own value) so the
// stored representation does not depend on the wire enum's numbering.
type RejectionReason int

const (
RejectionReasonUnknown RejectionReason = iota
RejectionReasonModeration
RejectionReasonUnsupportedType
RejectionReasonMismatchedType
RejectionReasonTooLarge
RejectionReasonCorrupt
RejectionReasonInternal
)

// ToProto maps the internal reason onto the public blobpb.RejectionReason.
func (r RejectionReason) ToProto() blobpb.RejectionReason {
switch r {
case RejectionReasonModeration:
return blobpb.RejectionReason_REJECTION_REASON_MODERATION
case RejectionReasonUnsupportedType:
return blobpb.RejectionReason_REJECTION_REASON_UNSUPPORTED_TYPE
case RejectionReasonMismatchedType:
return blobpb.RejectionReason_REJECTION_REASON_MISMATCHED_TYPE
case RejectionReasonTooLarge:
return blobpb.RejectionReason_REJECTION_REASON_TOO_LARGE
case RejectionReasonCorrupt:
return blobpb.RejectionReason_REJECTION_REASON_CORRUPT
case RejectionReasonInternal:
return blobpb.RejectionReason_REJECTION_REASON_INTERNAL
default:
return blobpb.RejectionReason_REJECTION_REASON_UNKNOWN
}
}

// RejectionMetadata records why a blob was rejected during finalization. It is
// set only on a StateRejected blob and is immutable thereafter.
type RejectionMetadata struct {
Reason RejectionReason

// FlaggedCategory is the moderation category that tripped, set only when
// Reason is RejectionReasonModeration; it is NONE (the zero value) otherwise.
FlaggedCategory moderationpb.FlaggedCategory
}

// ToProto renders the rejection metadata for the wire. A nil receiver renders to
// nil, so a non-rejected blob simply carries no rejection.
func (r *RejectionMetadata) ToProto() *blobpb.RejectionMetadata {
if r == nil {
return nil
}
return &blobpb.RejectionMetadata{
Reason: r.Reason.ToProto(),
FlaggedCategory: r.FlaggedCategory,
}
}

// Blob is the server-authoritative record for a stored blob. It is the durable
// identity behind a BlobId and tracks the blob through its lifecycle.
//
Expand Down Expand Up @@ -170,6 +234,10 @@ type Blob struct {
// own sibling fields here (e.g. Video, Audio), one per blobpb.BlobMetadata
// kind variant. Only images exist today.
Image *ImageMetadata

// Rejection records why this blob was rejected, set only when State is
// StateRejected; it is nil for any non-rejected blob.
Rejection *RejectionMetadata
}

func newBlobID() (*blobpb.BlobId, error) {
Expand Down
Loading
Loading