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
124 changes: 124 additions & 0 deletions blob/integration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package blob

import (
"bytes"
"context"
"errors"

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

// ErrBlobNotShareable is returned by Integration.ShareIntoChat when a referenced
// blob cannot be attached to a chat — it does not exist, is not owned by the
// sharer, is not a READY original, or is not an image. When it is returned none of
// the blobs are granted.
var ErrBlobNotShareable = errors.New("blob not shareable")

// Integration is the surface other domains (messaging today) use to attach blobs
// to a resource they own: it validates and grants read access on send
// (ShareIntoChat), and resolves the blobs' metadata on read (Resolve).
type Integration struct {
blobs Store
storage ObjectStorage
access AccessStore
}

// NewIntegration returns an Integration backed by the given blob metadata store,
// object storage, and ACL store.
func NewIntegration(blobs Store, storage ObjectStorage, access AccessStore) *Integration {
return &Integration{blobs: blobs, storage: storage, access: access}
}

// ShareIntoChat attaches blobs to a chat: it verifies that sharerID owns every
// blob in blobIDs and that each is a READY image original, then grants the chat
// read access to each. It is all-or-nothing — if any blob fails validation
// nothing is granted and ErrBlobNotShareable is returned — and idempotent, so a
// re-sent message re-grants harmlessly. An empty blobIDs is a no-op.
//
// Only the owner may introduce a blob into a chat: a BlobId is a bearer
// capability, so without the ownership check a member could attach a blob they
// merely learned the id of. Only a READY original is servable and grantable
// (renditions inherit their original's grants), so a pending, rejected, or
// rendition blob is rejected; and chat media is images only today, so a
// non-image blob is rejected too.
func (i *Integration) ShareIntoChat(ctx context.Context, sharerID *commonpb.UserId, chatID *commonpb.ChatId, blobIDs []*blobpb.BlobId) error {
if len(blobIDs) == 0 {
return nil
}

records, err := i.blobs.GetByIDs(ctx, blobIDs)
if err != nil {
return err
}
byID := make(map[string]*Blob, len(records))
for _, b := range records {
byID[string(b.ID.Value)] = b
}

// Validate every requested blob before granting any, so a single bad id in the
// batch grants nothing.
for _, id := range blobIDs {
b, ok := byID[string(id.Value)]
if !ok {
return ErrBlobNotShareable
}
if b.Owner == nil || !bytes.Equal(b.Owner.Value, sharerID.Value) {
return ErrBlobNotShareable
}
if b.Rendition != RenditionOriginal || b.ParentID != nil {
return ErrBlobNotShareable
}
// Media in a chat is images only today; reject any other content kind (a
// future video/file blob) rather than attaching something the chat cannot
// render or serve.
if !SupportedImageMimeTypes[b.MimeType] {
return ErrBlobNotShareable
}
if b.State != StateReady {
return ErrBlobNotShareable
}
}

chat := PrincipalForChat(chatID)
for _, id := range blobIDs {
if err := i.access.Grant(ctx, &Grant{BlobID: id, Principal: chat, Permission: PermissionRead}); err != nil {
return err
}
}
return nil
}

// Resolve returns the server-authoritative metadata — mime type, size, image
// dimensions, and a freshly minted, short-lived download URL — for each READY
// blob among ids, keyed by string(BlobId.Value). Unknown or not-yet-READY ids are
// omitted. An empty input yields a nil map.
//
// It performs NO authorization. Callers must only pass ids they have already
// established the reader may see — e.g. blob ids drawn from a chat message the
// reader is a member of, which were granted to the chat when the message was sent.
func (i *Integration) Resolve(ctx context.Context, ids []*blobpb.BlobId) (map[string]*blobpb.BlobMetadata, error) {
if len(ids) == 0 {
return nil, nil
}

records, err := i.blobs.GetByIDs(ctx, ids)
if err != nil {
return nil, err
}

out := make(map[string]*blobpb.BlobMetadata, len(records))
for _, record := range records {
// Only a READY blob is servable; a pending/rejected one has no metadata to
// surface, so it is left for the client to treat as unavailable.
if record.State != StateReady {
continue
}
metadata, err := buildMetadata(ctx, i.storage, record)
if err != nil {
return nil, err
}
out[string(record.ID.Value)] = metadata
}
return out, nil
}
157 changes: 157 additions & 0 deletions blob/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package blob_test

import (
"context"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/require"

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/blob"
"github.com/code-payments/flipcash2-server/blob/memory"
"github.com/code-payments/flipcash2-server/model"
)

func newBlobID(t *testing.T) *blobpb.BlobId {
id, err := uuid.NewRandom()
require.NoError(t, err)
v := id
return &blobpb.BlobId{Value: v[:]}
}

func newChatID() *commonpb.ChatId {
a := model.MustGenerateUserID().Value
b := model.MustGenerateUserID().Value
return &commonpb.ChatId{Value: append(append([]byte{}, a...), b...)}
}

func putReadyOriginal(t *testing.T, store blob.Store, owner *commonpb.UserId) *blobpb.BlobId {
ctx := context.Background()
id := newBlobID(t)
require.NoError(t, store.CreatePending(ctx, &blob.Blob{
ID: id,
Rendition: blob.RenditionOriginal,
Owner: owner,
State: blob.StatePending,
StorageKey: "images/x/original.png",
MimeType: "image/png",
SizeBytes: 1,
}))
_, err := store.Advance(ctx, id, blob.StateReady, nil)
require.NoError(t, err)
return id
}

func TestIntegration_ShareIntoChat(t *testing.T) {
ctx := context.Background()
store := memory.NewInMemory()
access := memory.NewInMemoryAccessStore()
integration := blob.NewIntegration(store, memory.NewInMemoryStorage(), access)

owner := model.MustGenerateUserID()
chatID := newChatID()
chatPrincipal := blob.PrincipalForChat(chatID)

t.Run("a ready original owned by the sharer is shared and granted", func(t *testing.T) {
id := putReadyOriginal(t, store, owner)
require.NoError(t, integration.ShareIntoChat(ctx, owner, chatID, []*blobpb.BlobId{id}))

has, err := access.HasGrant(ctx, id, chatPrincipal, blob.PermissionRead)
require.NoError(t, err)
require.True(t, has)

// Idempotent: sharing again leaves the grant in place, not an error.
require.NoError(t, integration.ShareIntoChat(ctx, owner, chatID, []*blobpb.BlobId{id}))
})

t.Run("empty input is a no-op", func(t *testing.T) {
require.NoError(t, integration.ShareIntoChat(ctx, owner, chatID, nil))
})

t.Run("a blob owned by someone else is rejected and grants nothing", func(t *testing.T) {
mine := putReadyOriginal(t, store, owner)
theirs := putReadyOriginal(t, store, model.MustGenerateUserID())

err := integration.ShareIntoChat(ctx, owner, chatID, []*blobpb.BlobId{mine, theirs})
require.ErrorIs(t, err, blob.ErrBlobNotShareable)

// All-or-nothing: the valid blob in the batch was not granted either.
has, err := access.HasGrant(ctx, mine, chatPrincipal, blob.PermissionRead)
require.NoError(t, err)
require.False(t, has)
})

t.Run("a non-ready blob is rejected", func(t *testing.T) {
id := newBlobID(t)
require.NoError(t, store.CreatePending(ctx, &blob.Blob{
ID: id, Rendition: blob.RenditionOriginal, Owner: owner, State: blob.StatePending,
StorageKey: "k", MimeType: "image/png", SizeBytes: 1,
}))
require.ErrorIs(t, integration.ShareIntoChat(ctx, owner, chatID, []*blobpb.BlobId{id}), blob.ErrBlobNotShareable)
})

t.Run("a rendition is rejected (only originals are shareable)", func(t *testing.T) {
original := putReadyOriginal(t, store, owner)
rendition := newBlobID(t)
require.NoError(t, store.CreatePending(ctx, &blob.Blob{
ID: rendition, Rendition: blob.RenditionDisplay, ParentID: original, Owner: owner, State: blob.StatePending,
StorageKey: "k", MimeType: "image/png", SizeBytes: 1,
}))
_, err := store.Advance(ctx, rendition, blob.StateReady, nil)
require.NoError(t, err)
require.ErrorIs(t, integration.ShareIntoChat(ctx, owner, chatID, []*blobpb.BlobId{rendition}), blob.ErrBlobNotShareable)
})

t.Run("a non-image blob is rejected", func(t *testing.T) {
id := newBlobID(t)
require.NoError(t, store.CreatePending(ctx, &blob.Blob{
ID: id, Rendition: blob.RenditionOriginal, Owner: owner, State: blob.StatePending,
StorageKey: "k", MimeType: "video/mp4", SizeBytes: 1,
}))
_, err := store.Advance(ctx, id, blob.StateReady, nil)
require.NoError(t, err)
require.ErrorIs(t, integration.ShareIntoChat(ctx, owner, chatID, []*blobpb.BlobId{id}), blob.ErrBlobNotShareable)
})

t.Run("an unknown blob is rejected", func(t *testing.T) {
require.ErrorIs(t, integration.ShareIntoChat(ctx, owner, chatID, []*blobpb.BlobId{newBlobID(t)}), blob.ErrBlobNotShareable)
})
}

func TestIntegration_Resolve(t *testing.T) {
ctx := context.Background()
store := memory.NewInMemory()
integration := blob.NewIntegration(store, memory.NewInMemoryStorage(), memory.NewInMemoryAccessStore())

owner := model.MustGenerateUserID()

// A READY blob resolves to its metadata with a fresh download URL.
ready := putReadyOriginal(t, store, owner)
// A pending (non-READY) blob is omitted.
pending := newBlobID(t)
require.NoError(t, store.CreatePending(ctx, &blob.Blob{
ID: pending, Rendition: blob.RenditionOriginal, Owner: owner, State: blob.StatePending,
StorageKey: "k", MimeType: "image/png", SizeBytes: 1,
}))
// An unknown id is omitted.
unknown := newBlobID(t)

meta, err := integration.Resolve(ctx, []*blobpb.BlobId{ready, pending, unknown})
require.NoError(t, err)
require.Len(t, meta, 1)

m := meta[string(ready.Value)]
require.NotNil(t, m)
require.Equal(t, "image/png", m.MimeType)
require.EqualValues(t, 1, m.SizeBytes)
require.NotNil(t, m.DownloadUrl)
require.NotEmpty(t, m.DownloadUrl.Url)

// Empty input yields a nil map.
empty, err := integration.Resolve(ctx, nil)
require.NoError(t, err)
require.Nil(t, empty)
}
8 changes: 4 additions & 4 deletions blob/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,16 +240,16 @@ type Blob struct {
Rejection *RejectionMetadata
}

func newBlobID() (*blobpb.BlobId, error) {
func MustGenerateID() *blobpb.BlobId {
id, err := uuid.NewRandom()
if err != nil {
return nil, err
panic(err)
}
value := id
return &blobpb.BlobId{Value: value[:]}, nil
return &blobpb.BlobId{Value: value[:]}
}

func blobIDString(id *blobpb.BlobId) string {
func IDString(id *blobpb.BlobId) string {
if id == nil {
return "<nil>"
}
Expand Down
26 changes: 13 additions & 13 deletions blob/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,9 @@ func (s *Server) InitiateExternalUpload(ctx context.Context, req *blobpb.Initiat
}, nil
}

id, err := newBlobID()
if err != nil {
log.Warn("Failed to generate blob id", zap.Error(err))
return nil, status.Error(codes.Internal, "failed to initiate upload")
}
id := MustGenerateID()
log = log.With(zap.String("blob_id", IDString(id)))

// The mime type was validated as a supported image above, so this resolves; an
// error here would mean the two lists drifted out of sync.
key, err := StorageKey(id, req.MimeType)
Expand Down Expand Up @@ -222,7 +220,7 @@ func (s *Server) CompleteExternalUpload(ctx context.Context, req *blobpb.Complet

log := s.log.With(
zap.String("owner_id", model.UserIDString(owner)),
zap.String("blob_id", blobIDString(req.BlobId)),
zap.String("blob_id", IDString(req.BlobId)),
)

record, err := s.blobs.GetByID(ctx, req.BlobId)
Expand Down Expand Up @@ -306,7 +304,7 @@ func (s *Server) GetBlobs(ctx context.Context, req *blobpb.GetBlobsRequest) (*bl
allowed, err := s.canRead(ctx, caller, record, req.Context)
if err != nil {
s.log.Warn("Failed to authorize blob",
zap.String("blob_id", blobIDString(record.ID)),
zap.String("blob_id", IDString(record.ID)),
zap.Error(err),
)
return nil, status.Error(codes.Internal, "failed to get blobs")
Expand All @@ -327,10 +325,10 @@ func (s *Server) GetBlobs(ctx context.Context, req *blobpb.GetBlobsRequest) (*bl
case blobpb.BlobStatus_BLOB_STATUS_READY:
// download_url and the rest of the metadata are only meaningful, and only
// minted, for a servable (READY) blob.
metadata, err := s.buildMetadata(ctx, record)
metadata, err := buildMetadata(ctx, s.storage, record)
if err != nil {
s.log.Warn("Failed to mint blob metadata",
zap.String("blob_id", blobIDString(record.ID)),
zap.String("blob_id", IDString(record.ID)),
zap.Error(err),
)
return nil, status.Error(codes.Internal, "failed to get blobs")
Expand Down Expand Up @@ -595,16 +593,18 @@ func (s *Server) currentStatus(ctx context.Context, id *blobpb.BlobId) (blobpb.B
func (s *Server) cleanupUpload(ctx context.Context, record *Blob) {
if err := s.storage.DeleteUpload(ctx, record.StorageKey); err != nil {
s.log.Warn("Failed to delete upload object after finalization",
zap.String("blob_id", blobIDString(record.ID)),
zap.String("blob_id", IDString(record.ID)),
zap.Error(err),
)
}
}

// buildMetadata assembles the server-authoritative metadata for a READY blob,
// minting a fresh, short-lived download URL.
func (s *Server) buildMetadata(ctx context.Context, record *Blob) (*blobpb.BlobMetadata, error) {
downloadURL, err := s.storage.SignDownloadURL(ctx, record.StorageKey)
// minting a fresh, short-lived download URL. It is a free function over an
// ObjectStorage so both the Server (GetBlobs) and Media (Resolve) can mint
// metadata from their own storage without duplicating the logic.
func buildMetadata(ctx context.Context, storage ObjectStorage, record *Blob) (*blobpb.BlobMetadata, error) {
downloadURL, err := storage.SignDownloadURL(ctx, record.StorageKey)
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion blob/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,5 @@ func StorageKey(id *blobpb.BlobId, mimeType string) (string, error) {
// An image kind with no registered extension means the two image maps drifted.
return "", fmt.Errorf("missing extension for image mime type %q", mimeType)
}
return fmt.Sprintf("images/%s/original%s", blobIDString(id), ext), nil
return fmt.Sprintf("images/%s/original%s", IDString(id), ext), nil
}
Loading
Loading