From 03515bcda05449196b1f349c242287b2ed7d03c9 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Tue, 30 Jun 2026 10:58:01 -0400 Subject: [PATCH] Improve Blob RPC responses --- blob/dynamodb/store.go | 68 +++++++++++++++++ blob/image.go | 48 ++++++++++-- blob/image_test.go | 23 ++++++ blob/memory/store.go | 27 +++++++ blob/model.go | 68 +++++++++++++++++ blob/policy.go | 94 +++++++++++++++++++++++ blob/server.go | 154 +++++++++++++++++++++++++++----------- blob/store.go | 30 ++++++-- blob/tests/server.go | 164 ++++++++++++++++++++++++++++++++++++----- blob/tests/store.go | 73 +++++++++++++++--- go.mod | 2 +- go.sum | 4 +- moderation/server.go | 11 ++- 13 files changed, 681 insertions(+), 85 deletions(-) create mode 100644 blob/policy.go diff --git a/blob/dynamodb/store.go b/blob/dynamodb/store.go index e9b49e5..132ccb6 100644 --- a/blob/dynamodb/store.go +++ b/blob/dynamodb/store.go @@ -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" ) @@ -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#" @@ -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)), @@ -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)), @@ -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 } diff --git a/blob/image.go b/blob/image.go index 4496f1b..b57027e 100644 --- a/blob/image.go +++ b/blob/image.go @@ -3,6 +3,7 @@ package blob import ( "bytes" "encoding/binary" + "errors" "fmt" "image" "image/jpeg" @@ -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 @@ -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 @@ -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)) diff --git a/blob/image_test.go b/blob/image_test.go index d309e25..42ac94a 100644 --- a/blob/image_test.go +++ b/blob/image_test.go @@ -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)) diff --git a/blob/memory/store.go b/blob/memory/store.go index 9a0ce2d..3e0dc51 100644 --- a/blob/memory/store.go +++ b/blob/memory/store.go @@ -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() @@ -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() diff --git a/blob/model.go b/blob/model.go index 699df3b..e35536f 100644 --- a/blob/model.go +++ b/blob/model.go @@ -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" ) @@ -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. @@ -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. // @@ -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) { diff --git a/blob/policy.go b/blob/policy.go new file mode 100644 index 0000000..12f9197 --- /dev/null +++ b/blob/policy.go @@ -0,0 +1,94 @@ +package blob + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "time" + + "google.golang.org/protobuf/types/known/durationpb" + + blobpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/blob/v1" +) + +// uploadPolicyTTL is how long a client may rely on a fetched policy before +// re-fetching it. The policy is static today, so this is generous; clients are +// also expected to refresh whenever they observe a version mismatch (including +// one echoed on a denied upload), so a stale cache self-corrects well before +// the TTL lapses. +const uploadPolicyTTL = 24 * time.Hour + +// currentPolicy is the upload policy advertised to every caller. It is static, +// so it is built once at startup and then shared read-only across requests +// rather than reconstructed (and re-hashed) on each call. +var currentPolicy = buildUploadPolicy() + +// currentPolicyVersion is the version token of currentPolicy, echoed on a +// policy-driven upload denial so a client can detect a stale cached policy. It +// aliases currentPolicy.Version, so the advertised policy and the version a +// denial reports can never drift apart. +var currentPolicyVersion = currentPolicy.Version + +// buildUploadPolicy assembles the upload policy advertised to clients: one +// constraint entry per supported image MIME type, each pinned to the same byte, +// dimension, and pixel ceilings the server enforces authoritatively when it +// reserves the upload (InitiateExternalUpload) and inspects the stored bytes +// (InspectImage). The policy is advisory — it lets a client validate and resize +// before uploading — but it never advertises a limit the server does not itself +// enforce. It is called once, to initialize currentPolicy. +func buildUploadPolicy() *blobpb.UploadPolicy { + constraints := buildMimeTypeConstraints() + return &blobpb.UploadPolicy{ + Version: &blobpb.PolicyVersion{Value: policyVersion(constraints)}, + Ttl: durationpb.New(uploadPolicyTTL), + MimeTypeConstraints: constraints, + } +} + +// buildMimeTypeConstraints returns the per-MIME-type constraints, one exact-type +// entry for every image type the server accepts. Every entry is an exact type +// (no wildcards), so the "most specific first" ordering the proto asks for is +// trivially satisfied; they are emitted in a stable, sorted order so the derived +// policy version is deterministic. There is deliberately no "image/*" or "*/*" +// fallback: a type with no matching entry is one the server does not accept. +func buildMimeTypeConstraints() []*blobpb.MimeTypeConstraints { + mimeTypes := make([]string, 0, len(SupportedImageMimeTypes)) + for mimeType := range SupportedImageMimeTypes { + mimeTypes = append(mimeTypes, mimeType) + } + sort.Strings(mimeTypes) + + constraints := make([]*blobpb.MimeTypeConstraints, 0, len(mimeTypes)) + for _, mimeType := range mimeTypes { + constraints = append(constraints, &blobpb.MimeTypeConstraints{ + MimeTypePattern: mimeType, + MaxSizeBytes: MaxOriginalImageSizeBytes, + Kind: &blobpb.MimeTypeConstraints_Image{ + Image: &blobpb.ImageConstraints{ + MaxWidth: maxImageDimension, + MaxHeight: maxImageDimension, + MaxPixels: maxImagePixels, + }, + }, + }) + } + return constraints +} + +// policyVersion hashes a canonical rendering of the policy's limits into a short +// hex token. The rendering covers every advertised value, so any change to the +// TTL or a constraint yields a different token; the constraints arrive in a +// stable order, so an unchanged policy always yields the same token. +func policyVersion(constraints []*blobpb.MimeTypeConstraints) string { + h := sha256.New() + fmt.Fprintf(h, "ttl=%d;", uploadPolicyTTL) + for _, c := range constraints { + img := c.GetImage() + fmt.Fprintf(h, "type=%s,size=%d,w=%d,h=%d,px=%d;", + c.MimeTypePattern, c.MaxSizeBytes, img.GetMaxWidth(), img.GetMaxHeight(), img.GetMaxPixels()) + } + // 16 bytes is ample to make an accidental collision between two distinct + // policies vanishingly unlikely, and stays well inside the proto's length cap. + return hex.EncodeToString(h.Sum(nil)[:16]) +} diff --git a/blob/server.go b/blob/server.go index 71156b7..de30ae9 100644 --- a/blob/server.go +++ b/blob/server.go @@ -11,6 +11,7 @@ import ( "google.golang.org/grpc/status" blobpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/blob/v1" + commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1" "github.com/code-payments/flipcash2-server/account" "github.com/code-payments/flipcash2-server/auth" @@ -18,11 +19,6 @@ import ( "github.com/code-payments/flipcash2-server/moderation" ) -// MaxOriginalSizeBytes 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. -const MaxOriginalSizeBytes = 8 * 1024 * 1024 // 8 MiB - // finalizeTimeout bounds the detached finalization that CompleteExternalUpload // drives. Finalization is dominated by moderation (an external call), so it is // generous, but bounded so a wedged dependency cannot leak work forever. @@ -70,6 +66,33 @@ func NewServer( } } +// GetUploadPolicy returns the upload constraints in force for the caller. It is +// advisory and cacheable: a client uses it to validate and resize before +// reserving an upload, but InitiateExternalUpload remains authoritative. Access +// is gated identically to initiating an upload, so a caller who could not upload +// does not receive a policy. +func (s *Server) GetUploadPolicy(ctx context.Context, req *blobpb.GetUploadPolicyRequest) (*blobpb.GetUploadPolicyResponse, error) { + owner, err := s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } + + log := s.log.With(zap.String("owner_id", model.UserIDString(owner))) + + allowed, err := s.uploadAllowed(ctx, owner, log) + if err != nil { + return nil, err + } + if !allowed { + return &blobpb.GetUploadPolicyResponse{Result: blobpb.GetUploadPolicyResponse_DENIED}, nil + } + + return &blobpb.GetUploadPolicyResponse{ + Result: blobpb.GetUploadPolicyResponse_OK, + Policy: currentPolicy, + }, nil +} + func (s *Server) InitiateExternalUpload(ctx context.Context, req *blobpb.InitiateExternalUploadRequest) (*blobpb.InitiateExternalUploadResponse, error) { owner, err := s.authz.Authorize(ctx, req, &req.Auth) if err != nil { @@ -82,38 +105,35 @@ func (s *Server) InitiateExternalUpload(ctx context.Context, req *blobpb.Initiat zap.Uint64("size_bytes", req.SizeBytes), ) - // Uploads are gated on registration, like other write paths. - isRegistered, err := s.accounts.IsRegistered(ctx, owner) + // Uploads are gated on registration (and, while the feature is staff-gated, on + // staff), like other write paths. + allowed, err := s.uploadAllowed(ctx, owner, log) if err != nil { - log.Warn("Failed to get registration flag", zap.Error(err)) - return nil, status.Error(codes.Internal, "failed to get registration flag") + return nil, err } - if !isRegistered { + if !allowed { return &blobpb.InitiateExternalUploadResponse{Result: blobpb.InitiateExternalUploadResponse_DENIED}, nil } - // While the feature is staff-gated, only staff accounts may initiate uploads. - if s.requireStaff { - isStaff, err := s.accounts.IsStaff(ctx, owner) - if err != nil { - log.Warn("Failed to get staff flag", zap.Error(err)) - return nil, status.Error(codes.Internal, "failed to get staff flag") - } - if !isStaff { - return &blobpb.InitiateExternalUploadResponse{Result: blobpb.InitiateExternalUploadResponse_DENIED}, nil - } - } - // The declared type and size become the immutable, pinned contract for the // upload. Reject anything we would not accept up front rather than after the - // bytes land. + // bytes land, surfacing the specific reason so the client can react instead of + // guessing at a generic denial. A policy-driven denial echoes the policy + // version so a client running on a stale cached policy knows to re-fetch. if !SupportedImageMimeTypes[req.MimeType] { log.Debug("Rejecting upload of unsupported mime type") - return &blobpb.InitiateExternalUploadResponse{Result: blobpb.InitiateExternalUploadResponse_DENIED}, nil + return &blobpb.InitiateExternalUploadResponse{ + Result: blobpb.InitiateExternalUploadResponse_UNSUPPORTED_TYPE, + PolicyVersion: currentPolicyVersion, + }, nil } - if req.SizeBytes == 0 || req.SizeBytes > MaxOriginalSizeBytes { - log.Debug("Rejecting upload outside size limits") - return &blobpb.InitiateExternalUploadResponse{Result: blobpb.InitiateExternalUploadResponse_DENIED}, nil + + if req.SizeBytes > MaxOriginalImageSizeBytes { + log.Debug("Rejecting oversize upload") + return &blobpb.InitiateExternalUploadResponse{ + Result: blobpb.InitiateExternalUploadResponse_TOO_LARGE, + PolicyVersion: currentPolicyVersion, + }, nil } id, err := newBlobID() @@ -156,6 +176,34 @@ func (s *Server) InitiateExternalUpload(ctx context.Context, req *blobpb.Initiat }, nil } +// uploadAllowed reports whether the caller may upload: they must be registered, +// and — while the feature is staff-gated — staff. A false with a nil error is a +// clean denial; a non-nil error is an internal failure already logged and shaped +// for return to the client. +func (s *Server) uploadAllowed(ctx context.Context, owner *commonpb.UserId, log *zap.Logger) (bool, error) { + isRegistered, err := s.accounts.IsRegistered(ctx, owner) + if err != nil { + log.Warn("Failed to get registration flag", zap.Error(err)) + return false, status.Error(codes.Internal, "failed to get registration flag") + } + if !isRegistered { + return false, nil + } + + if s.requireStaff { + isStaff, err := s.accounts.IsStaff(ctx, owner) + if err != nil { + log.Warn("Failed to get staff flag", zap.Error(err)) + return false, status.Error(codes.Internal, "failed to get staff flag") + } + if !isStaff { + return false, nil + } + } + + return true, nil +} + func (s *Server) CompleteExternalUpload(ctx context.Context, req *blobpb.CompleteExternalUploadRequest) (*blobpb.CompleteExternalUploadResponse, error) { owner, err := s.authz.Authorize(ctx, req, &req.Auth) if err != nil { @@ -207,12 +255,17 @@ func (s *Server) CompleteExternalUpload(ctx context.Context, req *blobpb.Complet return nil, status.Error(codes.Unavailable, "upload not yet finalized") } - // todo: Support rejection metadata + // finalize reports only the status; the rejection metadata it recorded lives on + // the (now terminal) record, so read it back to surface why. The committed + // record is authoritative even when a concurrent finalize won the race. var rejectionMetadata *blobpb.RejectionMetadata if finalStatus == blobpb.BlobStatus_BLOB_STATUS_REJECTED { - rejectionMetadata = &blobpb.RejectionMetadata{ - Reason: blobpb.RejectionReason_REJECTION_REASON_INTERNAL, + rejected, err := s.blobs.GetByID(ctx, record.ID) + if err != nil { + log.Warn("Failed to load rejection metadata", zap.Error(err)) + return nil, status.Error(codes.Internal, "failed to complete upload") } + rejectionMetadata = rejected.Rejection.ToProto() } return &blobpb.CompleteExternalUploadResponse{ @@ -266,10 +319,7 @@ func (s *Server) GetBlobs(ctx context.Context, req *blobpb.GetBlobsRequest) (*bl } protoBlob.Metadata = metadata case blobpb.BlobStatus_BLOB_STATUS_REJECTED: - // todo: Support rejection metadata - protoBlob.Rejection = &blobpb.RejectionMetadata{ - Reason: blobpb.RejectionReason_REJECTION_REASON_INTERNAL, - } + protoBlob.Rejection = record.Rejection.ToProto() } resolved = append(resolved, protoBlob) @@ -330,15 +380,17 @@ func (s *Server) finalize(ctx context.Context, record *Blob) (blobpb.BlobStatus, // The declared size and type are immutable: if the stored bytes disagree // with either, the blob is rejected rather than corrected. if uint64(len(data)) != record.SizeBytes { - return s.reject(ctx, record) + // The stored bytes don't match the size pinned at reservation, so the + // upload broke its declared size contract. + return s.reject(ctx, record, &RejectionMetadata{Reason: RejectionReasonTooLarge}) } inspection, err := InspectImage(data) if err != nil { - // Undecodable or unsupported bytes: not a servable image. - return s.reject(ctx, record) + // Undecodable, unsupported, or oversize bytes: not a servable image. + return s.reject(ctx, record, &RejectionMetadata{Reason: rejectionReasonForInspection(err)}) } if inspection.MimeType != record.MimeType { - return s.reject(ctx, record) + return s.reject(ctx, record, &RejectionMetadata{Reason: RejectionReasonMismatchedType}) } if s.moderator != nil { // Moderate a size-bounded rendering, not the full-resolution original: @@ -355,7 +407,10 @@ func (s *Server) finalize(ctx context.Context, record *Blob) (blobpb.BlobStatus, return blobpb.BlobStatus_BLOB_STATUS_UNKNOWN, err } if result.Flagged { - return s.reject(ctx, record) + return s.reject(ctx, record, &RejectionMetadata{ + Reason: RejectionReasonModeration, + FlaggedCategory: moderation.HighestFlaggedCategory(result), + }) } } @@ -420,8 +475,8 @@ func (s *Server) fetchUploaded(ctx context.Context, record *Blob) ([]byte, error return data, nil } -func (s *Server) reject(ctx context.Context, record *Blob) (blobpb.BlobStatus, error) { - advanced, err := s.blobs.Advance(ctx, record.ID, StateRejected, nil) +func (s *Server) reject(ctx context.Context, record *Blob, rejection *RejectionMetadata) (blobpb.BlobStatus, error) { + advanced, err := s.blobs.Reject(ctx, record.ID, rejection) if err != nil { return blobpb.BlobStatus_BLOB_STATUS_UNKNOWN, err } @@ -435,6 +490,23 @@ func (s *Server) reject(ctx context.Context, record *Blob) (blobpb.BlobStatus, e return blobpb.BlobStatus_BLOB_STATUS_REJECTED, nil } +// rejectionReasonForInspection classifies an InspectImage failure into the +// rejection reason it should be recorded under. The byte-level validation +// failures are wrapped with sentinels; anything else (e.g. a downstream +// processing fault) is reported as internal. +func rejectionReasonForInspection(err error) RejectionReason { + switch { + case errors.Is(err, ErrImageUnsupportedType): + return RejectionReasonUnsupportedType + case errors.Is(err, ErrImageTooLarge): + return RejectionReasonTooLarge + case errors.Is(err, ErrImageCorrupt): + return RejectionReasonCorrupt + default: + return RejectionReasonInternal + } +} + // currentStatus re-reads a blob and returns its authoritative public status. It // backs the lost-race paths in finalize: when Advance reports it did not perform // the transition, the local view is stale, so the committed state is read back diff --git a/blob/store.go b/blob/store.go index 738b8a5..c2bce68 100644 --- a/blob/store.go +++ b/blob/store.go @@ -34,12 +34,16 @@ type Store interface { // returns an empty slice (not ErrNotFound) when there are none. GetRenditions(ctx context.Context, parentID *blobpb.BlobId) ([]*Blob, error) - // Advance moves a blob forward to a later lifecycle state, persisting derived - // metadata when provided (image is set only on the transition into - // StateInspected). It advances strictly forward and never out of a terminal - // state, so a replayed or concurrent finalize is idempotent: advancing to a - // state the blob is already at or past is a no-op. The declared MimeType and - // SizeBytes are never changed. + // Advance moves a blob forward along the success path to a later lifecycle + // state, persisting derived metadata when provided (image is set only on the + // transition into StateInspected). It advances strictly forward and never out + // of a terminal state, so a replayed or concurrent finalize is idempotent: + // advancing to a state the blob is already at or past is a no-op. The declared + // MimeType and SizeBytes are never changed. + // + // StateRejected is not a valid target — rejection is terminal and carries + // metadata, so it is reached only through Reject. Passing it returns + // ErrCannotAdvanceToRejected. // // It reports whether this call actually performed the transition. A false // return with a nil error means the blob was already at or past the target @@ -48,6 +52,16 @@ type Store interface { // // ErrNotFound is returned if no blob exists for the given id. Advance(ctx context.Context, id *blobpb.BlobId, to State, image *ImageMetadata) (bool, error) + + // Reject moves a non-terminal blob to the terminal StateRejected, recording + // why. Like Advance it transitions only out of a non-terminal state and is + // idempotent: it reports whether it performed the transition, and a false with + // a nil error means the blob was already terminal — a concurrent or replayed + // finalize won the race, so the committed rejection (or readiness) stands and + // must not be overwritten. + // + // ErrNotFound is returned if no blob exists for the given id. + Reject(ctx context.Context, id *blobpb.BlobId, rejection *RejectionMetadata) (bool, error) } // Clone returns a deep copy of the blob, so stores can hand out values callers @@ -77,5 +91,9 @@ func (b *Blob) Clone() *Blob { image := *b.Image cloned.Image = &image } + if b.Rejection != nil { + rejection := *b.Rejection + cloned.Rejection = &rejection + } return cloned } diff --git a/blob/tests/server.go b/blob/tests/server.go index d5bedc2..5303a8c 100644 --- a/blob/tests/server.go +++ b/blob/tests/server.go @@ -16,6 +16,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/account" "github.com/code-payments/flipcash2-server/auth" @@ -42,6 +43,7 @@ func RunServerTests( teardown func(), ) { for _, tf := range []func(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, upload uploadFunc){ + testGetUploadPolicy, testInitiateExternalUpload, testUploadLifecycle, testFinalizationRejections, @@ -70,6 +72,76 @@ func registerUser(t *testing.T, accounts account.Store) (*commonpb.UserId, model return userID, signer } +func testGetUploadPolicy(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, _ uploadFunc) { + server := newServer(accounts, blobs, storage, nil, t) + + t.Run("unregistered is denied", func(t *testing.T) { + signer := model.MustGenerateKeyPair() + _, err := accounts.Bind(context.Background(), model.MustGenerateUserID(), signer.Proto()) + require.NoError(t, err) + + req := &blobpb.GetUploadPolicyRequest{} + require.NoError(t, signer.Auth(req, &req.Auth)) + + resp, err := server.GetUploadPolicy(context.Background(), req) + require.NoError(t, err) + require.Equal(t, blobpb.GetUploadPolicyResponse_DENIED, resp.Result) + require.Nil(t, resp.Policy) + }) + + t.Run("registered receives a policy covering every supported type", func(t *testing.T) { + _, signer := registerUser(t, accounts) + req := &blobpb.GetUploadPolicyRequest{} + require.NoError(t, signer.Auth(req, &req.Auth)) + + resp, err := server.GetUploadPolicy(context.Background(), req) + require.NoError(t, err) + require.Equal(t, blobpb.GetUploadPolicyResponse_OK, resp.Result) + require.NotNil(t, resp.Policy) + + policy := resp.Policy + require.NotNil(t, policy.Version) + require.NotEmpty(t, policy.Version.Value) + require.NotNil(t, policy.Ttl) + require.Positive(t, policy.Ttl.AsDuration()) + + // Exactly one entry per supported image type, with no wildcard fallback. + require.Len(t, policy.MimeTypeConstraints, len(blob.SupportedImageMimeTypes)) + seen := make(map[string]bool) + for _, c := range policy.MimeTypeConstraints { + require.True(t, blob.SupportedImageMimeTypes[c.MimeTypePattern], "unexpected pattern %q", c.MimeTypePattern) + require.False(t, seen[c.MimeTypePattern], "duplicate pattern %q", c.MimeTypePattern) + seen[c.MimeTypePattern] = true + + require.EqualValues(t, blob.MaxOriginalImageSizeBytes, c.MaxSizeBytes) + img := c.GetImage() + require.NotNil(t, img) + require.Positive(t, img.MaxWidth) + require.Positive(t, img.MaxHeight) + require.Positive(t, img.MaxPixels) + } + require.Len(t, seen, len(blob.SupportedImageMimeTypes)) + }) + + t.Run("version matches the one echoed on a policy-driven denial", func(t *testing.T) { + _, signer := registerUser(t, accounts) + + policyReq := &blobpb.GetUploadPolicyRequest{} + require.NoError(t, signer.Auth(policyReq, &policyReq.Auth)) + policyResp, err := server.GetUploadPolicy(context.Background(), policyReq) + require.NoError(t, err) + require.Equal(t, blobpb.GetUploadPolicyResponse_OK, policyResp.Result) + + denyReq := &blobpb.InitiateExternalUploadRequest{MimeType: "image/png", SizeBytes: blob.MaxOriginalImageSizeBytes + 1} + require.NoError(t, signer.Auth(denyReq, &denyReq.Auth)) + denyResp, err := server.InitiateExternalUpload(context.Background(), denyReq) + require.NoError(t, err) + require.Equal(t, blobpb.InitiateExternalUploadResponse_TOO_LARGE, denyResp.Result) + require.NotNil(t, denyResp.PolicyVersion) + require.Equal(t, policyResp.Policy.Version.Value, denyResp.PolicyVersion.Value) + }) +} + func testInitiateExternalUpload(t *testing.T, accounts account.Store, blobs blob.Store, storage blob.ObjectStorage, _ uploadFunc) { server := newServer(accounts, blobs, storage, nil, t) imageBytes := makePNG(t, 8, 8) @@ -85,26 +157,33 @@ func testInitiateExternalUpload(t *testing.T, accounts account.Store, blobs blob resp, err := server.InitiateExternalUpload(context.Background(), req) require.NoError(t, err) require.Equal(t, blobpb.InitiateExternalUploadResponse_DENIED, resp.Result) + // An authorization denial carries no policy version — the policy is not why + // it was rejected. + require.Nil(t, resp.PolicyVersion) }) - t.Run("unsupported mime type is denied", func(t *testing.T) { + t.Run("unsupported mime type", func(t *testing.T) { _, signer := registerUser(t, accounts) req := &blobpb.InitiateExternalUploadRequest{MimeType: "application/pdf", SizeBytes: 1024} require.NoError(t, signer.Auth(req, &req.Auth)) resp, err := server.InitiateExternalUpload(context.Background(), req) require.NoError(t, err) - require.Equal(t, blobpb.InitiateExternalUploadResponse_DENIED, resp.Result) + require.Equal(t, blobpb.InitiateExternalUploadResponse_UNSUPPORTED_TYPE, resp.Result) + require.NotNil(t, resp.PolicyVersion) + require.NotEmpty(t, resp.PolicyVersion.Value) }) - t.Run("oversize is denied", func(t *testing.T) { + t.Run("oversize is too large", func(t *testing.T) { _, signer := registerUser(t, accounts) - req := &blobpb.InitiateExternalUploadRequest{MimeType: "image/png", SizeBytes: blob.MaxOriginalSizeBytes + 1} + req := &blobpb.InitiateExternalUploadRequest{MimeType: "image/png", SizeBytes: blob.MaxOriginalImageSizeBytes + 1} require.NoError(t, signer.Auth(req, &req.Auth)) resp, err := server.InitiateExternalUpload(context.Background(), req) require.NoError(t, err) - require.Equal(t, blobpb.InitiateExternalUploadResponse_DENIED, resp.Result) + require.Equal(t, blobpb.InitiateExternalUploadResponse_TOO_LARGE, resp.Result) + require.NotNil(t, resp.PolicyVersion) + require.NotEmpty(t, resp.PolicyVersion.Value) }) t.Run("success reserves a pending original", func(t *testing.T) { @@ -199,12 +278,12 @@ func testFinalizationRejections(t *testing.T, accounts account.Store, blobs blob server := newServer(accounts, blobs, storage, nil, t) _, signer := registerUser(t, accounts) - t.Run("non-image bytes are rejected", func(t *testing.T) { + t.Run("non-image bytes are rejected as corrupt", func(t *testing.T) { junk := []byte("this is definitely not an image") blobID, target := initiate(t, server, signer, "image/png", uint64(len(junk))) upload(target, junk) - require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_REJECTED, complete(t, server, signer, blobID)) + requireRejected(t, server, signer, blobID, blobpb.RejectionReason_REJECTION_REASON_CORRUPT) }) t.Run("mime type mismatch is rejected", func(t *testing.T) { @@ -214,15 +293,15 @@ func testFinalizationRejections(t *testing.T, accounts account.Store, blobs blob blobID, target := initiate(t, server, signer, "image/gif", uint64(len(imageBytes))) upload(target, imageBytes) - require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_REJECTED, complete(t, server, signer, blobID)) + requireRejected(t, server, signer, blobID, blobpb.RejectionReason_REJECTION_REASON_MISMATCHED_TYPE) }) - t.Run("size mismatch is rejected", func(t *testing.T) { + t.Run("size mismatch is rejected as too large", func(t *testing.T) { imageBytes := makePNG(t, 4, 4) blobID, target := initiate(t, server, signer, "image/png", uint64(len(imageBytes)+1)) upload(target, imageBytes) - require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_REJECTED, complete(t, server, signer, blobID)) + requireRejected(t, server, signer, blobID, blobpb.RejectionReason_REJECTION_REASON_TOO_LARGE) }) } @@ -230,12 +309,16 @@ func testModeration(t *testing.T, accounts account.Store, blobs blob.Store, stor _, signer := registerUser(t, accounts) imageBytes := makePNG(t, 6, 6) - t.Run("flagged image is rejected", func(t *testing.T) { - server := newServer(accounts, blobs, storage, &fakeModerator{flagged: true}, t) + t.Run("flagged image is rejected with the moderation category", func(t *testing.T) { + server := newServer(accounts, blobs, storage, &fakeModerator{flagged: true, categories: []string{"general_nsfw"}}, t) blobID, target := initiate(t, server, signer, "image/png", uint64(len(imageBytes))) upload(target, imageBytes) - require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_REJECTED, complete(t, server, signer, blobID)) + resp := completeResponse(t, server, signer, blobID) + require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_REJECTED, resp.Status) + require.NotNil(t, resp.RejectionMetadata) + require.Equal(t, blobpb.RejectionReason_REJECTION_REASON_MODERATION, resp.RejectionMetadata.Reason) + require.Equal(t, moderationpb.FlaggedCategory_NSFW, resp.RejectionMetadata.FlaggedCategory) }) t.Run("clean image is ready", func(t *testing.T) { @@ -260,6 +343,12 @@ func testGetBlobs(t *testing.T, accounts account.Store, blobs blob.Store, storag // A PENDING blob (reserved, never uploaded). pendingID, _ := initiate(t, server, signer, "image/png", uint64(len(imageBytes))) + // A REJECTED blob (uploaded bytes that fail validation). + junk := []byte("not an image at all") + rejectedID, rejectedTarget := initiate(t, server, signer, "image/png", uint64(len(junk))) + upload(rejectedTarget, junk) + require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_REJECTED, complete(t, server, signer, rejectedID)) + t.Run("owner resolves a ready blob with a fresh download url and metadata", func(t *testing.T) { req := &blobpb.GetBlobsRequest{BlobIds: &blobpb.BlobIdBatch{BlobIds: []*blobpb.BlobId{readyID}}} require.NoError(t, signer.Auth(req, &req.Auth)) @@ -299,6 +388,22 @@ func testGetBlobs(t *testing.T, accounts account.Store, blobs blob.Store, storag require.Nil(t, resp.Blobs) }) + t.Run("owner resolves a rejected blob with rejection metadata", func(t *testing.T) { + req := &blobpb.GetBlobsRequest{BlobIds: &blobpb.BlobIdBatch{BlobIds: []*blobpb.BlobId{rejectedID}}} + require.NoError(t, signer.Auth(req, &req.Auth)) + + resp, err := server.GetBlobs(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp.Blobs) + require.Len(t, resp.Blobs.Blobs, 1) + + got := resp.Blobs.Blobs[0] + require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_REJECTED, got.Status) + require.Nil(t, got.Metadata) + require.NotNil(t, got.Rejection) + require.Equal(t, blobpb.RejectionReason_REJECTION_REASON_CORRUPT, got.Rejection.Reason) + }) + t.Run("pending blob has status but no metadata", func(t *testing.T) { req := &blobpb.GetBlobsRequest{BlobIds: &blobpb.BlobIdBatch{BlobIds: []*blobpb.BlobId{pendingID}}} require.NoError(t, signer.Auth(req, &req.Auth)) @@ -341,15 +446,29 @@ func initiate(t *testing.T, server *blob.Server, signer model.KeyPair, mimeType return resp.BlobId, resp.UploadTarget } -// complete runs CompleteExternalUpload and returns the resulting status. -func complete(t *testing.T, server *blob.Server, signer model.KeyPair, blobID *blobpb.BlobId) blobpb.BlobStatus { +// completeResponse runs CompleteExternalUpload and returns the full OK response. +func completeResponse(t *testing.T, server *blob.Server, signer model.KeyPair, blobID *blobpb.BlobId) *blobpb.CompleteExternalUploadResponse { req := &blobpb.CompleteExternalUploadRequest{BlobId: blobID} require.NoError(t, signer.Auth(req, &req.Auth)) resp, err := server.CompleteExternalUpload(context.Background(), req) require.NoError(t, err) require.Equal(t, blobpb.CompleteExternalUploadResponse_OK, resp.Result) - return resp.Status + return resp +} + +// complete runs CompleteExternalUpload and returns the resulting status. +func complete(t *testing.T, server *blob.Server, signer model.KeyPair, blobID *blobpb.BlobId) blobpb.BlobStatus { + return completeResponse(t, server, signer, blobID).Status +} + +// requireRejected asserts that completing the blob rejects it with the given +// reason, surfaced in the response's rejection metadata. +func requireRejected(t *testing.T, server *blob.Server, signer model.KeyPair, blobID *blobpb.BlobId, reason blobpb.RejectionReason) { + resp := completeResponse(t, server, signer, blobID) + require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_REJECTED, resp.Status) + require.NotNil(t, resp.RejectionMetadata) + require.Equal(t, reason, resp.RejectionMetadata.Reason) } func makePNG(t *testing.T, width, height int) []byte { @@ -366,6 +485,9 @@ func makePNG(t *testing.T, width, height int) []byte { type fakeModerator struct { flagged bool + // categories, when set, are returned as the flagged categories (each given a + // distinct score) so the mapping into a proto FlaggedCategory can be exercised. + categories []string } func (m *fakeModerator) ClassifyText(context.Context, string) (*moderation.Result, error) { @@ -373,7 +495,15 @@ func (m *fakeModerator) ClassifyText(context.Context, string) (*moderation.Resul } func (m *fakeModerator) ClassifyImage(context.Context, []byte) (*moderation.Result, error) { - return &moderation.Result{Flagged: m.flagged}, nil + result := &moderation.Result{Flagged: m.flagged} + if len(m.categories) > 0 { + result.FlaggedCategories = m.categories + result.CategoryScores = make(map[string]float64, len(m.categories)) + for i, category := range m.categories { + result.CategoryScores[category] = float64(i + 1) + } + } + return result, nil } func (m *fakeModerator) ClassifyCurrencyName(context.Context, string) (*moderation.Result, error) { diff --git a/blob/tests/store.go b/blob/tests/store.go index 9fe228b..1fd802a 100644 --- a/blob/tests/store.go +++ b/blob/tests/store.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" blobpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/blob/v1" + moderationpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/moderation/v1" "github.com/code-payments/flipcash2-server/blob" "github.com/code-payments/flipcash2-server/model" @@ -18,6 +19,7 @@ func RunStoreTests(t *testing.T, store blob.Store, teardown func()) { for _, tf := range []func(t *testing.T, store blob.Store){ testStoreCreateAndGet, testStoreAdvance, + testStoreReject, testStoreRenditions, } { tf(t, store) @@ -128,23 +130,76 @@ func testStoreAdvance(t *testing.T, store blob.Store) { advanced, err = store.Advance(ctx, original.ID, blob.StateUploaded, nil) require.NoError(t, err) require.False(t, advanced) - advanced, err = store.Advance(ctx, original.ID, blob.StateRejected, nil) - require.NoError(t, err) - require.False(t, advanced) got, err = store.GetByID(ctx, original.ID) require.NoError(t, err) require.Equal(t, blob.StateReady, got.State) - // A blob can be rejected outright with no derived metadata. - rejected := pendingOriginal(t) - require.NoError(t, store.CreatePending(ctx, rejected)) - advanced, err = store.Advance(ctx, rejected.ID, blob.StateRejected, nil) + // Advance cannot be used to reject a blob; StateRejected is reached only + // through Reject, so passing it as a target is an error and changes nothing. + advanced, err = store.Advance(ctx, original.ID, blob.StateRejected, nil) + require.ErrorIs(t, err, blob.ErrCannotAdvanceToRejected) + require.False(t, advanced) +} + +func testStoreReject(t *testing.T, store blob.Store) { + ctx := context.Background() + + // Rejecting an unknown blob reports not-found. + advanced, err := store.Reject(ctx, newBlobID(t), &blob.RejectionMetadata{Reason: blob.RejectionReasonCorrupt}) + require.ErrorIs(t, err, blob.ErrNotFound) + require.False(t, advanced) + + // A pending blob is rejected, and the reason round-trips (no flagged category + // for a non-moderation reason). + corrupt := pendingOriginal(t) + require.NoError(t, store.CreatePending(ctx, corrupt)) + advanced, err = store.Reject(ctx, corrupt.ID, &blob.RejectionMetadata{Reason: blob.RejectionReasonCorrupt}) require.NoError(t, err) require.True(t, advanced) - got, err = store.GetByID(ctx, rejected.ID) + got, err := store.GetByID(ctx, corrupt.ID) require.NoError(t, err) require.Equal(t, blob.StateRejected, got.State) - require.Nil(t, got.Image) + require.NotNil(t, got.Rejection) + require.Equal(t, blob.RejectionReasonCorrupt, got.Rejection.Reason) + require.Equal(t, moderationpb.FlaggedCategory_NONE, got.Rejection.FlaggedCategory) + + // A moderation rejection also round-trips its flagged category. + flagged := pendingOriginal(t) + require.NoError(t, store.CreatePending(ctx, flagged)) + advanced, err = store.Reject(ctx, flagged.ID, &blob.RejectionMetadata{ + Reason: blob.RejectionReasonModeration, + FlaggedCategory: moderationpb.FlaggedCategory_NSFW, + }) + require.NoError(t, err) + require.True(t, advanced) + got, err = store.GetByID(ctx, flagged.ID) + require.NoError(t, err) + require.Equal(t, blob.RejectionReasonModeration, got.Rejection.Reason) + require.Equal(t, moderationpb.FlaggedCategory_NSFW, got.Rejection.FlaggedCategory) + + // Rejection is terminal and idempotent: a second reject neither transitions nor + // overwrites the recorded reason. + advanced, err = store.Reject(ctx, flagged.ID, &blob.RejectionMetadata{Reason: blob.RejectionReasonCorrupt}) + require.NoError(t, err) + require.False(t, advanced) + got, err = store.GetByID(ctx, flagged.ID) + require.NoError(t, err) + require.Equal(t, blob.RejectionReasonModeration, got.Rejection.Reason) + + // A READY blob cannot be rejected. + ready := pendingOriginal(t) + require.NoError(t, store.CreatePending(ctx, ready)) + for _, to := range []blob.State{blob.StateUploaded, blob.StateInspected, blob.StatePromoted, blob.StateReady} { + _, err = store.Advance(ctx, ready.ID, to, nil) + require.NoError(t, err) + } + advanced, err = store.Reject(ctx, ready.ID, &blob.RejectionMetadata{Reason: blob.RejectionReasonCorrupt}) + require.NoError(t, err) + require.False(t, advanced) + got, err = store.GetByID(ctx, ready.ID) + require.NoError(t, err) + require.Equal(t, blob.StateReady, got.State) + require.Nil(t, got.Rejection) } func testStoreRenditions(t *testing.T, store blob.Store) { diff --git a/go.mod b/go.mod index 12004d1..527d229 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.42.3 github.com/aws/smithy-go v1.27.1 github.com/buckket/go-blurhash v1.1.0 - github.com/code-payments/flipcash2-protobuf-api v1.14.1-0.20260629203049-f1014792ec00 + github.com/code-payments/flipcash2-protobuf-api v1.15.0 github.com/code-payments/ocp-protobuf-api v1.13.2-0.20260610171241-de46af911053 github.com/code-payments/ocp-server v1.19.0 github.com/devsisters/go-applereceipt v0.0.0-20240805020915-fa22a0160fc2 diff --git a/go.sum b/go.sum index b3d6e15..43c8889 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB0 github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= github.com/code-payments/code-vm-indexer v1.2.0 h1:rSHpBMiT9BKgmKcXg/VIoi/h0t7jNxGx07Qz59m+6Q0= github.com/code-payments/code-vm-indexer v1.2.0/go.mod h1:vn91YN2qNqb+gGJeZe2+l+TNxVmEEiRHXXnIn2Y40h8= -github.com/code-payments/flipcash2-protobuf-api v1.14.1-0.20260629203049-f1014792ec00 h1:YT5cVHI5610PW6vvPUjgHqV3FzmWdIXKcBqH6ngX9/o= -github.com/code-payments/flipcash2-protobuf-api v1.14.1-0.20260629203049-f1014792ec00/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= +github.com/code-payments/flipcash2-protobuf-api v1.15.0 h1:xVjrxqgnxCxdZQ0I/SGjtso2z6E9ZWCxVv/h5E+3/Ps= +github.com/code-payments/flipcash2-protobuf-api v1.15.0/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= github.com/code-payments/ocp-protobuf-api v1.13.2-0.20260610171241-de46af911053 h1:0B96K7/z7TpFbAJeyzP43A0eM58Ie/KRuu5U6PUrses= github.com/code-payments/ocp-protobuf-api v1.13.2-0.20260610171241-de46af911053/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= github.com/code-payments/ocp-server v1.19.0 h1:l4cznUxOAmaclHKpJANJ2zE3LTXg+BDBbRhl+r66lFI= diff --git a/moderation/server.go b/moderation/server.go index 4a2e916..bc46f40 100644 --- a/moderation/server.go +++ b/moderation/server.go @@ -98,7 +98,7 @@ func (s *Server) ModerateText(ctx context.Context, req *moderationpb.ModerateTex resp.Attestation = s.signAttestation(log, req.Text, userID) } else { log.Info("Text is flagged", zap.Strings("categories", result.FlaggedCategories)) - resp.FlaggedCategory = getHighestFlaggedCategory(result) + resp.FlaggedCategory = HighestFlaggedCategory(result) } return resp, nil @@ -135,7 +135,7 @@ func (s *Server) ModerateImage(ctx context.Context, req *moderationpb.ModerateIm resp.Attestation = s.signAttestation(log, req.ImageData, userID) } else { log.Info("Image is flagged", zap.Strings("categories", result.FlaggedCategories)) - resp.FlaggedCategory = getHighestFlaggedCategory(result) + resp.FlaggedCategory = HighestFlaggedCategory(result) } return resp, nil @@ -183,7 +183,12 @@ func isBlockedCurrencyName(name string) bool { return false } -func getHighestFlaggedCategory(result *Result) moderationpb.FlaggedCategory { +// HighestFlaggedCategory maps a moderation Result's flagged categories onto the +// proto FlaggedCategory vocabulary, returning the highest-scoring one that maps +// to a well-defined category (falling back to OTHER). It is the shared mapping +// used both by this service's responses and by callers that surface a moderation +// verdict elsewhere (e.g. a blob's rejection metadata). +func HighestFlaggedCategory(result *Result) moderationpb.FlaggedCategory { var highestScore float64 highestFlaggedCategory := moderationpb.FlaggedCategory_OTHER for _, flaggedCategory := range result.FlaggedCategories {