From a24f1e10ea1a513ccfd14f026174b9a50e360b0a Mon Sep 17 00:00:00 2001 From: SourceSensei Date: Tue, 8 Sep 2026 17:45:35 +0100 Subject: [PATCH] chore: promote verified development snapshot to staging --- backend/cmd/api/config.go | 14 +- backend/cmd/api/main.go | 12 +- backend/cmd/api/media_config_test.go | 33 ++ backend/ent/listingmedia.go | 74 ++- backend/ent/listingmedia/listingmedia.go | 50 ++ backend/ent/listingmedia/where.go | 325 +++++++++++ backend/ent/listingmedia_create.go | 115 ++++ backend/ent/listingmedia_update.go | 370 +++++++++++++ backend/ent/migrate/schema.go | 5 + backend/ent/mutation.go | 510 +++++++++++++++++- backend/ent/runtime.go | 24 +- backend/ent/schema/listingmedia.go | 5 + backend/go.mod | 7 +- backend/go.sum | 46 +- backend/internal/httpapi/media_handler.go | 146 +++++ .../internal/httpapi/media_handler_test.go | 106 ++++ .../internal/listingmedia/ent_finalization.go | 125 +++++ backend/internal/listingmedia/ent_reader.go | 103 ++++ .../internal/listingmedia/ent_repository.go | 43 +- backend/internal/listingmedia/finalizer.go | 77 +++ .../internal/listingmedia/finalizer_test.go | 87 +++ .../internal/listingmedia/image_sanitizer.go | 60 +++ .../listingmedia/image_sanitizer_test.go | 98 ++++ backend/internal/listingmedia/model.go | 1 + backend/internal/listingmedia/reader.go | 146 +++++ backend/internal/listingmedia/reader_test.go | 85 +++ backend/internal/listingmedia/repository.go | 14 + backend/internal/listingmedia/service.go | 22 +- backend/internal/listingmedia/service_test.go | 18 + .../listingmedia/supabase_finalization.go | 106 ++++ .../supabase_finalization_test.go | 146 +++++ .../internal/listingmedia/supabase_reader.go | 16 + .../listingmedia/supabase_reader_test.go | 49 ++ .../internal/listingmedia/supabase_storage.go | 92 ++++ .../listingmedia/supabase_storage_test.go | 111 ++++ .../listingmedia/upload_retry_test.go | 28 + .../listings/listingmedia_repository_test.go | 15 + .../media_finalization_integration_test.go | 192 +++++++ .../listings/media_retry_integration_test.go | 28 + .../refund_integrity_integration_test.go | 149 +++++ backend/internal/payments/sql_store.go | 106 +++- .../payments/sql_store_integration_test.go | 2 +- backend/internal/payments/stripe_gateway.go | 3 + .../webhook_integrity_integration_test.go | 79 +++ .../payments/webhook_projection_test.go | 10 + .../webhook_replay_integration_test.go | 41 ++ ...026-09-07-vila-launch-payment-integrity.md | 53 ++ .../plans/2026-09-07-vila-supabase-media.md | 38 ++ frontend/messages/en.json | 15 + frontend/messages/es.json | 15 + frontend/messages/pt-PT.json | 15 + .../app/[locale]/moderation/listings/page.tsx | 2 +- .../media/[mediaId]/finalize/route.ts | 11 + .../[listingId]/media/[mediaId]/route.ts | 11 + .../v1/me/listings/[listingId]/media/route.ts | 11 + .../[listingId]/media/upload-intents/route.ts | 11 + .../[listingId]/media/[mediaId]/route.ts | 11 + .../listings/[listingId]/media/route.ts | 11 + .../[listingId]/media/[mediaId]/route.ts | 11 + .../listings/[listingId]/media/route.ts | 11 + .../discovery/public-listing-detail.test.tsx | 8 + .../discovery/public-listing-detail.tsx | 8 + .../listing-media/listing-photos.test.tsx | 123 +++++ .../features/listing-media/listing-photos.tsx | 217 ++++++++ .../listing-media/media-contract.test.ts | 71 +++ .../features/listing-media/media-contract.ts | 125 +++++ .../src/features/listing-media/media-copy.ts | 13 + .../listing-media/media-proxy.test.ts | 130 +++++ .../src/features/listing-media/media-proxy.ts | 217 ++++++++ .../listing-media/media-upload.test.ts | 72 +++ .../features/listing-media/media-upload.ts | 75 +++ .../listings/listing-dashboard.test.tsx | 3 + .../features/listings/listing-dashboard.tsx | 15 +- .../listings/moderation-queue.test.tsx | 3 + .../features/listings/moderation-queue.tsx | 16 +- frontend/src/shared/api/generated/index.ts | 4 +- frontend/src/shared/api/generated/sdk.gen.ts | 57 +- .../src/shared/api/generated/types.gen.ts | 328 +++++++++++ openapi/juntly-api.v1.yaml | 103 ++++ .../20260907140000_verify_listing_media.sql | 23 + 80 files changed, 5666 insertions(+), 65 deletions(-) create mode 100644 backend/cmd/api/media_config_test.go create mode 100644 backend/internal/httpapi/media_handler.go create mode 100644 backend/internal/httpapi/media_handler_test.go create mode 100644 backend/internal/listingmedia/ent_finalization.go create mode 100644 backend/internal/listingmedia/ent_reader.go create mode 100644 backend/internal/listingmedia/finalizer.go create mode 100644 backend/internal/listingmedia/finalizer_test.go create mode 100644 backend/internal/listingmedia/image_sanitizer.go create mode 100644 backend/internal/listingmedia/image_sanitizer_test.go create mode 100644 backend/internal/listingmedia/reader.go create mode 100644 backend/internal/listingmedia/reader_test.go create mode 100644 backend/internal/listingmedia/supabase_finalization.go create mode 100644 backend/internal/listingmedia/supabase_finalization_test.go create mode 100644 backend/internal/listingmedia/supabase_reader.go create mode 100644 backend/internal/listingmedia/supabase_reader_test.go create mode 100644 backend/internal/listingmedia/supabase_storage.go create mode 100644 backend/internal/listingmedia/supabase_storage_test.go create mode 100644 backend/internal/listingmedia/upload_retry_test.go create mode 100644 backend/internal/listings/media_finalization_integration_test.go create mode 100644 backend/internal/listings/media_retry_integration_test.go create mode 100644 backend/internal/payments/refund_integrity_integration_test.go create mode 100644 backend/internal/payments/webhook_integrity_integration_test.go create mode 100644 backend/internal/payments/webhook_projection_test.go create mode 100644 backend/internal/payments/webhook_replay_integration_test.go create mode 100644 docs/superpowers/plans/2026-09-07-vila-launch-payment-integrity.md create mode 100644 docs/superpowers/plans/2026-09-07-vila-supabase-media.md create mode 100644 frontend/src/app/api/v1/me/listings/[listingId]/media/[mediaId]/finalize/route.ts create mode 100644 frontend/src/app/api/v1/me/listings/[listingId]/media/[mediaId]/route.ts create mode 100644 frontend/src/app/api/v1/me/listings/[listingId]/media/route.ts create mode 100644 frontend/src/app/api/v1/me/listings/[listingId]/media/upload-intents/route.ts create mode 100644 frontend/src/app/api/v1/moderation/listings/[listingId]/media/[mediaId]/route.ts create mode 100644 frontend/src/app/api/v1/moderation/listings/[listingId]/media/route.ts create mode 100644 frontend/src/app/api/v1/public/listings/[listingId]/media/[mediaId]/route.ts create mode 100644 frontend/src/app/api/v1/public/listings/[listingId]/media/route.ts create mode 100644 frontend/src/features/listing-media/listing-photos.test.tsx create mode 100644 frontend/src/features/listing-media/listing-photos.tsx create mode 100644 frontend/src/features/listing-media/media-contract.test.ts create mode 100644 frontend/src/features/listing-media/media-contract.ts create mode 100644 frontend/src/features/listing-media/media-copy.ts create mode 100644 frontend/src/features/listing-media/media-proxy.test.ts create mode 100644 frontend/src/features/listing-media/media-proxy.ts create mode 100644 frontend/src/features/listing-media/media-upload.test.ts create mode 100644 frontend/src/features/listing-media/media-upload.ts create mode 100644 supabase/migrations/20260907140000_verify_listing_media.sql diff --git a/backend/cmd/api/config.go b/backend/cmd/api/config.go index 7343081..910b928 100644 --- a/backend/cmd/api/config.go +++ b/backend/cmd/api/config.go @@ -8,6 +8,7 @@ import ( "github.com/SourceSenseiTheRealOne/juntly/backend/internal/authn" "github.com/SourceSenseiTheRealOne/juntly/backend/internal/contactreveal" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/listingmedia" "github.com/SourceSenseiTheRealOne/juntly/backend/internal/payments" ) @@ -19,6 +20,7 @@ type runtimeConfig struct { contactCipher contactreveal.Cipher paymentGateway payments.Gateway platformFeeBPS int + mediaStorage listingmedia.ManagedStorage } func loadRuntimeConfig(lookup func(string) string) (runtimeConfig, error) { @@ -71,7 +73,17 @@ func loadRuntimeConfig(lookup func(string) string) (runtimeConfig, error) { } } - return runtimeConfig{databaseURL: databaseURL, verifier: verifier, contactCipher: contactCipher, paymentGateway: paymentGateway, platformFeeBPS: platformFeeBPS}, nil + var mediaStorage listingmedia.ManagedStorage + storageOrigin := strings.TrimSpace(lookup("JUNTLY_STORAGE_ORIGIN")) + storageKey := lookup("JUNTLY_STORAGE_SERVER_KEY") + storageBucket := strings.TrimSpace(lookup("JUNTLY_STORAGE_BUCKET")) + if storageOrigin != "" || storageKey != "" || storageBucket != "" { + mediaStorage, err = listingmedia.NewSupabaseStorage(listingmedia.SupabaseStorageConfig{Origin: storageOrigin, ServerKey: storageKey, Bucket: storageBucket}) + if err != nil { + return runtimeConfig{}, ErrInvalidRuntimeConfig + } + } + return runtimeConfig{databaseURL: databaseURL, verifier: verifier, contactCipher: contactCipher, paymentGateway: paymentGateway, platformFeeBPS: platformFeeBPS, mediaStorage: mediaStorage}, nil } func parseOptionalDuration(value string) (time.Duration, error) { diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index d1c13d6..6c33375 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -137,9 +137,17 @@ func newAPIHandler(config runtimeConfig) (http.Handler, io.Closer, error) { moderatorAuthorizer := moderation.NewService(userService, moderation.NewEntRepository(client)) paymentService := payments.NewService(userService, moderatorAuthorizer, payments.NewSQLStore(database), config.paymentGateway, config.platformFeeBPS) listingLifecycle := listings.NewLifecycleService(providerAuthorizer, moderatorAuthorizer, listingRepository) - listingMedia := listingmedia.NewService(providerAuthorizer, listingmedia.NewEntRepository(client), listingmedia.NewUnavailableStorage()) + mediaRepository := listingmedia.NewEntRepository(client) + var uploadStorage listingmedia.Storage = listingmedia.NewUnavailableStorage() + if config.mediaStorage != nil { + uploadStorage = config.mediaStorage + } + listingMedia := listingmedia.NewService(providerAuthorizer, mediaRepository, uploadStorage) + mediaFinalizer := listingmedia.NewFinalizer(providerAuthorizer, mediaRepository, config.mediaStorage) + mediaReader := listingmedia.NewReader(userService, mediaRepository, config.mediaStorage) ownerListings := listings.NewOwnerService(listingDrafts, listingLifecycle, listingMedia) moderationQueue := moderation.NewQueueService(moderatorAuthorizer, listingRepository) moderationReview := moderation.NewReviewService(moderationQueue, listingLifecycle) - return httpapi.NewRouter(healthService, readinessService, config.verifier, userService, accountService, referenceService, providerService, ownerListings, moderationReview, publicDiscovery, contactChannels, contactReveal, messagingService, quotationService, bookingService, reviewService, entitlementService, administrationService, paymentService), client, nil + router := httpapi.NewRouter(healthService, readinessService, config.verifier, userService, accountService, referenceService, providerService, ownerListings, moderationReview, publicDiscovery, contactChannels, contactReveal, messagingService, quotationService, bookingService, reviewService, entitlementService, administrationService, paymentService) + return httpapi.NewMediaRouter(router, config.verifier, mediaReader, mediaFinalizer), client, nil } diff --git a/backend/cmd/api/media_config_test.go b/backend/cmd/api/media_config_test.go new file mode 100644 index 0000000..31179cf --- /dev/null +++ b/backend/cmd/api/media_config_test.go @@ -0,0 +1,33 @@ +package main + +import "testing" + +func TestMediaRuntimeConfigurationIsAllOrNothing(t *testing.T) { + base := map[string]string{"DATABASE_URL": "postgresql://synthetic", "CLERK_SECRET_KEY": "synthetic-secret", "CLERK_AUTHORIZED_PARTIES": "http://localhost:4200"} + storage := map[string]string{"JUNTLY_STORAGE_ORIGIN": "https://storage.example.test", "JUNTLY_STORAGE_SERVER_KEY": "unit-test-key", "JUNTLY_STORAGE_BUCKET": "listing-images"} + for _, missing := range []string{"all", "", "JUNTLY_STORAGE_ORIGIN", "JUNTLY_STORAGE_SERVER_KEY", "JUNTLY_STORAGE_BUCKET"} { + t.Run(missing, func(t *testing.T) { + config, err := loadRuntimeConfig(func(key string) string { + if v, ok := base[key]; ok { + return v + } + if missing == "all" || key == missing { + return "" + } + return storage[key] + }) + if missing != "" && missing != "all" { + if err == nil { + t.Fatal("partial storage config accepted") + } + return + } + if err != nil { + t.Fatal(err) + } + if (config.mediaStorage != nil) != (missing == "") { + t.Fatal("storage activation did not match complete explicit configuration") + } + }) + } +} diff --git a/backend/ent/listingmedia.go b/backend/ent/listingmedia.go index 4b643e4..e7b3b10 100644 --- a/backend/ent/listingmedia.go +++ b/backend/ent/listingmedia.go @@ -30,6 +30,16 @@ type ListingMedia struct { ChecksumSha256 string `json:"checksum_sha256,omitempty"` // ObjectReference holds the value of the "object_reference" field. ObjectReference string `json:"object_reference,omitempty"` + // VerifiedObjectReference holds the value of the "verified_object_reference" field. + VerifiedObjectReference *string `json:"verified_object_reference,omitempty"` + // VerifiedChecksumSha256 holds the value of the "verified_checksum_sha256" field. + VerifiedChecksumSha256 *string `json:"verified_checksum_sha256,omitempty"` + // VerifiedByteSize holds the value of the "verified_byte_size" field. + VerifiedByteSize *int64 `json:"verified_byte_size,omitempty"` + // PixelWidth holds the value of the "pixel_width" field. + PixelWidth *int `json:"pixel_width,omitempty"` + // PixelHeight holds the value of the "pixel_height" field. + PixelHeight *int `json:"pixel_height,omitempty"` // State holds the value of the "state" field. State listingmedia.State `json:"state,omitempty"` // CreatedAt holds the value of the "created_at" field. @@ -44,9 +54,9 @@ func (*ListingMedia) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case listingmedia.FieldOrdinal, listingmedia.FieldByteSize: + case listingmedia.FieldOrdinal, listingmedia.FieldByteSize, listingmedia.FieldVerifiedByteSize, listingmedia.FieldPixelWidth, listingmedia.FieldPixelHeight: values[i] = new(sql.NullInt64) - case listingmedia.FieldContentType, listingmedia.FieldChecksumSha256, listingmedia.FieldObjectReference, listingmedia.FieldState: + case listingmedia.FieldContentType, listingmedia.FieldChecksumSha256, listingmedia.FieldObjectReference, listingmedia.FieldVerifiedObjectReference, listingmedia.FieldVerifiedChecksumSha256, listingmedia.FieldState: values[i] = new(sql.NullString) case listingmedia.FieldCreatedAt, listingmedia.FieldUpdatedAt: values[i] = new(sql.NullTime) @@ -109,6 +119,41 @@ func (_m *ListingMedia) assignValues(columns []string, values []any) error { } else if value.Valid { _m.ObjectReference = value.String } + case listingmedia.FieldVerifiedObjectReference: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field verified_object_reference", values[i]) + } else if value.Valid { + _m.VerifiedObjectReference = new(string) + *_m.VerifiedObjectReference = value.String + } + case listingmedia.FieldVerifiedChecksumSha256: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field verified_checksum_sha256", values[i]) + } else if value.Valid { + _m.VerifiedChecksumSha256 = new(string) + *_m.VerifiedChecksumSha256 = value.String + } + case listingmedia.FieldVerifiedByteSize: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field verified_byte_size", values[i]) + } else if value.Valid { + _m.VerifiedByteSize = new(int64) + *_m.VerifiedByteSize = value.Int64 + } + case listingmedia.FieldPixelWidth: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field pixel_width", values[i]) + } else if value.Valid { + _m.PixelWidth = new(int) + *_m.PixelWidth = int(value.Int64) + } + case listingmedia.FieldPixelHeight: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field pixel_height", values[i]) + } else if value.Valid { + _m.PixelHeight = new(int) + *_m.PixelHeight = int(value.Int64) + } case listingmedia.FieldState: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field state", values[i]) @@ -181,6 +226,31 @@ func (_m *ListingMedia) String() string { builder.WriteString("object_reference=") builder.WriteString(_m.ObjectReference) builder.WriteString(", ") + if v := _m.VerifiedObjectReference; v != nil { + builder.WriteString("verified_object_reference=") + builder.WriteString(*v) + } + builder.WriteString(", ") + if v := _m.VerifiedChecksumSha256; v != nil { + builder.WriteString("verified_checksum_sha256=") + builder.WriteString(*v) + } + builder.WriteString(", ") + if v := _m.VerifiedByteSize; v != nil { + builder.WriteString("verified_byte_size=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.PixelWidth; v != nil { + builder.WriteString("pixel_width=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.PixelHeight; v != nil { + builder.WriteString("pixel_height=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("state=") builder.WriteString(fmt.Sprintf("%v", _m.State)) builder.WriteString(", ") diff --git a/backend/ent/listingmedia/listingmedia.go b/backend/ent/listingmedia/listingmedia.go index 139cbd1..90c7a7c 100644 --- a/backend/ent/listingmedia/listingmedia.go +++ b/backend/ent/listingmedia/listingmedia.go @@ -27,6 +27,16 @@ const ( FieldChecksumSha256 = "checksum_sha256" // FieldObjectReference holds the string denoting the object_reference field in the database. FieldObjectReference = "object_reference" + // FieldVerifiedObjectReference holds the string denoting the verified_object_reference field in the database. + FieldVerifiedObjectReference = "verified_object_reference" + // FieldVerifiedChecksumSha256 holds the string denoting the verified_checksum_sha256 field in the database. + FieldVerifiedChecksumSha256 = "verified_checksum_sha256" + // FieldVerifiedByteSize holds the string denoting the verified_byte_size field in the database. + FieldVerifiedByteSize = "verified_byte_size" + // FieldPixelWidth holds the string denoting the pixel_width field in the database. + FieldPixelWidth = "pixel_width" + // FieldPixelHeight holds the string denoting the pixel_height field in the database. + FieldPixelHeight = "pixel_height" // FieldState holds the string denoting the state field in the database. FieldState = "state" // FieldCreatedAt holds the string denoting the created_at field in the database. @@ -46,6 +56,11 @@ var Columns = []string{ FieldByteSize, FieldChecksumSha256, FieldObjectReference, + FieldVerifiedObjectReference, + FieldVerifiedChecksumSha256, + FieldVerifiedByteSize, + FieldPixelWidth, + FieldPixelHeight, FieldState, FieldCreatedAt, FieldUpdatedAt, @@ -72,6 +87,16 @@ var ( ChecksumSha256Validator func(string) error // ObjectReferenceValidator is a validator for the "object_reference" field. It is called by the builders before save. ObjectReferenceValidator func(string) error + // VerifiedObjectReferenceValidator is a validator for the "verified_object_reference" field. It is called by the builders before save. + VerifiedObjectReferenceValidator func(string) error + // VerifiedChecksumSha256Validator is a validator for the "verified_checksum_sha256" field. It is called by the builders before save. + VerifiedChecksumSha256Validator func(string) error + // VerifiedByteSizeValidator is a validator for the "verified_byte_size" field. It is called by the builders before save. + VerifiedByteSizeValidator func(int64) error + // PixelWidthValidator is a validator for the "pixel_width" field. It is called by the builders before save. + PixelWidthValidator func(int) error + // PixelHeightValidator is a validator for the "pixel_height" field. It is called by the builders before save. + PixelHeightValidator func(int) error // DefaultCreatedAt holds the default value on creation for the "created_at" field. DefaultCreatedAt func() time.Time // DefaultUpdatedAt holds the default value on creation for the "updated_at" field. @@ -147,6 +172,31 @@ func ByObjectReference(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldObjectReference, opts...).ToFunc() } +// ByVerifiedObjectReference orders the results by the verified_object_reference field. +func ByVerifiedObjectReference(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVerifiedObjectReference, opts...).ToFunc() +} + +// ByVerifiedChecksumSha256 orders the results by the verified_checksum_sha256 field. +func ByVerifiedChecksumSha256(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVerifiedChecksumSha256, opts...).ToFunc() +} + +// ByVerifiedByteSize orders the results by the verified_byte_size field. +func ByVerifiedByteSize(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVerifiedByteSize, opts...).ToFunc() +} + +// ByPixelWidth orders the results by the pixel_width field. +func ByPixelWidth(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPixelWidth, opts...).ToFunc() +} + +// ByPixelHeight orders the results by the pixel_height field. +func ByPixelHeight(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPixelHeight, opts...).ToFunc() +} + // ByState orders the results by the state field. func ByState(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldState, opts...).ToFunc() diff --git a/backend/ent/listingmedia/where.go b/backend/ent/listingmedia/where.go index 88418f4..3a4381b 100644 --- a/backend/ent/listingmedia/where.go +++ b/backend/ent/listingmedia/where.go @@ -85,6 +85,31 @@ func ObjectReference(v string) predicate.ListingMedia { return predicate.ListingMedia(sql.FieldEQ(FieldObjectReference, v)) } +// VerifiedObjectReference applies equality check predicate on the "verified_object_reference" field. It's identical to VerifiedObjectReferenceEQ. +func VerifiedObjectReference(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldEQ(FieldVerifiedObjectReference, v)) +} + +// VerifiedChecksumSha256 applies equality check predicate on the "verified_checksum_sha256" field. It's identical to VerifiedChecksumSha256EQ. +func VerifiedChecksumSha256(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldEQ(FieldVerifiedChecksumSha256, v)) +} + +// VerifiedByteSize applies equality check predicate on the "verified_byte_size" field. It's identical to VerifiedByteSizeEQ. +func VerifiedByteSize(v int64) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldEQ(FieldVerifiedByteSize, v)) +} + +// PixelWidth applies equality check predicate on the "pixel_width" field. It's identical to PixelWidthEQ. +func PixelWidth(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldEQ(FieldPixelWidth, v)) +} + +// PixelHeight applies equality check predicate on the "pixel_height" field. It's identical to PixelHeightEQ. +func PixelHeight(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldEQ(FieldPixelHeight, v)) +} + // CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. func CreatedAt(v time.Time) predicate.ListingMedia { return predicate.ListingMedia(sql.FieldEQ(FieldCreatedAt, v)) @@ -410,6 +435,306 @@ func ObjectReferenceContainsFold(v string) predicate.ListingMedia { return predicate.ListingMedia(sql.FieldContainsFold(FieldObjectReference, v)) } +// VerifiedObjectReferenceEQ applies the EQ predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceEQ(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldEQ(FieldVerifiedObjectReference, v)) +} + +// VerifiedObjectReferenceNEQ applies the NEQ predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceNEQ(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNEQ(FieldVerifiedObjectReference, v)) +} + +// VerifiedObjectReferenceIn applies the In predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceIn(vs ...string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldIn(FieldVerifiedObjectReference, vs...)) +} + +// VerifiedObjectReferenceNotIn applies the NotIn predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceNotIn(vs ...string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNotIn(FieldVerifiedObjectReference, vs...)) +} + +// VerifiedObjectReferenceGT applies the GT predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceGT(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldGT(FieldVerifiedObjectReference, v)) +} + +// VerifiedObjectReferenceGTE applies the GTE predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceGTE(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldGTE(FieldVerifiedObjectReference, v)) +} + +// VerifiedObjectReferenceLT applies the LT predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceLT(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldLT(FieldVerifiedObjectReference, v)) +} + +// VerifiedObjectReferenceLTE applies the LTE predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceLTE(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldLTE(FieldVerifiedObjectReference, v)) +} + +// VerifiedObjectReferenceContains applies the Contains predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceContains(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldContains(FieldVerifiedObjectReference, v)) +} + +// VerifiedObjectReferenceHasPrefix applies the HasPrefix predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceHasPrefix(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldHasPrefix(FieldVerifiedObjectReference, v)) +} + +// VerifiedObjectReferenceHasSuffix applies the HasSuffix predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceHasSuffix(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldHasSuffix(FieldVerifiedObjectReference, v)) +} + +// VerifiedObjectReferenceIsNil applies the IsNil predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceIsNil() predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldIsNull(FieldVerifiedObjectReference)) +} + +// VerifiedObjectReferenceNotNil applies the NotNil predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceNotNil() predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNotNull(FieldVerifiedObjectReference)) +} + +// VerifiedObjectReferenceEqualFold applies the EqualFold predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceEqualFold(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldEqualFold(FieldVerifiedObjectReference, v)) +} + +// VerifiedObjectReferenceContainsFold applies the ContainsFold predicate on the "verified_object_reference" field. +func VerifiedObjectReferenceContainsFold(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldContainsFold(FieldVerifiedObjectReference, v)) +} + +// VerifiedChecksumSha256EQ applies the EQ predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256EQ(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldEQ(FieldVerifiedChecksumSha256, v)) +} + +// VerifiedChecksumSha256NEQ applies the NEQ predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256NEQ(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNEQ(FieldVerifiedChecksumSha256, v)) +} + +// VerifiedChecksumSha256In applies the In predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256In(vs ...string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldIn(FieldVerifiedChecksumSha256, vs...)) +} + +// VerifiedChecksumSha256NotIn applies the NotIn predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256NotIn(vs ...string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNotIn(FieldVerifiedChecksumSha256, vs...)) +} + +// VerifiedChecksumSha256GT applies the GT predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256GT(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldGT(FieldVerifiedChecksumSha256, v)) +} + +// VerifiedChecksumSha256GTE applies the GTE predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256GTE(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldGTE(FieldVerifiedChecksumSha256, v)) +} + +// VerifiedChecksumSha256LT applies the LT predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256LT(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldLT(FieldVerifiedChecksumSha256, v)) +} + +// VerifiedChecksumSha256LTE applies the LTE predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256LTE(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldLTE(FieldVerifiedChecksumSha256, v)) +} + +// VerifiedChecksumSha256Contains applies the Contains predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256Contains(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldContains(FieldVerifiedChecksumSha256, v)) +} + +// VerifiedChecksumSha256HasPrefix applies the HasPrefix predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256HasPrefix(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldHasPrefix(FieldVerifiedChecksumSha256, v)) +} + +// VerifiedChecksumSha256HasSuffix applies the HasSuffix predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256HasSuffix(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldHasSuffix(FieldVerifiedChecksumSha256, v)) +} + +// VerifiedChecksumSha256IsNil applies the IsNil predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256IsNil() predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldIsNull(FieldVerifiedChecksumSha256)) +} + +// VerifiedChecksumSha256NotNil applies the NotNil predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256NotNil() predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNotNull(FieldVerifiedChecksumSha256)) +} + +// VerifiedChecksumSha256EqualFold applies the EqualFold predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256EqualFold(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldEqualFold(FieldVerifiedChecksumSha256, v)) +} + +// VerifiedChecksumSha256ContainsFold applies the ContainsFold predicate on the "verified_checksum_sha256" field. +func VerifiedChecksumSha256ContainsFold(v string) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldContainsFold(FieldVerifiedChecksumSha256, v)) +} + +// VerifiedByteSizeEQ applies the EQ predicate on the "verified_byte_size" field. +func VerifiedByteSizeEQ(v int64) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldEQ(FieldVerifiedByteSize, v)) +} + +// VerifiedByteSizeNEQ applies the NEQ predicate on the "verified_byte_size" field. +func VerifiedByteSizeNEQ(v int64) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNEQ(FieldVerifiedByteSize, v)) +} + +// VerifiedByteSizeIn applies the In predicate on the "verified_byte_size" field. +func VerifiedByteSizeIn(vs ...int64) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldIn(FieldVerifiedByteSize, vs...)) +} + +// VerifiedByteSizeNotIn applies the NotIn predicate on the "verified_byte_size" field. +func VerifiedByteSizeNotIn(vs ...int64) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNotIn(FieldVerifiedByteSize, vs...)) +} + +// VerifiedByteSizeGT applies the GT predicate on the "verified_byte_size" field. +func VerifiedByteSizeGT(v int64) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldGT(FieldVerifiedByteSize, v)) +} + +// VerifiedByteSizeGTE applies the GTE predicate on the "verified_byte_size" field. +func VerifiedByteSizeGTE(v int64) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldGTE(FieldVerifiedByteSize, v)) +} + +// VerifiedByteSizeLT applies the LT predicate on the "verified_byte_size" field. +func VerifiedByteSizeLT(v int64) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldLT(FieldVerifiedByteSize, v)) +} + +// VerifiedByteSizeLTE applies the LTE predicate on the "verified_byte_size" field. +func VerifiedByteSizeLTE(v int64) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldLTE(FieldVerifiedByteSize, v)) +} + +// VerifiedByteSizeIsNil applies the IsNil predicate on the "verified_byte_size" field. +func VerifiedByteSizeIsNil() predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldIsNull(FieldVerifiedByteSize)) +} + +// VerifiedByteSizeNotNil applies the NotNil predicate on the "verified_byte_size" field. +func VerifiedByteSizeNotNil() predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNotNull(FieldVerifiedByteSize)) +} + +// PixelWidthEQ applies the EQ predicate on the "pixel_width" field. +func PixelWidthEQ(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldEQ(FieldPixelWidth, v)) +} + +// PixelWidthNEQ applies the NEQ predicate on the "pixel_width" field. +func PixelWidthNEQ(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNEQ(FieldPixelWidth, v)) +} + +// PixelWidthIn applies the In predicate on the "pixel_width" field. +func PixelWidthIn(vs ...int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldIn(FieldPixelWidth, vs...)) +} + +// PixelWidthNotIn applies the NotIn predicate on the "pixel_width" field. +func PixelWidthNotIn(vs ...int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNotIn(FieldPixelWidth, vs...)) +} + +// PixelWidthGT applies the GT predicate on the "pixel_width" field. +func PixelWidthGT(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldGT(FieldPixelWidth, v)) +} + +// PixelWidthGTE applies the GTE predicate on the "pixel_width" field. +func PixelWidthGTE(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldGTE(FieldPixelWidth, v)) +} + +// PixelWidthLT applies the LT predicate on the "pixel_width" field. +func PixelWidthLT(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldLT(FieldPixelWidth, v)) +} + +// PixelWidthLTE applies the LTE predicate on the "pixel_width" field. +func PixelWidthLTE(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldLTE(FieldPixelWidth, v)) +} + +// PixelWidthIsNil applies the IsNil predicate on the "pixel_width" field. +func PixelWidthIsNil() predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldIsNull(FieldPixelWidth)) +} + +// PixelWidthNotNil applies the NotNil predicate on the "pixel_width" field. +func PixelWidthNotNil() predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNotNull(FieldPixelWidth)) +} + +// PixelHeightEQ applies the EQ predicate on the "pixel_height" field. +func PixelHeightEQ(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldEQ(FieldPixelHeight, v)) +} + +// PixelHeightNEQ applies the NEQ predicate on the "pixel_height" field. +func PixelHeightNEQ(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNEQ(FieldPixelHeight, v)) +} + +// PixelHeightIn applies the In predicate on the "pixel_height" field. +func PixelHeightIn(vs ...int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldIn(FieldPixelHeight, vs...)) +} + +// PixelHeightNotIn applies the NotIn predicate on the "pixel_height" field. +func PixelHeightNotIn(vs ...int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNotIn(FieldPixelHeight, vs...)) +} + +// PixelHeightGT applies the GT predicate on the "pixel_height" field. +func PixelHeightGT(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldGT(FieldPixelHeight, v)) +} + +// PixelHeightGTE applies the GTE predicate on the "pixel_height" field. +func PixelHeightGTE(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldGTE(FieldPixelHeight, v)) +} + +// PixelHeightLT applies the LT predicate on the "pixel_height" field. +func PixelHeightLT(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldLT(FieldPixelHeight, v)) +} + +// PixelHeightLTE applies the LTE predicate on the "pixel_height" field. +func PixelHeightLTE(v int) predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldLTE(FieldPixelHeight, v)) +} + +// PixelHeightIsNil applies the IsNil predicate on the "pixel_height" field. +func PixelHeightIsNil() predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldIsNull(FieldPixelHeight)) +} + +// PixelHeightNotNil applies the NotNil predicate on the "pixel_height" field. +func PixelHeightNotNil() predicate.ListingMedia { + return predicate.ListingMedia(sql.FieldNotNull(FieldPixelHeight)) +} + // StateEQ applies the EQ predicate on the "state" field. func StateEQ(v State) predicate.ListingMedia { return predicate.ListingMedia(sql.FieldEQ(FieldState, v)) diff --git a/backend/ent/listingmedia_create.go b/backend/ent/listingmedia_create.go index 79713a5..92fcb8f 100644 --- a/backend/ent/listingmedia_create.go +++ b/backend/ent/listingmedia_create.go @@ -57,6 +57,76 @@ func (_c *ListingMediaCreate) SetObjectReference(v string) *ListingMediaCreate { return _c } +// SetVerifiedObjectReference sets the "verified_object_reference" field. +func (_c *ListingMediaCreate) SetVerifiedObjectReference(v string) *ListingMediaCreate { + _c.mutation.SetVerifiedObjectReference(v) + return _c +} + +// SetNillableVerifiedObjectReference sets the "verified_object_reference" field if the given value is not nil. +func (_c *ListingMediaCreate) SetNillableVerifiedObjectReference(v *string) *ListingMediaCreate { + if v != nil { + _c.SetVerifiedObjectReference(*v) + } + return _c +} + +// SetVerifiedChecksumSha256 sets the "verified_checksum_sha256" field. +func (_c *ListingMediaCreate) SetVerifiedChecksumSha256(v string) *ListingMediaCreate { + _c.mutation.SetVerifiedChecksumSha256(v) + return _c +} + +// SetNillableVerifiedChecksumSha256 sets the "verified_checksum_sha256" field if the given value is not nil. +func (_c *ListingMediaCreate) SetNillableVerifiedChecksumSha256(v *string) *ListingMediaCreate { + if v != nil { + _c.SetVerifiedChecksumSha256(*v) + } + return _c +} + +// SetVerifiedByteSize sets the "verified_byte_size" field. +func (_c *ListingMediaCreate) SetVerifiedByteSize(v int64) *ListingMediaCreate { + _c.mutation.SetVerifiedByteSize(v) + return _c +} + +// SetNillableVerifiedByteSize sets the "verified_byte_size" field if the given value is not nil. +func (_c *ListingMediaCreate) SetNillableVerifiedByteSize(v *int64) *ListingMediaCreate { + if v != nil { + _c.SetVerifiedByteSize(*v) + } + return _c +} + +// SetPixelWidth sets the "pixel_width" field. +func (_c *ListingMediaCreate) SetPixelWidth(v int) *ListingMediaCreate { + _c.mutation.SetPixelWidth(v) + return _c +} + +// SetNillablePixelWidth sets the "pixel_width" field if the given value is not nil. +func (_c *ListingMediaCreate) SetNillablePixelWidth(v *int) *ListingMediaCreate { + if v != nil { + _c.SetPixelWidth(*v) + } + return _c +} + +// SetPixelHeight sets the "pixel_height" field. +func (_c *ListingMediaCreate) SetPixelHeight(v int) *ListingMediaCreate { + _c.mutation.SetPixelHeight(v) + return _c +} + +// SetNillablePixelHeight sets the "pixel_height" field if the given value is not nil. +func (_c *ListingMediaCreate) SetNillablePixelHeight(v *int) *ListingMediaCreate { + if v != nil { + _c.SetPixelHeight(*v) + } + return _c +} + // SetState sets the "state" field. func (_c *ListingMediaCreate) SetState(v listingmedia.State) *ListingMediaCreate { _c.mutation.SetState(v) @@ -211,6 +281,31 @@ func (_c *ListingMediaCreate) check() error { return &ValidationError{Name: "object_reference", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.object_reference": %w`, err)} } } + if v, ok := _c.mutation.VerifiedObjectReference(); ok { + if err := listingmedia.VerifiedObjectReferenceValidator(v); err != nil { + return &ValidationError{Name: "verified_object_reference", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.verified_object_reference": %w`, err)} + } + } + if v, ok := _c.mutation.VerifiedChecksumSha256(); ok { + if err := listingmedia.VerifiedChecksumSha256Validator(v); err != nil { + return &ValidationError{Name: "verified_checksum_sha256", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.verified_checksum_sha256": %w`, err)} + } + } + if v, ok := _c.mutation.VerifiedByteSize(); ok { + if err := listingmedia.VerifiedByteSizeValidator(v); err != nil { + return &ValidationError{Name: "verified_byte_size", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.verified_byte_size": %w`, err)} + } + } + if v, ok := _c.mutation.PixelWidth(); ok { + if err := listingmedia.PixelWidthValidator(v); err != nil { + return &ValidationError{Name: "pixel_width", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.pixel_width": %w`, err)} + } + } + if v, ok := _c.mutation.PixelHeight(); ok { + if err := listingmedia.PixelHeightValidator(v); err != nil { + return &ValidationError{Name: "pixel_height", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.pixel_height": %w`, err)} + } + } if _, ok := _c.mutation.State(); !ok { return &ValidationError{Name: "state", err: errors.New(`ent: missing required field "ListingMedia.state"`)} } @@ -284,6 +379,26 @@ func (_c *ListingMediaCreate) createSpec() (*ListingMedia, *sqlgraph.CreateSpec) _spec.SetField(listingmedia.FieldObjectReference, field.TypeString, value) _node.ObjectReference = value } + if value, ok := _c.mutation.VerifiedObjectReference(); ok { + _spec.SetField(listingmedia.FieldVerifiedObjectReference, field.TypeString, value) + _node.VerifiedObjectReference = &value + } + if value, ok := _c.mutation.VerifiedChecksumSha256(); ok { + _spec.SetField(listingmedia.FieldVerifiedChecksumSha256, field.TypeString, value) + _node.VerifiedChecksumSha256 = &value + } + if value, ok := _c.mutation.VerifiedByteSize(); ok { + _spec.SetField(listingmedia.FieldVerifiedByteSize, field.TypeInt64, value) + _node.VerifiedByteSize = &value + } + if value, ok := _c.mutation.PixelWidth(); ok { + _spec.SetField(listingmedia.FieldPixelWidth, field.TypeInt, value) + _node.PixelWidth = &value + } + if value, ok := _c.mutation.PixelHeight(); ok { + _spec.SetField(listingmedia.FieldPixelHeight, field.TypeInt, value) + _node.PixelHeight = &value + } if value, ok := _c.mutation.State(); ok { _spec.SetField(listingmedia.FieldState, field.TypeEnum, value) _node.State = value diff --git a/backend/ent/listingmedia_update.go b/backend/ent/listingmedia_update.go index 8f404d9..75a7d03 100644 --- a/backend/ent/listingmedia_update.go +++ b/backend/ent/listingmedia_update.go @@ -84,6 +84,127 @@ func (_u *ListingMediaUpdate) AddByteSize(v int64) *ListingMediaUpdate { return _u } +// SetVerifiedObjectReference sets the "verified_object_reference" field. +func (_u *ListingMediaUpdate) SetVerifiedObjectReference(v string) *ListingMediaUpdate { + _u.mutation.SetVerifiedObjectReference(v) + return _u +} + +// SetNillableVerifiedObjectReference sets the "verified_object_reference" field if the given value is not nil. +func (_u *ListingMediaUpdate) SetNillableVerifiedObjectReference(v *string) *ListingMediaUpdate { + if v != nil { + _u.SetVerifiedObjectReference(*v) + } + return _u +} + +// ClearVerifiedObjectReference clears the value of the "verified_object_reference" field. +func (_u *ListingMediaUpdate) ClearVerifiedObjectReference() *ListingMediaUpdate { + _u.mutation.ClearVerifiedObjectReference() + return _u +} + +// SetVerifiedChecksumSha256 sets the "verified_checksum_sha256" field. +func (_u *ListingMediaUpdate) SetVerifiedChecksumSha256(v string) *ListingMediaUpdate { + _u.mutation.SetVerifiedChecksumSha256(v) + return _u +} + +// SetNillableVerifiedChecksumSha256 sets the "verified_checksum_sha256" field if the given value is not nil. +func (_u *ListingMediaUpdate) SetNillableVerifiedChecksumSha256(v *string) *ListingMediaUpdate { + if v != nil { + _u.SetVerifiedChecksumSha256(*v) + } + return _u +} + +// ClearVerifiedChecksumSha256 clears the value of the "verified_checksum_sha256" field. +func (_u *ListingMediaUpdate) ClearVerifiedChecksumSha256() *ListingMediaUpdate { + _u.mutation.ClearVerifiedChecksumSha256() + return _u +} + +// SetVerifiedByteSize sets the "verified_byte_size" field. +func (_u *ListingMediaUpdate) SetVerifiedByteSize(v int64) *ListingMediaUpdate { + _u.mutation.ResetVerifiedByteSize() + _u.mutation.SetVerifiedByteSize(v) + return _u +} + +// SetNillableVerifiedByteSize sets the "verified_byte_size" field if the given value is not nil. +func (_u *ListingMediaUpdate) SetNillableVerifiedByteSize(v *int64) *ListingMediaUpdate { + if v != nil { + _u.SetVerifiedByteSize(*v) + } + return _u +} + +// AddVerifiedByteSize adds value to the "verified_byte_size" field. +func (_u *ListingMediaUpdate) AddVerifiedByteSize(v int64) *ListingMediaUpdate { + _u.mutation.AddVerifiedByteSize(v) + return _u +} + +// ClearVerifiedByteSize clears the value of the "verified_byte_size" field. +func (_u *ListingMediaUpdate) ClearVerifiedByteSize() *ListingMediaUpdate { + _u.mutation.ClearVerifiedByteSize() + return _u +} + +// SetPixelWidth sets the "pixel_width" field. +func (_u *ListingMediaUpdate) SetPixelWidth(v int) *ListingMediaUpdate { + _u.mutation.ResetPixelWidth() + _u.mutation.SetPixelWidth(v) + return _u +} + +// SetNillablePixelWidth sets the "pixel_width" field if the given value is not nil. +func (_u *ListingMediaUpdate) SetNillablePixelWidth(v *int) *ListingMediaUpdate { + if v != nil { + _u.SetPixelWidth(*v) + } + return _u +} + +// AddPixelWidth adds value to the "pixel_width" field. +func (_u *ListingMediaUpdate) AddPixelWidth(v int) *ListingMediaUpdate { + _u.mutation.AddPixelWidth(v) + return _u +} + +// ClearPixelWidth clears the value of the "pixel_width" field. +func (_u *ListingMediaUpdate) ClearPixelWidth() *ListingMediaUpdate { + _u.mutation.ClearPixelWidth() + return _u +} + +// SetPixelHeight sets the "pixel_height" field. +func (_u *ListingMediaUpdate) SetPixelHeight(v int) *ListingMediaUpdate { + _u.mutation.ResetPixelHeight() + _u.mutation.SetPixelHeight(v) + return _u +} + +// SetNillablePixelHeight sets the "pixel_height" field if the given value is not nil. +func (_u *ListingMediaUpdate) SetNillablePixelHeight(v *int) *ListingMediaUpdate { + if v != nil { + _u.SetPixelHeight(*v) + } + return _u +} + +// AddPixelHeight adds value to the "pixel_height" field. +func (_u *ListingMediaUpdate) AddPixelHeight(v int) *ListingMediaUpdate { + _u.mutation.AddPixelHeight(v) + return _u +} + +// ClearPixelHeight clears the value of the "pixel_height" field. +func (_u *ListingMediaUpdate) ClearPixelHeight() *ListingMediaUpdate { + _u.mutation.ClearPixelHeight() + return _u +} + // SetState sets the "state" field. func (_u *ListingMediaUpdate) SetState(v listingmedia.State) *ListingMediaUpdate { _u.mutation.SetState(v) @@ -162,6 +283,31 @@ func (_u *ListingMediaUpdate) check() error { return &ValidationError{Name: "byte_size", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.byte_size": %w`, err)} } } + if v, ok := _u.mutation.VerifiedObjectReference(); ok { + if err := listingmedia.VerifiedObjectReferenceValidator(v); err != nil { + return &ValidationError{Name: "verified_object_reference", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.verified_object_reference": %w`, err)} + } + } + if v, ok := _u.mutation.VerifiedChecksumSha256(); ok { + if err := listingmedia.VerifiedChecksumSha256Validator(v); err != nil { + return &ValidationError{Name: "verified_checksum_sha256", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.verified_checksum_sha256": %w`, err)} + } + } + if v, ok := _u.mutation.VerifiedByteSize(); ok { + if err := listingmedia.VerifiedByteSizeValidator(v); err != nil { + return &ValidationError{Name: "verified_byte_size", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.verified_byte_size": %w`, err)} + } + } + if v, ok := _u.mutation.PixelWidth(); ok { + if err := listingmedia.PixelWidthValidator(v); err != nil { + return &ValidationError{Name: "pixel_width", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.pixel_width": %w`, err)} + } + } + if v, ok := _u.mutation.PixelHeight(); ok { + if err := listingmedia.PixelHeightValidator(v); err != nil { + return &ValidationError{Name: "pixel_height", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.pixel_height": %w`, err)} + } + } if v, ok := _u.mutation.State(); ok { if err := listingmedia.StateValidator(v); err != nil { return &ValidationError{Name: "state", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.state": %w`, err)} @@ -197,6 +343,45 @@ func (_u *ListingMediaUpdate) sqlSave(ctx context.Context) (_node int, err error if value, ok := _u.mutation.AddedByteSize(); ok { _spec.AddField(listingmedia.FieldByteSize, field.TypeInt64, value) } + if value, ok := _u.mutation.VerifiedObjectReference(); ok { + _spec.SetField(listingmedia.FieldVerifiedObjectReference, field.TypeString, value) + } + if _u.mutation.VerifiedObjectReferenceCleared() { + _spec.ClearField(listingmedia.FieldVerifiedObjectReference, field.TypeString) + } + if value, ok := _u.mutation.VerifiedChecksumSha256(); ok { + _spec.SetField(listingmedia.FieldVerifiedChecksumSha256, field.TypeString, value) + } + if _u.mutation.VerifiedChecksumSha256Cleared() { + _spec.ClearField(listingmedia.FieldVerifiedChecksumSha256, field.TypeString) + } + if value, ok := _u.mutation.VerifiedByteSize(); ok { + _spec.SetField(listingmedia.FieldVerifiedByteSize, field.TypeInt64, value) + } + if value, ok := _u.mutation.AddedVerifiedByteSize(); ok { + _spec.AddField(listingmedia.FieldVerifiedByteSize, field.TypeInt64, value) + } + if _u.mutation.VerifiedByteSizeCleared() { + _spec.ClearField(listingmedia.FieldVerifiedByteSize, field.TypeInt64) + } + if value, ok := _u.mutation.PixelWidth(); ok { + _spec.SetField(listingmedia.FieldPixelWidth, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedPixelWidth(); ok { + _spec.AddField(listingmedia.FieldPixelWidth, field.TypeInt, value) + } + if _u.mutation.PixelWidthCleared() { + _spec.ClearField(listingmedia.FieldPixelWidth, field.TypeInt) + } + if value, ok := _u.mutation.PixelHeight(); ok { + _spec.SetField(listingmedia.FieldPixelHeight, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedPixelHeight(); ok { + _spec.AddField(listingmedia.FieldPixelHeight, field.TypeInt, value) + } + if _u.mutation.PixelHeightCleared() { + _spec.ClearField(listingmedia.FieldPixelHeight, field.TypeInt) + } if value, ok := _u.mutation.State(); ok { _spec.SetField(listingmedia.FieldState, field.TypeEnum, value) } @@ -279,6 +464,127 @@ func (_u *ListingMediaUpdateOne) AddByteSize(v int64) *ListingMediaUpdateOne { return _u } +// SetVerifiedObjectReference sets the "verified_object_reference" field. +func (_u *ListingMediaUpdateOne) SetVerifiedObjectReference(v string) *ListingMediaUpdateOne { + _u.mutation.SetVerifiedObjectReference(v) + return _u +} + +// SetNillableVerifiedObjectReference sets the "verified_object_reference" field if the given value is not nil. +func (_u *ListingMediaUpdateOne) SetNillableVerifiedObjectReference(v *string) *ListingMediaUpdateOne { + if v != nil { + _u.SetVerifiedObjectReference(*v) + } + return _u +} + +// ClearVerifiedObjectReference clears the value of the "verified_object_reference" field. +func (_u *ListingMediaUpdateOne) ClearVerifiedObjectReference() *ListingMediaUpdateOne { + _u.mutation.ClearVerifiedObjectReference() + return _u +} + +// SetVerifiedChecksumSha256 sets the "verified_checksum_sha256" field. +func (_u *ListingMediaUpdateOne) SetVerifiedChecksumSha256(v string) *ListingMediaUpdateOne { + _u.mutation.SetVerifiedChecksumSha256(v) + return _u +} + +// SetNillableVerifiedChecksumSha256 sets the "verified_checksum_sha256" field if the given value is not nil. +func (_u *ListingMediaUpdateOne) SetNillableVerifiedChecksumSha256(v *string) *ListingMediaUpdateOne { + if v != nil { + _u.SetVerifiedChecksumSha256(*v) + } + return _u +} + +// ClearVerifiedChecksumSha256 clears the value of the "verified_checksum_sha256" field. +func (_u *ListingMediaUpdateOne) ClearVerifiedChecksumSha256() *ListingMediaUpdateOne { + _u.mutation.ClearVerifiedChecksumSha256() + return _u +} + +// SetVerifiedByteSize sets the "verified_byte_size" field. +func (_u *ListingMediaUpdateOne) SetVerifiedByteSize(v int64) *ListingMediaUpdateOne { + _u.mutation.ResetVerifiedByteSize() + _u.mutation.SetVerifiedByteSize(v) + return _u +} + +// SetNillableVerifiedByteSize sets the "verified_byte_size" field if the given value is not nil. +func (_u *ListingMediaUpdateOne) SetNillableVerifiedByteSize(v *int64) *ListingMediaUpdateOne { + if v != nil { + _u.SetVerifiedByteSize(*v) + } + return _u +} + +// AddVerifiedByteSize adds value to the "verified_byte_size" field. +func (_u *ListingMediaUpdateOne) AddVerifiedByteSize(v int64) *ListingMediaUpdateOne { + _u.mutation.AddVerifiedByteSize(v) + return _u +} + +// ClearVerifiedByteSize clears the value of the "verified_byte_size" field. +func (_u *ListingMediaUpdateOne) ClearVerifiedByteSize() *ListingMediaUpdateOne { + _u.mutation.ClearVerifiedByteSize() + return _u +} + +// SetPixelWidth sets the "pixel_width" field. +func (_u *ListingMediaUpdateOne) SetPixelWidth(v int) *ListingMediaUpdateOne { + _u.mutation.ResetPixelWidth() + _u.mutation.SetPixelWidth(v) + return _u +} + +// SetNillablePixelWidth sets the "pixel_width" field if the given value is not nil. +func (_u *ListingMediaUpdateOne) SetNillablePixelWidth(v *int) *ListingMediaUpdateOne { + if v != nil { + _u.SetPixelWidth(*v) + } + return _u +} + +// AddPixelWidth adds value to the "pixel_width" field. +func (_u *ListingMediaUpdateOne) AddPixelWidth(v int) *ListingMediaUpdateOne { + _u.mutation.AddPixelWidth(v) + return _u +} + +// ClearPixelWidth clears the value of the "pixel_width" field. +func (_u *ListingMediaUpdateOne) ClearPixelWidth() *ListingMediaUpdateOne { + _u.mutation.ClearPixelWidth() + return _u +} + +// SetPixelHeight sets the "pixel_height" field. +func (_u *ListingMediaUpdateOne) SetPixelHeight(v int) *ListingMediaUpdateOne { + _u.mutation.ResetPixelHeight() + _u.mutation.SetPixelHeight(v) + return _u +} + +// SetNillablePixelHeight sets the "pixel_height" field if the given value is not nil. +func (_u *ListingMediaUpdateOne) SetNillablePixelHeight(v *int) *ListingMediaUpdateOne { + if v != nil { + _u.SetPixelHeight(*v) + } + return _u +} + +// AddPixelHeight adds value to the "pixel_height" field. +func (_u *ListingMediaUpdateOne) AddPixelHeight(v int) *ListingMediaUpdateOne { + _u.mutation.AddPixelHeight(v) + return _u +} + +// ClearPixelHeight clears the value of the "pixel_height" field. +func (_u *ListingMediaUpdateOne) ClearPixelHeight() *ListingMediaUpdateOne { + _u.mutation.ClearPixelHeight() + return _u +} + // SetState sets the "state" field. func (_u *ListingMediaUpdateOne) SetState(v listingmedia.State) *ListingMediaUpdateOne { _u.mutation.SetState(v) @@ -370,6 +676,31 @@ func (_u *ListingMediaUpdateOne) check() error { return &ValidationError{Name: "byte_size", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.byte_size": %w`, err)} } } + if v, ok := _u.mutation.VerifiedObjectReference(); ok { + if err := listingmedia.VerifiedObjectReferenceValidator(v); err != nil { + return &ValidationError{Name: "verified_object_reference", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.verified_object_reference": %w`, err)} + } + } + if v, ok := _u.mutation.VerifiedChecksumSha256(); ok { + if err := listingmedia.VerifiedChecksumSha256Validator(v); err != nil { + return &ValidationError{Name: "verified_checksum_sha256", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.verified_checksum_sha256": %w`, err)} + } + } + if v, ok := _u.mutation.VerifiedByteSize(); ok { + if err := listingmedia.VerifiedByteSizeValidator(v); err != nil { + return &ValidationError{Name: "verified_byte_size", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.verified_byte_size": %w`, err)} + } + } + if v, ok := _u.mutation.PixelWidth(); ok { + if err := listingmedia.PixelWidthValidator(v); err != nil { + return &ValidationError{Name: "pixel_width", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.pixel_width": %w`, err)} + } + } + if v, ok := _u.mutation.PixelHeight(); ok { + if err := listingmedia.PixelHeightValidator(v); err != nil { + return &ValidationError{Name: "pixel_height", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.pixel_height": %w`, err)} + } + } if v, ok := _u.mutation.State(); ok { if err := listingmedia.StateValidator(v); err != nil { return &ValidationError{Name: "state", err: fmt.Errorf(`ent: validator failed for field "ListingMedia.state": %w`, err)} @@ -422,6 +753,45 @@ func (_u *ListingMediaUpdateOne) sqlSave(ctx context.Context) (_node *ListingMed if value, ok := _u.mutation.AddedByteSize(); ok { _spec.AddField(listingmedia.FieldByteSize, field.TypeInt64, value) } + if value, ok := _u.mutation.VerifiedObjectReference(); ok { + _spec.SetField(listingmedia.FieldVerifiedObjectReference, field.TypeString, value) + } + if _u.mutation.VerifiedObjectReferenceCleared() { + _spec.ClearField(listingmedia.FieldVerifiedObjectReference, field.TypeString) + } + if value, ok := _u.mutation.VerifiedChecksumSha256(); ok { + _spec.SetField(listingmedia.FieldVerifiedChecksumSha256, field.TypeString, value) + } + if _u.mutation.VerifiedChecksumSha256Cleared() { + _spec.ClearField(listingmedia.FieldVerifiedChecksumSha256, field.TypeString) + } + if value, ok := _u.mutation.VerifiedByteSize(); ok { + _spec.SetField(listingmedia.FieldVerifiedByteSize, field.TypeInt64, value) + } + if value, ok := _u.mutation.AddedVerifiedByteSize(); ok { + _spec.AddField(listingmedia.FieldVerifiedByteSize, field.TypeInt64, value) + } + if _u.mutation.VerifiedByteSizeCleared() { + _spec.ClearField(listingmedia.FieldVerifiedByteSize, field.TypeInt64) + } + if value, ok := _u.mutation.PixelWidth(); ok { + _spec.SetField(listingmedia.FieldPixelWidth, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedPixelWidth(); ok { + _spec.AddField(listingmedia.FieldPixelWidth, field.TypeInt, value) + } + if _u.mutation.PixelWidthCleared() { + _spec.ClearField(listingmedia.FieldPixelWidth, field.TypeInt) + } + if value, ok := _u.mutation.PixelHeight(); ok { + _spec.SetField(listingmedia.FieldPixelHeight, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedPixelHeight(); ok { + _spec.AddField(listingmedia.FieldPixelHeight, field.TypeInt, value) + } + if _u.mutation.PixelHeightCleared() { + _spec.ClearField(listingmedia.FieldPixelHeight, field.TypeInt) + } if value, ok := _u.mutation.State(); ok { _spec.SetField(listingmedia.FieldState, field.TypeEnum, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index f85474a..3b9bbc7 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -158,6 +158,11 @@ var ( {Name: "byte_size", Type: field.TypeInt64}, {Name: "checksum_sha256", Type: field.TypeString, Size: 64}, {Name: "object_reference", Type: field.TypeString, Size: 512}, + {Name: "verified_object_reference", Type: field.TypeString, Nullable: true, Size: 512}, + {Name: "verified_checksum_sha256", Type: field.TypeString, Nullable: true, Size: 64}, + {Name: "verified_byte_size", Type: field.TypeInt64, Nullable: true}, + {Name: "pixel_width", Type: field.TypeInt, Nullable: true}, + {Name: "pixel_height", Type: field.TypeInt, Nullable: true}, {Name: "state", Type: field.TypeEnum, Enums: []string{"pending_upload", "ready", "deleted"}, Default: "pending_upload"}, {Name: "created_at", Type: field.TypeTime}, {Name: "updated_at", Type: field.TypeTime}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index c6ada9c..fa9e4c5 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -4668,24 +4668,32 @@ func (m *ListingEventMutation) ResetEdge(name string) error { // ListingMediaMutation represents an operation that mutates the ListingMedia nodes in the graph. type ListingMediaMutation struct { config - op Op - typ string - id *uuid.UUID - listing_id *uuid.UUID - ordinal *int - addordinal *int - content_type *string - byte_size *int64 - addbyte_size *int64 - checksum_sha256 *string - object_reference *string - state *listingmedia.State - created_at *time.Time - updated_at *time.Time - clearedFields map[string]struct{} - done bool - oldValue func(context.Context) (*ListingMedia, error) - predicates []predicate.ListingMedia + op Op + typ string + id *uuid.UUID + listing_id *uuid.UUID + ordinal *int + addordinal *int + content_type *string + byte_size *int64 + addbyte_size *int64 + checksum_sha256 *string + object_reference *string + verified_object_reference *string + verified_checksum_sha256 *string + verified_byte_size *int64 + addverified_byte_size *int64 + pixel_width *int + addpixel_width *int + pixel_height *int + addpixel_height *int + state *listingmedia.State + created_at *time.Time + updated_at *time.Time + clearedFields map[string]struct{} + done bool + oldValue func(context.Context) (*ListingMedia, error) + predicates []predicate.ListingMedia } var _ ent.Mutation = (*ListingMediaMutation)(nil) @@ -5048,6 +5056,314 @@ func (m *ListingMediaMutation) ResetObjectReference() { m.object_reference = nil } +// SetVerifiedObjectReference sets the "verified_object_reference" field. +func (m *ListingMediaMutation) SetVerifiedObjectReference(s string) { + m.verified_object_reference = &s +} + +// VerifiedObjectReference returns the value of the "verified_object_reference" field in the mutation. +func (m *ListingMediaMutation) VerifiedObjectReference() (r string, exists bool) { + v := m.verified_object_reference + if v == nil { + return + } + return *v, true +} + +// OldVerifiedObjectReference returns the old "verified_object_reference" field's value of the ListingMedia entity. +// If the ListingMedia object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ListingMediaMutation) OldVerifiedObjectReference(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVerifiedObjectReference is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVerifiedObjectReference requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVerifiedObjectReference: %w", err) + } + return oldValue.VerifiedObjectReference, nil +} + +// ClearVerifiedObjectReference clears the value of the "verified_object_reference" field. +func (m *ListingMediaMutation) ClearVerifiedObjectReference() { + m.verified_object_reference = nil + m.clearedFields[listingmedia.FieldVerifiedObjectReference] = struct{}{} +} + +// VerifiedObjectReferenceCleared returns if the "verified_object_reference" field was cleared in this mutation. +func (m *ListingMediaMutation) VerifiedObjectReferenceCleared() bool { + _, ok := m.clearedFields[listingmedia.FieldVerifiedObjectReference] + return ok +} + +// ResetVerifiedObjectReference resets all changes to the "verified_object_reference" field. +func (m *ListingMediaMutation) ResetVerifiedObjectReference() { + m.verified_object_reference = nil + delete(m.clearedFields, listingmedia.FieldVerifiedObjectReference) +} + +// SetVerifiedChecksumSha256 sets the "verified_checksum_sha256" field. +func (m *ListingMediaMutation) SetVerifiedChecksumSha256(s string) { + m.verified_checksum_sha256 = &s +} + +// VerifiedChecksumSha256 returns the value of the "verified_checksum_sha256" field in the mutation. +func (m *ListingMediaMutation) VerifiedChecksumSha256() (r string, exists bool) { + v := m.verified_checksum_sha256 + if v == nil { + return + } + return *v, true +} + +// OldVerifiedChecksumSha256 returns the old "verified_checksum_sha256" field's value of the ListingMedia entity. +// If the ListingMedia object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ListingMediaMutation) OldVerifiedChecksumSha256(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVerifiedChecksumSha256 is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVerifiedChecksumSha256 requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVerifiedChecksumSha256: %w", err) + } + return oldValue.VerifiedChecksumSha256, nil +} + +// ClearVerifiedChecksumSha256 clears the value of the "verified_checksum_sha256" field. +func (m *ListingMediaMutation) ClearVerifiedChecksumSha256() { + m.verified_checksum_sha256 = nil + m.clearedFields[listingmedia.FieldVerifiedChecksumSha256] = struct{}{} +} + +// VerifiedChecksumSha256Cleared returns if the "verified_checksum_sha256" field was cleared in this mutation. +func (m *ListingMediaMutation) VerifiedChecksumSha256Cleared() bool { + _, ok := m.clearedFields[listingmedia.FieldVerifiedChecksumSha256] + return ok +} + +// ResetVerifiedChecksumSha256 resets all changes to the "verified_checksum_sha256" field. +func (m *ListingMediaMutation) ResetVerifiedChecksumSha256() { + m.verified_checksum_sha256 = nil + delete(m.clearedFields, listingmedia.FieldVerifiedChecksumSha256) +} + +// SetVerifiedByteSize sets the "verified_byte_size" field. +func (m *ListingMediaMutation) SetVerifiedByteSize(i int64) { + m.verified_byte_size = &i + m.addverified_byte_size = nil +} + +// VerifiedByteSize returns the value of the "verified_byte_size" field in the mutation. +func (m *ListingMediaMutation) VerifiedByteSize() (r int64, exists bool) { + v := m.verified_byte_size + if v == nil { + return + } + return *v, true +} + +// OldVerifiedByteSize returns the old "verified_byte_size" field's value of the ListingMedia entity. +// If the ListingMedia object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ListingMediaMutation) OldVerifiedByteSize(ctx context.Context) (v *int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVerifiedByteSize is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVerifiedByteSize requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVerifiedByteSize: %w", err) + } + return oldValue.VerifiedByteSize, nil +} + +// AddVerifiedByteSize adds i to the "verified_byte_size" field. +func (m *ListingMediaMutation) AddVerifiedByteSize(i int64) { + if m.addverified_byte_size != nil { + *m.addverified_byte_size += i + } else { + m.addverified_byte_size = &i + } +} + +// AddedVerifiedByteSize returns the value that was added to the "verified_byte_size" field in this mutation. +func (m *ListingMediaMutation) AddedVerifiedByteSize() (r int64, exists bool) { + v := m.addverified_byte_size + if v == nil { + return + } + return *v, true +} + +// ClearVerifiedByteSize clears the value of the "verified_byte_size" field. +func (m *ListingMediaMutation) ClearVerifiedByteSize() { + m.verified_byte_size = nil + m.addverified_byte_size = nil + m.clearedFields[listingmedia.FieldVerifiedByteSize] = struct{}{} +} + +// VerifiedByteSizeCleared returns if the "verified_byte_size" field was cleared in this mutation. +func (m *ListingMediaMutation) VerifiedByteSizeCleared() bool { + _, ok := m.clearedFields[listingmedia.FieldVerifiedByteSize] + return ok +} + +// ResetVerifiedByteSize resets all changes to the "verified_byte_size" field. +func (m *ListingMediaMutation) ResetVerifiedByteSize() { + m.verified_byte_size = nil + m.addverified_byte_size = nil + delete(m.clearedFields, listingmedia.FieldVerifiedByteSize) +} + +// SetPixelWidth sets the "pixel_width" field. +func (m *ListingMediaMutation) SetPixelWidth(i int) { + m.pixel_width = &i + m.addpixel_width = nil +} + +// PixelWidth returns the value of the "pixel_width" field in the mutation. +func (m *ListingMediaMutation) PixelWidth() (r int, exists bool) { + v := m.pixel_width + if v == nil { + return + } + return *v, true +} + +// OldPixelWidth returns the old "pixel_width" field's value of the ListingMedia entity. +// If the ListingMedia object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ListingMediaMutation) OldPixelWidth(ctx context.Context) (v *int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPixelWidth is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPixelWidth requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPixelWidth: %w", err) + } + return oldValue.PixelWidth, nil +} + +// AddPixelWidth adds i to the "pixel_width" field. +func (m *ListingMediaMutation) AddPixelWidth(i int) { + if m.addpixel_width != nil { + *m.addpixel_width += i + } else { + m.addpixel_width = &i + } +} + +// AddedPixelWidth returns the value that was added to the "pixel_width" field in this mutation. +func (m *ListingMediaMutation) AddedPixelWidth() (r int, exists bool) { + v := m.addpixel_width + if v == nil { + return + } + return *v, true +} + +// ClearPixelWidth clears the value of the "pixel_width" field. +func (m *ListingMediaMutation) ClearPixelWidth() { + m.pixel_width = nil + m.addpixel_width = nil + m.clearedFields[listingmedia.FieldPixelWidth] = struct{}{} +} + +// PixelWidthCleared returns if the "pixel_width" field was cleared in this mutation. +func (m *ListingMediaMutation) PixelWidthCleared() bool { + _, ok := m.clearedFields[listingmedia.FieldPixelWidth] + return ok +} + +// ResetPixelWidth resets all changes to the "pixel_width" field. +func (m *ListingMediaMutation) ResetPixelWidth() { + m.pixel_width = nil + m.addpixel_width = nil + delete(m.clearedFields, listingmedia.FieldPixelWidth) +} + +// SetPixelHeight sets the "pixel_height" field. +func (m *ListingMediaMutation) SetPixelHeight(i int) { + m.pixel_height = &i + m.addpixel_height = nil +} + +// PixelHeight returns the value of the "pixel_height" field in the mutation. +func (m *ListingMediaMutation) PixelHeight() (r int, exists bool) { + v := m.pixel_height + if v == nil { + return + } + return *v, true +} + +// OldPixelHeight returns the old "pixel_height" field's value of the ListingMedia entity. +// If the ListingMedia object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ListingMediaMutation) OldPixelHeight(ctx context.Context) (v *int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPixelHeight is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPixelHeight requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPixelHeight: %w", err) + } + return oldValue.PixelHeight, nil +} + +// AddPixelHeight adds i to the "pixel_height" field. +func (m *ListingMediaMutation) AddPixelHeight(i int) { + if m.addpixel_height != nil { + *m.addpixel_height += i + } else { + m.addpixel_height = &i + } +} + +// AddedPixelHeight returns the value that was added to the "pixel_height" field in this mutation. +func (m *ListingMediaMutation) AddedPixelHeight() (r int, exists bool) { + v := m.addpixel_height + if v == nil { + return + } + return *v, true +} + +// ClearPixelHeight clears the value of the "pixel_height" field. +func (m *ListingMediaMutation) ClearPixelHeight() { + m.pixel_height = nil + m.addpixel_height = nil + m.clearedFields[listingmedia.FieldPixelHeight] = struct{}{} +} + +// PixelHeightCleared returns if the "pixel_height" field was cleared in this mutation. +func (m *ListingMediaMutation) PixelHeightCleared() bool { + _, ok := m.clearedFields[listingmedia.FieldPixelHeight] + return ok +} + +// ResetPixelHeight resets all changes to the "pixel_height" field. +func (m *ListingMediaMutation) ResetPixelHeight() { + m.pixel_height = nil + m.addpixel_height = nil + delete(m.clearedFields, listingmedia.FieldPixelHeight) +} + // SetState sets the "state" field. func (m *ListingMediaMutation) SetState(l listingmedia.State) { m.state = &l @@ -5190,7 +5506,7 @@ func (m *ListingMediaMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ListingMediaMutation) Fields() []string { - fields := make([]string, 0, 9) + fields := make([]string, 0, 14) if m.listing_id != nil { fields = append(fields, listingmedia.FieldListingID) } @@ -5209,6 +5525,21 @@ func (m *ListingMediaMutation) Fields() []string { if m.object_reference != nil { fields = append(fields, listingmedia.FieldObjectReference) } + if m.verified_object_reference != nil { + fields = append(fields, listingmedia.FieldVerifiedObjectReference) + } + if m.verified_checksum_sha256 != nil { + fields = append(fields, listingmedia.FieldVerifiedChecksumSha256) + } + if m.verified_byte_size != nil { + fields = append(fields, listingmedia.FieldVerifiedByteSize) + } + if m.pixel_width != nil { + fields = append(fields, listingmedia.FieldPixelWidth) + } + if m.pixel_height != nil { + fields = append(fields, listingmedia.FieldPixelHeight) + } if m.state != nil { fields = append(fields, listingmedia.FieldState) } @@ -5238,6 +5569,16 @@ func (m *ListingMediaMutation) Field(name string) (ent.Value, bool) { return m.ChecksumSha256() case listingmedia.FieldObjectReference: return m.ObjectReference() + case listingmedia.FieldVerifiedObjectReference: + return m.VerifiedObjectReference() + case listingmedia.FieldVerifiedChecksumSha256: + return m.VerifiedChecksumSha256() + case listingmedia.FieldVerifiedByteSize: + return m.VerifiedByteSize() + case listingmedia.FieldPixelWidth: + return m.PixelWidth() + case listingmedia.FieldPixelHeight: + return m.PixelHeight() case listingmedia.FieldState: return m.State() case listingmedia.FieldCreatedAt: @@ -5265,6 +5606,16 @@ func (m *ListingMediaMutation) OldField(ctx context.Context, name string) (ent.V return m.OldChecksumSha256(ctx) case listingmedia.FieldObjectReference: return m.OldObjectReference(ctx) + case listingmedia.FieldVerifiedObjectReference: + return m.OldVerifiedObjectReference(ctx) + case listingmedia.FieldVerifiedChecksumSha256: + return m.OldVerifiedChecksumSha256(ctx) + case listingmedia.FieldVerifiedByteSize: + return m.OldVerifiedByteSize(ctx) + case listingmedia.FieldPixelWidth: + return m.OldPixelWidth(ctx) + case listingmedia.FieldPixelHeight: + return m.OldPixelHeight(ctx) case listingmedia.FieldState: return m.OldState(ctx) case listingmedia.FieldCreatedAt: @@ -5322,6 +5673,41 @@ func (m *ListingMediaMutation) SetField(name string, value ent.Value) error { } m.SetObjectReference(v) return nil + case listingmedia.FieldVerifiedObjectReference: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVerifiedObjectReference(v) + return nil + case listingmedia.FieldVerifiedChecksumSha256: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVerifiedChecksumSha256(v) + return nil + case listingmedia.FieldVerifiedByteSize: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVerifiedByteSize(v) + return nil + case listingmedia.FieldPixelWidth: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPixelWidth(v) + return nil + case listingmedia.FieldPixelHeight: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPixelHeight(v) + return nil case listingmedia.FieldState: v, ok := value.(listingmedia.State) if !ok { @@ -5357,6 +5743,15 @@ func (m *ListingMediaMutation) AddedFields() []string { if m.addbyte_size != nil { fields = append(fields, listingmedia.FieldByteSize) } + if m.addverified_byte_size != nil { + fields = append(fields, listingmedia.FieldVerifiedByteSize) + } + if m.addpixel_width != nil { + fields = append(fields, listingmedia.FieldPixelWidth) + } + if m.addpixel_height != nil { + fields = append(fields, listingmedia.FieldPixelHeight) + } return fields } @@ -5369,6 +5764,12 @@ func (m *ListingMediaMutation) AddedField(name string) (ent.Value, bool) { return m.AddedOrdinal() case listingmedia.FieldByteSize: return m.AddedByteSize() + case listingmedia.FieldVerifiedByteSize: + return m.AddedVerifiedByteSize() + case listingmedia.FieldPixelWidth: + return m.AddedPixelWidth() + case listingmedia.FieldPixelHeight: + return m.AddedPixelHeight() } return nil, false } @@ -5392,6 +5793,27 @@ func (m *ListingMediaMutation) AddField(name string, value ent.Value) error { } m.AddByteSize(v) return nil + case listingmedia.FieldVerifiedByteSize: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddVerifiedByteSize(v) + return nil + case listingmedia.FieldPixelWidth: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddPixelWidth(v) + return nil + case listingmedia.FieldPixelHeight: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddPixelHeight(v) + return nil } return fmt.Errorf("unknown ListingMedia numeric field %s", name) } @@ -5399,7 +5821,23 @@ func (m *ListingMediaMutation) AddField(name string, value ent.Value) error { // ClearedFields returns all nullable fields that were cleared during this // mutation. func (m *ListingMediaMutation) ClearedFields() []string { - return nil + var fields []string + if m.FieldCleared(listingmedia.FieldVerifiedObjectReference) { + fields = append(fields, listingmedia.FieldVerifiedObjectReference) + } + if m.FieldCleared(listingmedia.FieldVerifiedChecksumSha256) { + fields = append(fields, listingmedia.FieldVerifiedChecksumSha256) + } + if m.FieldCleared(listingmedia.FieldVerifiedByteSize) { + fields = append(fields, listingmedia.FieldVerifiedByteSize) + } + if m.FieldCleared(listingmedia.FieldPixelWidth) { + fields = append(fields, listingmedia.FieldPixelWidth) + } + if m.FieldCleared(listingmedia.FieldPixelHeight) { + fields = append(fields, listingmedia.FieldPixelHeight) + } + return fields } // FieldCleared returns a boolean indicating if a field with the given name was @@ -5412,6 +5850,23 @@ func (m *ListingMediaMutation) FieldCleared(name string) bool { // ClearField clears the value of the field with the given name. It returns an // error if the field is not defined in the schema. func (m *ListingMediaMutation) ClearField(name string) error { + switch name { + case listingmedia.FieldVerifiedObjectReference: + m.ClearVerifiedObjectReference() + return nil + case listingmedia.FieldVerifiedChecksumSha256: + m.ClearVerifiedChecksumSha256() + return nil + case listingmedia.FieldVerifiedByteSize: + m.ClearVerifiedByteSize() + return nil + case listingmedia.FieldPixelWidth: + m.ClearPixelWidth() + return nil + case listingmedia.FieldPixelHeight: + m.ClearPixelHeight() + return nil + } return fmt.Errorf("unknown ListingMedia nullable field %s", name) } @@ -5437,6 +5892,21 @@ func (m *ListingMediaMutation) ResetField(name string) error { case listingmedia.FieldObjectReference: m.ResetObjectReference() return nil + case listingmedia.FieldVerifiedObjectReference: + m.ResetVerifiedObjectReference() + return nil + case listingmedia.FieldVerifiedChecksumSha256: + m.ResetVerifiedChecksumSha256() + return nil + case listingmedia.FieldVerifiedByteSize: + m.ResetVerifiedByteSize() + return nil + case listingmedia.FieldPixelWidth: + m.ResetPixelWidth() + return nil + case listingmedia.FieldPixelHeight: + m.ResetPixelHeight() + return nil case listingmedia.FieldState: m.ResetState() return nil diff --git a/backend/ent/runtime.go b/backend/ent/runtime.go index a9552a1..a3bbba3 100644 --- a/backend/ent/runtime.go +++ b/backend/ent/runtime.go @@ -391,12 +391,32 @@ func init() { return nil } }() + // listingmediaDescVerifiedObjectReference is the schema descriptor for verified_object_reference field. + listingmediaDescVerifiedObjectReference := listingmediaFields[7].Descriptor() + // listingmedia.VerifiedObjectReferenceValidator is a validator for the "verified_object_reference" field. It is called by the builders before save. + listingmedia.VerifiedObjectReferenceValidator = listingmediaDescVerifiedObjectReference.Validators[0].(func(string) error) + // listingmediaDescVerifiedChecksumSha256 is the schema descriptor for verified_checksum_sha256 field. + listingmediaDescVerifiedChecksumSha256 := listingmediaFields[8].Descriptor() + // listingmedia.VerifiedChecksumSha256Validator is a validator for the "verified_checksum_sha256" field. It is called by the builders before save. + listingmedia.VerifiedChecksumSha256Validator = listingmediaDescVerifiedChecksumSha256.Validators[0].(func(string) error) + // listingmediaDescVerifiedByteSize is the schema descriptor for verified_byte_size field. + listingmediaDescVerifiedByteSize := listingmediaFields[9].Descriptor() + // listingmedia.VerifiedByteSizeValidator is a validator for the "verified_byte_size" field. It is called by the builders before save. + listingmedia.VerifiedByteSizeValidator = listingmediaDescVerifiedByteSize.Validators[0].(func(int64) error) + // listingmediaDescPixelWidth is the schema descriptor for pixel_width field. + listingmediaDescPixelWidth := listingmediaFields[10].Descriptor() + // listingmedia.PixelWidthValidator is a validator for the "pixel_width" field. It is called by the builders before save. + listingmedia.PixelWidthValidator = listingmediaDescPixelWidth.Validators[0].(func(int) error) + // listingmediaDescPixelHeight is the schema descriptor for pixel_height field. + listingmediaDescPixelHeight := listingmediaFields[11].Descriptor() + // listingmedia.PixelHeightValidator is a validator for the "pixel_height" field. It is called by the builders before save. + listingmedia.PixelHeightValidator = listingmediaDescPixelHeight.Validators[0].(func(int) error) // listingmediaDescCreatedAt is the schema descriptor for created_at field. - listingmediaDescCreatedAt := listingmediaFields[8].Descriptor() + listingmediaDescCreatedAt := listingmediaFields[13].Descriptor() // listingmedia.DefaultCreatedAt holds the default value on creation for the created_at field. listingmedia.DefaultCreatedAt = listingmediaDescCreatedAt.Default.(func() time.Time) // listingmediaDescUpdatedAt is the schema descriptor for updated_at field. - listingmediaDescUpdatedAt := listingmediaFields[9].Descriptor() + listingmediaDescUpdatedAt := listingmediaFields[14].Descriptor() // listingmedia.DefaultUpdatedAt holds the default value on creation for the updated_at field. listingmedia.DefaultUpdatedAt = listingmediaDescUpdatedAt.Default.(func() time.Time) // listingmedia.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. diff --git a/backend/ent/schema/listingmedia.go b/backend/ent/schema/listingmedia.go index f59db3d..88c7753 100644 --- a/backend/ent/schema/listingmedia.go +++ b/backend/ent/schema/listingmedia.go @@ -19,6 +19,11 @@ func (ListingMedia) Fields() []ent.Field { field.Int64("byte_size").Positive(), field.String("checksum_sha256").NotEmpty().MaxLen(64).Immutable(), field.String("object_reference").NotEmpty().MaxLen(512).Immutable(), + field.String("verified_object_reference").Optional().Nillable().MaxLen(512), + field.String("verified_checksum_sha256").Optional().Nillable().MaxLen(64), + field.Int64("verified_byte_size").Optional().Nillable().Positive(), + field.Int("pixel_width").Optional().Nillable().Positive(), + field.Int("pixel_height").Optional().Nillable().Positive(), field.Enum("state").Values("pending_upload", "ready", "deleted").Default("pending_upload"), field.Time("created_at").Default(utcNow).Immutable(), field.Time("updated_at").Default(utcNow).UpdateDefault(utcNow), diff --git a/backend/go.mod b/backend/go.mod index 557a90c..25b8460 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,6 +9,7 @@ require ( github.com/clerk/clerk-sdk-go/v2 v2.7.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 + golang.org/x/image v0.45.0 ) require ( @@ -28,8 +29,8 @@ require ( github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.41.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 2a431ea..91ea4a4 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -12,9 +12,17 @@ github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQ github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/clerk/clerk-sdk-go/v2 v2.7.0 h1:Bc/hbqpXdPsaNpp9ppOzL3I0R5+8jVeZ5FvgB+bPv0o= github.com/clerk/clerk-sdk-go/v2 v2.7.0/go.mod h1:ncFmsPwmD5WpGCNW5bJve862j/HQfpkzsshXYV/quJ8= +github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= +github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= @@ -28,6 +36,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo= github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -42,16 +52,34 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= +github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= +github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -67,10 +95,12 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -79,8 +109,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -89,6 +119,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -100,12 +132,14 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/backend/internal/httpapi/media_handler.go b/backend/internal/httpapi/media_handler.go new file mode 100644 index 0000000..953444e --- /dev/null +++ b/backend/internal/httpapi/media_handler.go @@ -0,0 +1,146 @@ +package httpapi + +import ( + "context" + "errors" + "io" + "net/http" + "strconv" + "strings" + + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/authn" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/listingmedia" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/provideraccess" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/users" + "github.com/google/uuid" +) + +type MediaReader interface { + List(context.Context, users.VerifiedIdentity, listingmedia.ReadScope, uuid.UUID) ([]listingmedia.Photo, error) + Get(context.Context, users.VerifiedIdentity, listingmedia.ReadScope, uuid.UUID, uuid.UUID) ([]byte, error) +} +type MediaFinalizer interface { + Finalize(context.Context, users.VerifiedIdentity, uuid.UUID, uuid.UUID) error +} + +type mediaHandler struct { + reader MediaReader + finalizer MediaFinalizer + scope listingmedia.ReadScope +} + +// Specific media routes are composed around the existing API without changing +// unrelated routes. Scope comes from server registration, never query/body data. +func NewMediaRouter(fallback http.Handler, verifier authn.Verifier, reader MediaReader, finalizer MediaFinalizer) http.Handler { + mux := http.NewServeMux() + for segment, scope := range map[string]listingmedia.ReadScope{"public": listingmedia.PublicRead, "me": listingmedia.OwnerRead, "moderation": listingmedia.ModeratorRead} { + var handler http.Handler = mediaHandler{reader: reader, finalizer: finalizer, scope: scope} + if scope != listingmedia.PublicRead { + handler = authn.RequireVerifiedIdentity(verifier, handler) + } + path := "/api/v1/" + segment + "/listings/{listingID}/media" + mux.Handle("GET "+path, handler) + mux.Handle("GET "+path+"/{mediaID}", handler) + if scope == listingmedia.OwnerRead { + mux.Handle("POST "+path+"/{mediaID}/finalize", handler) + } + } + mux.Handle("/", fallback) + return mux +} + +func (h mediaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + requestID := requestIDFromHeader(r.Header.Get(RequestIDHeader)) + w.Header().Set(RequestIDHeader, requestID) + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + identity, authenticated := authn.IdentityFromContext(r.Context()) + if h.scope != listingmedia.PublicRead && !authenticated { + writeMediaError(w, provideraccess.ErrUnauthorized, requestID) + return + } + listingID, valid := mediaPathID(r.PathValue("listingID")) + if !valid || r.URL.RawQuery != "" || r.URL.RawPath != "" { + writeMediaError(w, listingmedia.ErrInvalidUpload, requestID) + return + } + mediaID := uuid.Nil + if value := r.PathValue("mediaID"); value != "" { + mediaID, valid = mediaPathID(value) + if !valid { + writeMediaError(w, listingmedia.ErrInvalidUpload, requestID) + return + } + } + if r.Method == http.MethodPost { + if h.scope != listingmedia.OwnerRead || mediaID == uuid.Nil || h.finalizer == nil { + writeMediaError(w, listingmedia.ErrUnavailable, requestID) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 1025)) + text := strings.TrimSpace(string(body)) + if err != nil || len(body) > 1024 || (text != "" && text != "{}") { + writeMediaError(w, listingmedia.ErrInvalidUpload, requestID) + return + } + if err := h.finalizer.Finalize(r.Context(), identity, listingID, mediaID); err != nil { + writeMediaError(w, err, requestID) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + if h.reader == nil { + writeMediaError(w, listingmedia.ErrUnavailable, requestID) + return + } + if mediaID == uuid.Nil { + photos, err := h.reader.List(r.Context(), identity, h.scope, listingID) + if err != nil { + writeMediaError(w, err, requestID) + return + } + if photos == nil { + photos = []listingmedia.Photo{} + } + writeJSON(w, http.StatusOK, struct { + Photos []listingmedia.Photo `json:"photos"` + }{Photos: photos}, requestID) + return + } + body, err := h.reader.Get(r.Context(), identity, h.scope, listingID, mediaID) + if err != nil { + writeMediaError(w, err, requestID) + return + } + if len(body) == 0 || len(body) > 10485760 { + writeMediaError(w, listingmedia.ErrUnavailable, requestID) + return + } + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +func mediaPathID(value string) (uuid.UUID, bool) { + id, err := uuid.Parse(value) + return id, err == nil && id != uuid.Nil && id.String() == value +} +func writeMediaError(w http.ResponseWriter, err error, id string) { + switch { + case errors.Is(err, provideraccess.ErrUnauthorized): + writeAPIError(w, 401, "UNAUTHORIZED", "Unauthorized", id) + case errors.Is(err, provideraccess.ErrForbidden): + writeAPIError(w, 403, "FORBIDDEN", "Forbidden", id) + case errors.Is(err, listingmedia.ErrNotFound): + writeAPIError(w, 404, "NOT_FOUND", "Not found", id) + case errors.Is(err, listingmedia.ErrConflict): + writeAPIError(w, 409, "CONFLICT", "Conflict", id) + case errors.Is(err, listingmedia.ErrInvalidUpload): + writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", id) + default: + writeAPIError(w, 503, "SERVICE_UNAVAILABLE", "Service unavailable", id) + } +} diff --git a/backend/internal/httpapi/media_handler_test.go b/backend/internal/httpapi/media_handler_test.go new file mode 100644 index 0000000..59a56ea --- /dev/null +++ b/backend/internal/httpapi/media_handler_test.go @@ -0,0 +1,106 @@ +package httpapi_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/httpapi" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/listingmedia" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/users" + "github.com/google/uuid" +) + +func TestMediaRoutesProtectScopeAndNeverExposeStorageReferences(t *testing.T) { + reader := &httpMediaReader{} + finalizer := &httpMediaFinalizer{} + handler := httpapi.NewMediaRouter(http.NotFoundHandler(), staticVerifier{identity: users.VerifiedIdentity{Subject: "owner"}}, reader, finalizer) + listing := uuid.NewString() + media := uuid.NewString() + for _, test := range []struct { + path, method, body string + token bool + want int + }{ + {"/api/v1/me/listings/" + listing + "/media", "GET", "", false, 401}, + {"/api/v1/moderation/listings/" + listing + "/media", "GET", "", false, 401}, + {"/api/v1/me/listings/" + listing + "/media/" + media + "/finalize", "POST", "{}", false, 401}, + {"/api/v1/public/listings/" + listing + "/media", "GET", "", false, 200}, + {"/api/v1/me/listings/" + listing + "/media", "GET", "", true, 200}, + {"/api/v1/moderation/listings/" + listing + "/media", "GET", "", true, 200}, + {"/api/v1/me/listings/" + listing + "/media/" + media + "/finalize", "POST", "{}", true, 204}, + {"/api/v1/me/listings/" + listing + "/media/" + media + "/finalize", "POST", `{"reference":"evil"}`, true, 400}, + {"/api/v1/public/listings/not-a-uuid/media", "GET", "", false, 400}, + {"/api/v1/public/listings/" + listing + "/media?scope=moderator", "GET", "", false, 400}, + } { + r := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + if test.token { + r.Header.Set("Authorization", "Bearer synthetic-token") + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != test.want || strings.Contains(w.Body.String(), "objectReference") { + t.Fatalf("%s returned %d instead of %d", test.path, w.Code, test.want) + } + } + if finalizer.calls != 1 || reader.calls != 3 || reader.scope != listingmedia.ModeratorRead { + t.Fatal("invalid/authless request reached service or route scope was not authoritative") + } +} + +func TestMediaRoutesServeNoStorePNGAndBoundErrors(t *testing.T) { + reader := &httpMediaReader{body: []byte("synthetic-image-bytes")} + finalizer := &httpMediaFinalizer{err: listingmedia.ErrConflict} + handler := httpapi.NewMediaRouter(http.NotFoundHandler(), staticVerifier{identity: users.VerifiedIdentity{Subject: "owner"}}, reader, finalizer) + path := "/api/v1/public/listings/" + uuid.NewString() + "/media/" + uuid.NewString() + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != 200 || w.Header().Get("Content-Type") != "image/png" || w.Header().Get("Cache-Control") != "private, no-store" || w.Header().Get("X-Content-Type-Options") != "nosniff" || w.Body.String() != string(reader.body) { + t.Fatal("unsafe image HTTP response") + } + for err, status := range map[error]int{listingmedia.ErrNotFound: 404, errors.New("private provider key"): 503} { + reader.err = err + w = httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != status || strings.Contains(w.Body.String(), "private provider key") { + t.Fatal("unsafe error response") + } + } + r := httptest.NewRequest("POST", strings.Replace(path, "/public/", "/me/", 1)+"/finalize", nil) + r.Header.Set("Authorization", "Bearer synthetic-token") + w = httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != 409 { + t.Fatal("revision conflict lost") + } +} + +type httpMediaReader struct { + calls int + scope listingmedia.ReadScope + body []byte + err error +} + +func (r *httpMediaReader) List(_ context.Context, _ users.VerifiedIdentity, scope listingmedia.ReadScope, _ uuid.UUID) ([]listingmedia.Photo, error) { + r.calls++ + r.scope = scope + return []listingmedia.Photo{}, r.err +} +func (r *httpMediaReader) Get(context.Context, users.VerifiedIdentity, listingmedia.ReadScope, uuid.UUID, uuid.UUID) ([]byte, error) { + r.calls++ + return r.body, r.err +} + +type httpMediaFinalizer struct { + calls int + err error +} + +func (f *httpMediaFinalizer) Finalize(context.Context, users.VerifiedIdentity, uuid.UUID, uuid.UUID) error { + f.calls++ + return f.err +} diff --git a/backend/internal/listingmedia/ent_finalization.go b/backend/internal/listingmedia/ent_finalization.go new file mode 100644 index 0000000..637a6ad --- /dev/null +++ b/backend/internal/listingmedia/ent_finalization.go @@ -0,0 +1,125 @@ +package listingmedia + +import ( + "context" + + jent "github.com/SourceSenseiTheRealOne/juntly/backend/ent" + "github.com/SourceSenseiTheRealOne/juntly/backend/ent/listing" + "github.com/SourceSenseiTheRealOne/juntly/backend/ent/listingevent" + media "github.com/SourceSenseiTheRealOne/juntly/backend/ent/listingmedia" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/provideraccess" + "github.com/google/uuid" +) + +var _ FinalizationRepository = entRepository{} + +func editableListing(ctx context.Context, client *jent.Client, owner, id uuid.UUID) (*jent.Listing, error) { + if client == nil { + return nil, ErrUnavailable + } + if owner == uuid.Nil || id == uuid.Nil { + return nil, provideraccess.ErrForbidden + } + entity, err := client.Listing.Query().Where(listing.IDEQ(id), listing.InternalUserIDEQ(owner), listing.StateIn(listing.StateDraft, listing.StateRejected)).Only(ctx) + if jent.IsNotFound(err) { + return nil, provideraccess.ErrForbidden + } + if err != nil { + return nil, ErrUnavailable + } + return entity, nil +} + +func editableMedia(ctx context.Context, client *jent.Client, listingID, id uuid.UUID) (*jent.ListingMedia, error) { + entity, err := client.ListingMedia.Query().Where(media.IDEQ(id), media.ListingIDEQ(listingID), media.StateIn(media.StatePendingUpload, media.StateReady)).Only(ctx) + if jent.IsNotFound(err) { + return nil, provideraccess.ErrForbidden + } + if err != nil { + return nil, ErrUnavailable + } + return entity, nil +} + +func (r entRepository) GetEditable(ctx context.Context, owner, listingID, id uuid.UUID) (MediaObject, error) { + parent, err := editableListing(ctx, r.client, owner, listingID) + if err != nil { + return MediaObject{}, err + } + entity, err := editableMedia(ctx, r.client, listingID, id) + if err != nil { + return MediaObject{}, err + } + if entity.State == media.StateReady && !validVerifiedRecord(entity) { + return MediaObject{}, ErrUnavailable + } + return MediaObject{ID: entity.ID, Request: UploadRequest{Ordinal: entity.Ordinal, ContentType: entity.ContentType, ByteSize: entity.ByteSize, ChecksumSHA256: entity.ChecksumSha256}, ObjectReference: entity.ObjectReference, Revision: parent.Revision, Ready: entity.State == media.StateReady}, nil +} + +func (r entRepository) Complete(ctx context.Context, owner, listingID, id uuid.UUID, revision int, reference string, image SanitizedImage) error { + if r.client == nil { + return ErrUnavailable + } + if owner == uuid.Nil || listingID == uuid.Nil || id == uuid.Nil || revision < 1 || !validSanitizedImage(image) || reference != verifiedReference(id, image.ChecksumSHA256) { + return ErrInvalidUpload + } + tx, err := r.client.Tx(ctx) + if err != nil { + return ErrUnavailable + } + defer func() { _ = tx.Rollback() }() + client := tx.Client() + parent, err := editableListing(ctx, client, owner, listingID) + if err != nil { + return err + } + entity, err := editableMedia(ctx, client, listingID, id) + if err != nil { + return err + } + if entity.State == media.StateReady { + if !verifiedRecordMatches(entity, reference, image) { + return ErrInvalidUpload + } + return tx.Commit() + } + // Lock/CAS the parent before marking ready. Submit, moderation and owner + // edits update this same revision, so none can race an unreviewed photo in. + affected, err := client.Listing.Update().Where(listing.IDEQ(listingID), listing.InternalUserIDEQ(owner), listing.StateEQ(parent.State), listing.RevisionEQ(revision)).SetRevision(revision + 1).Save(ctx) + if err != nil { + return ErrUnavailable + } + if affected != 1 { + return ErrConflict + } + affected, err = client.ListingMedia.Update().Where(media.IDEQ(id), media.ListingIDEQ(listingID), media.StateEQ(media.StatePendingUpload)). + SetState(media.StateReady).SetVerifiedObjectReference(reference).SetVerifiedChecksumSha256(image.ChecksumSHA256). + SetVerifiedByteSize(int64(len(image.Bytes))).SetPixelWidth(image.Width).SetPixelHeight(image.Height).Save(ctx) + if err != nil { + return ErrUnavailable + } + if affected != 1 { + return ErrConflict + } + if err := client.ListingEvent.Create().SetListingID(listingID).SetActorInternalUserID(owner). + SetEventType(listingevent.EventTypeUpdated).SetFromState(string(parent.State)).SetToState(string(parent.State)).SetRevision(revision + 1).Exec(ctx); err != nil { + return ErrUnavailable + } + return tx.Commit() +} + +func verifiedReference(id uuid.UUID, checksum string) string { + return "verified/" + id.String() + "/" + checksum + ".png" +} + +func validVerifiedRecord(entity *jent.ListingMedia) bool { + if entity.VerifiedObjectReference == nil || entity.VerifiedChecksumSha256 == nil || entity.VerifiedByteSize == nil || entity.PixelWidth == nil || entity.PixelHeight == nil { + return false + } + request := UploadRequest{Ordinal: entity.Ordinal, ContentType: "image/png", ByteSize: *entity.VerifiedByteSize, ChecksumSHA256: *entity.VerifiedChecksumSha256} + return validUploadRequest(request) && *entity.VerifiedObjectReference == verifiedReference(entity.ID, *entity.VerifiedChecksumSha256) && *entity.PixelWidth > 0 && *entity.PixelHeight > 0 && *entity.PixelWidth <= 8192 && *entity.PixelHeight <= 8192 && int64(*entity.PixelWidth)*int64(*entity.PixelHeight) <= 16_000_000 +} + +func verifiedRecordMatches(entity *jent.ListingMedia, reference string, image SanitizedImage) bool { + return validVerifiedRecord(entity) && *entity.VerifiedObjectReference == reference && *entity.VerifiedChecksumSha256 == image.ChecksumSHA256 && *entity.VerifiedByteSize == int64(len(image.Bytes)) && *entity.PixelWidth == image.Width && *entity.PixelHeight == image.Height +} diff --git a/backend/internal/listingmedia/ent_reader.go b/backend/internal/listingmedia/ent_reader.go new file mode 100644 index 0000000..b633a1f --- /dev/null +++ b/backend/internal/listingmedia/ent_reader.go @@ -0,0 +1,103 @@ +package listingmedia + +import ( + "context" + + jent "github.com/SourceSenseiTheRealOne/juntly/backend/ent" + "github.com/SourceSenseiTheRealOne/juntly/backend/ent/listing" + media "github.com/SourceSenseiTheRealOne/juntly/backend/ent/listingmedia" + "github.com/SourceSenseiTheRealOne/juntly/backend/ent/platformrole" + "github.com/google/uuid" +) + +var _ ReadRepository = entRepository{} + +func (r entRepository) ListVerified(ctx context.Context, scope ReadScope, actor, listingID uuid.UUID) ([]VerifiedMedia, error) { + if r.client == nil { + return nil, ErrUnavailable + } + if listingID == uuid.Nil { + return nil, ErrNotFound + } + parent, err := r.client.Listing.Get(ctx, listingID) + if err != nil { + return nil, mediaReadError(err) + } + switch scope { + case PublicRead: + if parent.State != listing.StateActive { + return nil, ErrNotFound + } + if err := r.requirePublicListing(ctx, parent); err != nil { + return nil, err + } + case OwnerRead: + if actor == uuid.Nil || parent.InternalUserID != actor { + return nil, ErrNotFound + } + case ModeratorRead: + if actor == uuid.Nil { + return nil, ErrNotFound + } + granted, err := r.client.PlatformRole.Query().Where(platformrole.InternalUserIDEQ(actor), platformrole.RoleEQ("moderator")).Exist(ctx) + if err != nil { + return nil, ErrUnavailable + } + if !granted { + return nil, ErrNotFound + } + default: + return nil, ErrNotFound + } + entities, err := r.client.ListingMedia.Query().Where(media.ListingIDEQ(listingID), media.StateEQ(media.StateReady)).Order(jent.Asc(media.FieldOrdinal)).Limit(10).All(ctx) + if err != nil { + return nil, ErrUnavailable + } + result := make([]VerifiedMedia, 0, len(entities)) + for _, entity := range entities { + // Historical ready rows without verified metadata never become visible. + if !validVerifiedRecord(entity) { + continue + } + result = append(result, VerifiedMedia{ID: entity.ID, Ordinal: entity.Ordinal, ObjectReference: *entity.VerifiedObjectReference, ByteSize: *entity.VerifiedByteSize, ChecksumSHA256: *entity.VerifiedChecksumSha256, Width: *entity.PixelWidth, Height: *entity.PixelHeight}) + } + return result, nil +} + +// Match discovery's non-localized visibility prerequisites as well as state. +func (r entRepository) requirePublicListing(ctx context.Context, parent *jent.Listing) error { + category, err := r.client.ServiceCategory.Get(ctx, parent.CategoryID) + if err != nil { + return mediaReadError(err) + } + if !category.Active { + return ErrNotFound + } + if category.ParentID != nil { + ancestor, err := r.client.ServiceCategory.Get(ctx, *category.ParentID) + if err != nil { + return mediaReadError(err) + } + if !ancestor.Active { + return ErrNotFound + } + } + locality, err := r.client.Locality.Get(ctx, parent.PrimaryLocalityID) + if err != nil { + return mediaReadError(err) + } + if !locality.Active { + return ErrNotFound + } + if _, err := r.client.ProviderProfile.Get(ctx, parent.InternalUserID); err != nil { + return mediaReadError(err) + } + return nil +} + +func mediaReadError(err error) error { + if jent.IsNotFound(err) { + return ErrNotFound + } + return ErrUnavailable +} diff --git a/backend/internal/listingmedia/ent_repository.go b/backend/internal/listingmedia/ent_repository.go index 913afc1..e99cf6c 100644 --- a/backend/internal/listingmedia/ent_repository.go +++ b/backend/internal/listingmedia/ent_repository.go @@ -3,16 +3,57 @@ package listingmedia import ( "context" "errors" + "strings" jent "github.com/SourceSenseiTheRealOne/juntly/backend/ent" "github.com/SourceSenseiTheRealOne/juntly/backend/ent/listing" entlistingmedia "github.com/SourceSenseiTheRealOne/juntly/backend/ent/listingmedia" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/provideraccess" "github.com/google/uuid" ) type entRepository struct{ client *jent.Client } -func NewEntRepository(client *jent.Client) Repository { return entRepository{client: client} } +func NewEntRepository(client *jent.Client) ManagedRepository { return entRepository{client: client} } +func (r entRepository) RequireEditable(ctx context.Context, owner, listingID uuid.UUID) error { + if r.client == nil { + return ErrUnavailable + } + if owner == uuid.Nil || listingID == uuid.Nil { + return provideraccess.ErrForbidden + } + exists, err := r.client.Listing.Query().Where(listing.IDEQ(listingID), listing.InternalUserIDEQ(owner), listing.StateIn(listing.StateDraft, listing.StateRejected)).Exist(ctx) + if err != nil { + return ErrUnavailable + } + if !exists { + return provideraccess.ErrForbidden + } + return nil +} +func (r entRepository) FindReservation(ctx context.Context, owner, listingID uuid.UUID, request UploadRequest) (uuid.UUID, string, error) { + if err := r.RequireEditable(ctx, owner, listingID); err != nil { + return uuid.Nil, "", err + } + if !validUploadRequest(request) { + return uuid.Nil, "", ErrInvalidUpload + } + media, err := r.client.ListingMedia.Query().Where(entlistingmedia.ListingIDEQ(listingID), entlistingmedia.OrdinalEQ(request.Ordinal)).Only(ctx) + if jent.IsNotFound(err) { + return uuid.Nil, "", nil + } + if err != nil { + return uuid.Nil, "", ErrUnavailable + } + if media.State != entlistingmedia.StatePendingUpload || !strings.EqualFold(media.ContentType, request.ContentType) || media.ByteSize != request.ByteSize || media.ChecksumSha256 != request.ChecksumSHA256 { + return uuid.Nil, "", ErrConflict + } + if media.ObjectReference == "" { + return uuid.Nil, "", ErrUnavailable + } + return media.ID, media.ObjectReference, nil +} + func (r entRepository) ReservePending(ctx context.Context, owner, listingID, mediaID uuid.UUID, request UploadRequest, objectReference string) error { if r.client == nil || owner == uuid.Nil || listingID == uuid.Nil || mediaID == uuid.Nil || objectReference == "" { return errors.New("listing media persistence unavailable") diff --git a/backend/internal/listingmedia/finalizer.go b/backend/internal/listingmedia/finalizer.go new file mode 100644 index 0000000..83744e9 --- /dev/null +++ b/backend/internal/listingmedia/finalizer.go @@ -0,0 +1,77 @@ +package listingmedia + +import ( + "context" + + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/users" + "github.com/google/uuid" +) + +// MediaObject is a private repository projection, never a public response. +type MediaObject struct { + ID uuid.UUID + Request UploadRequest + ObjectReference string + Revision int + Ready bool +} + +type FinalizationRepository interface { + GetEditable(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (MediaObject, error) + Complete(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, int, string, SanitizedImage) error +} + +type FinalizationStorage interface { + DownloadPending(context.Context, string) ([]byte, error) + WriteVerified(context.Context, uuid.UUID, SanitizedImage) (string, error) +} + +type Finalizer struct { + authorizer ProviderAuthorizer + repository FinalizationRepository + storage FinalizationStorage + slots chan struct{} +} + +func NewFinalizer(authorizer ProviderAuthorizer, repository FinalizationRepository, storage FinalizationStorage) *Finalizer { + return &Finalizer{authorizer: authorizer, repository: repository, storage: storage, slots: make(chan struct{}, 2)} +} + +func (f *Finalizer) Finalize(ctx context.Context, identity users.VerifiedIdentity, listingID, mediaID uuid.UUID) error { + if f == nil || f.authorizer == nil || f.repository == nil || f.storage == nil { + return ErrUnavailable + } + if listingID == uuid.Nil || mediaID == uuid.Nil { + return ErrInvalidUpload + } + owner, err := f.authorizer.RequireProvider(ctx, identity) + if err != nil { + return err + } + media, err := f.repository.GetEditable(ctx, owner.ID, listingID, mediaID) + if err != nil { + return err + } + if media.Ready { + return nil + } + select { + case f.slots <- struct{}{}: + defer func() { <-f.slots }() + default: + return ErrUnavailable + } + body, err := f.storage.DownloadPending(ctx, media.ObjectReference) + if err != nil { + return ErrUnavailable + } + verified, err := SanitizeImage(body, media.Request) + if err != nil { + return err + } + reference, err := f.storage.WriteVerified(ctx, mediaID, verified) + if err != nil || reference == "" { + return ErrUnavailable + } + return f.repository.Complete(ctx, owner.ID, listingID, mediaID, media.Revision, reference, verified) +} diff --git a/backend/internal/listingmedia/finalizer_test.go b/backend/internal/listingmedia/finalizer_test.go new file mode 100644 index 0000000..0502a99 --- /dev/null +++ b/backend/internal/listingmedia/finalizer_test.go @@ -0,0 +1,87 @@ +package listingmedia + +import ( + "context" + "errors" + "testing" + + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/provideraccess" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/users" + "github.com/google/uuid" +) + +func TestFinalizeVerifiesBeforePublishing(t *testing.T) { + body := testPNG(t, 2, 3) + repo := &finalizationTestRepo{media: MediaObject{ID: uuid.New(), Request: imageRequest(body), ObjectReference: "pending/test", Revision: 1}} + storage := &finalizationTestStorage{body: body} + finalizer := NewFinalizer(&recordingAuthorizer{owner: users.InternalUser{ID: uuid.New()}}, repo, storage) + if err := finalizer.Finalize(context.Background(), users.VerifiedIdentity{Subject: "provider"}, uuid.New(), repo.media.ID); err != nil { + t.Fatal(err) + } + if repo.completed != 1 || storage.writes != 1 || repo.verified.ContentType != "image/png" || repo.reference != "verified/test.png" { + t.Fatal("verified publication not persisted") + } +} + +func TestFinalizeRejectsCorruptionAndUnauthorizedReads(t *testing.T) { + for _, unauthorized := range []bool{false, true} { + body := testPNG(t, 2, 3) + repo := &finalizationTestRepo{media: MediaObject{ID: uuid.New(), Request: imageRequest(body), ObjectReference: "pending/test", Revision: 1}} + storage := &finalizationTestStorage{body: append(body, 1)} + if unauthorized { + repo.err = provideraccess.ErrForbidden + } + f := NewFinalizer(&recordingAuthorizer{owner: users.InternalUser{ID: uuid.New()}}, repo, storage) + err := f.Finalize(context.Background(), users.VerifiedIdentity{Subject: "provider"}, uuid.New(), repo.media.ID) + if err == nil || storage.writes != 0 || repo.completed != 0 || (unauthorized && storage.reads != 0) { + t.Fatal("unsafe finalization crossed publication boundary") + } + if unauthorized && !errors.Is(err, provideraccess.ErrForbidden) { + t.Fatal("lost authorization error") + } + } +} + +func TestFinalizeReadyReplaySkipsStorage(t *testing.T) { + repo := &finalizationTestRepo{media: MediaObject{ID: uuid.New(), Ready: true}} + storage := &finalizationTestStorage{} + f := NewFinalizer(&recordingAuthorizer{owner: users.InternalUser{ID: uuid.New()}}, repo, storage) + if err := f.Finalize(context.Background(), users.VerifiedIdentity{Subject: "provider"}, uuid.New(), repo.media.ID); err != nil { + t.Fatal(err) + } + if storage.reads != 0 || storage.writes != 0 || repo.completed != 0 { + t.Fatal("ready replay performed storage mutation") + } +} + +type finalizationTestRepo struct { + media MediaObject + err error + completed int + reference string + verified SanitizedImage +} + +func (r *finalizationTestRepo) GetEditable(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (MediaObject, error) { + return r.media, r.err +} +func (r *finalizationTestRepo) Complete(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ uuid.UUID, _ int, ref string, img SanitizedImage) error { + r.completed++ + r.reference = ref + r.verified = img + return nil +} + +type finalizationTestStorage struct { + body []byte + reads, writes int +} + +func (s *finalizationTestStorage) DownloadPending(context.Context, string) ([]byte, error) { + s.reads++ + return s.body, nil +} +func (s *finalizationTestStorage) WriteVerified(context.Context, uuid.UUID, SanitizedImage) (string, error) { + s.writes++ + return "verified/test.png", nil +} diff --git a/backend/internal/listingmedia/image_sanitizer.go b/backend/internal/listingmedia/image_sanitizer.go new file mode 100644 index 0000000..55b7a1d --- /dev/null +++ b/backend/internal/listingmedia/image_sanitizer.go @@ -0,0 +1,60 @@ +package listingmedia + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "image" + _ "image/jpeg" + "image/png" + "strings" + + _ "golang.org/x/image/webp" +) + +// SanitizedImage contains newly encoded pixels, never the original upload. +type SanitizedImage struct { + Bytes []byte + ContentType string + ChecksumSHA256 string + Width, Height int +} + +func SanitizeImage(body []byte, request UploadRequest) (SanitizedImage, error) { + if !validUploadRequest(request) || int64(len(body)) != request.ByteSize { + return SanitizedImage{}, ErrInvalidUpload + } + sum := sha256.Sum256(body) + if hex.EncodeToString(sum[:]) != request.ChecksumSHA256 { + return SanitizedImage{}, ErrInvalidUpload + } + config, format, err := image.DecodeConfig(bytes.NewReader(body)) + if err != nil || config.Width < 1 || config.Height < 1 || config.Width > 8192 || config.Height > 8192 || int64(config.Width)*int64(config.Height) > 16_000_000 { + return SanitizedImage{}, ErrInvalidUpload + } + mime := map[string]string{"jpeg": "image/jpeg", "png": "image/png", "webp": "image/webp"}[format] + if mime == "" || !strings.EqualFold(mime, request.ContentType) { + return SanitizedImage{}, ErrInvalidUpload + } + decoded, decodedFormat, err := image.Decode(bytes.NewReader(body)) + if err != nil || decodedFormat != format || decoded.Bounds().Dx() != config.Width || decoded.Bounds().Dy() != config.Height { + return SanitizedImage{}, ErrInvalidUpload + } + output := boundedImageBuffer{} + encoder := png.Encoder{CompressionLevel: png.BestSpeed} + if err := encoder.Encode(&output, decoded); err != nil { + return SanitizedImage{}, ErrInvalidUpload + } + sanitized := output.Bytes() + sum = sha256.Sum256(sanitized) + return SanitizedImage{Bytes: sanitized, ContentType: "image/png", ChecksumSHA256: hex.EncodeToString(sum[:]), Width: config.Width, Height: config.Height}, nil +} + +type boundedImageBuffer struct{ bytes.Buffer } + +func (b *boundedImageBuffer) Write(p []byte) (int, error) { + if len(p) > 10485760-b.Len() { + return 0, ErrInvalidUpload + } + return b.Buffer.Write(p) +} diff --git a/backend/internal/listingmedia/image_sanitizer_test.go b/backend/internal/listingmedia/image_sanitizer_test.go new file mode 100644 index 0000000..6f4563d --- /dev/null +++ b/backend/internal/listingmedia/image_sanitizer_test.go @@ -0,0 +1,98 @@ +package listingmedia + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "image" + "image/color" + "image/jpeg" + "image/png" + "testing" +) + +func imageRequest(body []byte) UploadRequest { + sum := sha256.Sum256(body) + return UploadRequest{Ordinal: 1, ContentType: "image/png", ByteSize: int64(len(body)), ChecksumSHA256: hex.EncodeToString(sum[:])} +} + +func testPNG(t *testing.T, width, height int) []byte { + t.Helper() + var b bytes.Buffer + img := image.NewNRGBA(image.Rect(0, 0, width, height)) + img.Set(0, 0, color.NRGBA{R: 120, A: 255}) + if err := png.Encode(&b, img); err != nil { + t.Fatal(err) + } + return b.Bytes() +} + +func TestSanitizeImageVerifiesAndReencodesBytes(t *testing.T) { + body := append(testPNG(t, 2, 3), []byte("synthetic-private-metadata-trailer")...) + result, err := SanitizeImage(body, imageRequest(body)) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(result.Bytes, []byte("synthetic-private-metadata")) { + t.Fatal("private trailer survived sanitization") + } + if result.Width != 2 || result.Height != 3 || result.ContentType != "image/png" { + t.Fatal("wrong sanitized image properties") + } + config, err := png.DecodeConfig(bytes.NewReader(result.Bytes)) + if err != nil || config.Width != 2 || config.Height != 3 { + t.Fatal("sanitized bytes are not a valid image") + } + sum := sha256.Sum256(result.Bytes) + if result.ChecksumSHA256 != hex.EncodeToString(sum[:]) { + t.Fatal("wrong sanitized checksum") + } +} + +func TestSanitizeImageAcceptsJPEGAndWebP(t *testing.T) { + var jpegBytes bytes.Buffer + if err := jpeg.Encode(&jpegBytes, image.NewRGBA(image.Rect(0, 0, 2, 3)), nil); err != nil { + t.Fatal(err) + } + webp, err := base64.StdEncoding.DecodeString("UklGRjQAAABXRUJQVlA4ICgAAABwAQCdASoCAAMAAUAmJaACdAGIQAD+9Yj7J3pf/43j/Jd/bvsoAAAA") + if err != nil { + t.Fatal(err) + } + for mime, body := range map[string][]byte{"image/jpeg": jpegBytes.Bytes(), "image/webp": webp} { + r := imageRequest(body) + r.ContentType = mime + v, err := SanitizeImage(body, r) + if err != nil || v.Width != 2 || v.Height != 3 || v.ContentType != "image/png" { + t.Fatalf("%s conversion failed: %v", mime, err) + } + } +} + +func TestSanitizeImageRejectsUntrustedInputs(t *testing.T) { + body := testPNG(t, 2, 3) + for _, tc := range []struct { + name string + body []byte + mutate func(*UploadRequest) + }{ + {"checksum", body, func(r *UploadRequest) { + r.ChecksumSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }}, + {"size", body, func(r *UploadRequest) { r.ByteSize++ }}, + {"mime", body, func(r *UploadRequest) { r.ContentType = "image/jpeg" }}, + {"not_image", []byte(""), func(*UploadRequest) {}}, + {"excess_dimensions", testPNG(t, 8193, 1), func(*UploadRequest) {}}, + {"too_large", make([]byte, 10485761), func(*UploadRequest) {}}, + } { + t.Run(tc.name, func(t *testing.T) { + r := imageRequest(tc.body) + tc.mutate(&r) + v, err := SanitizeImage(tc.body, r) + if !errors.Is(err, ErrInvalidUpload) || len(v.Bytes) != 0 { + t.Fatal("unsafe input produced image") + } + }) + } +} diff --git a/backend/internal/listingmedia/model.go b/backend/internal/listingmedia/model.go index 862f1ed..dec4c85 100644 --- a/backend/internal/listingmedia/model.go +++ b/backend/internal/listingmedia/model.go @@ -9,6 +9,7 @@ import ( var ( ErrInvalidUpload = errors.New("invalid listing media upload") ErrUnavailable = errors.New("listing media unavailable") + ErrConflict = errors.New("listing media revision conflict") ) type UploadRequest struct { diff --git a/backend/internal/listingmedia/reader.go b/backend/internal/listingmedia/reader.go new file mode 100644 index 0000000..1b3ed48 --- /dev/null +++ b/backend/internal/listingmedia/reader.go @@ -0,0 +1,146 @@ +package listingmedia + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "image/png" + + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/provideraccess" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/users" + "github.com/google/uuid" +) + +var ErrNotFound = errors.New("listing media not found") + +type ReadScope string + +const ( + PublicRead ReadScope = "public" + OwnerRead ReadScope = "owner" + ModeratorRead ReadScope = "moderator" +) + +// VerifiedMedia stays private to repository/storage adapters. +type VerifiedMedia struct { + ID uuid.UUID + Ordinal int + ObjectReference string + ByteSize int64 + ChecksumSHA256 string + Width, Height int +} + +// Photo is the allowlisted response; it contains no storage capability or key. +type Photo struct { + ID uuid.UUID `json:"id"` + Ordinal int `json:"ordinal"` + Width int `json:"width"` + Height int `json:"height"` +} + +type ReadRepository interface { + ListVerified(context.Context, ReadScope, uuid.UUID, uuid.UUID) ([]VerifiedMedia, error) +} +type VerifiedStorage interface { + DownloadVerified(context.Context, VerifiedMedia) ([]byte, error) +} + +type Reader struct { + identities users.Service + repository ReadRepository + storage VerifiedStorage + slots chan struct{} +} + +func NewReader(identities users.Service, repository ReadRepository, storage VerifiedStorage) *Reader { + return &Reader{identities: identities, repository: repository, storage: storage, slots: make(chan struct{}, 8)} +} +func (r *Reader) entries(ctx context.Context, identity users.VerifiedIdentity, scope ReadScope, listingID uuid.UUID) ([]VerifiedMedia, error) { + if r == nil || r.repository == nil || r.storage == nil { + return nil, ErrUnavailable + } + if listingID == uuid.Nil || (scope != PublicRead && scope != OwnerRead && scope != ModeratorRead) { + return nil, ErrInvalidUpload + } + actor := uuid.Nil + if scope != PublicRead { + if r.identities == nil { + return nil, ErrUnavailable + } + user, _, err := r.identities.Reconcile(ctx, identity) + if errors.Is(err, users.ErrInvalidIdentity) { + return nil, provideraccess.ErrUnauthorized + } + if err != nil || user.ID == uuid.Nil { + return nil, ErrUnavailable + } + actor = user.ID + } + entries, err := r.repository.ListVerified(ctx, scope, actor, listingID) + if err != nil { + return nil, err + } + if len(entries) > 10 { + return nil, ErrUnavailable + } + for _, entry := range entries { + if !validVerifiedMedia(entry) { + return nil, ErrUnavailable + } + } + return entries, nil +} +func (r *Reader) List(ctx context.Context, identity users.VerifiedIdentity, scope ReadScope, listingID uuid.UUID) ([]Photo, error) { + entries, err := r.entries(ctx, identity, scope, listingID) + if err != nil { + return nil, err + } + photos := make([]Photo, len(entries)) + for i, entry := range entries { + photos[i] = Photo{ID: entry.ID, Ordinal: entry.Ordinal, Width: entry.Width, Height: entry.Height} + } + return photos, nil +} +func (r *Reader) Get(ctx context.Context, identity users.VerifiedIdentity, scope ReadScope, listingID, mediaID uuid.UUID) ([]byte, error) { + if mediaID == uuid.Nil { + return nil, ErrInvalidUpload + } + entries, err := r.entries(ctx, identity, scope, listingID) + if err != nil { + return nil, err + } + for _, entry := range entries { + if entry.ID != mediaID { + continue + } + select { + case r.slots <- struct{}{}: + defer func() { <-r.slots }() + default: + return nil, ErrUnavailable + } + body, err := r.storage.DownloadVerified(ctx, entry) + if err != nil || !matchesVerifiedBytes(body, entry) { + return nil, ErrUnavailable + } + return body, nil + } + return nil, ErrNotFound +} +func validVerifiedMedia(entry VerifiedMedia) bool { + return entry.ID != uuid.Nil && validUploadRequest(UploadRequest{Ordinal: entry.Ordinal, ContentType: "image/png", ByteSize: entry.ByteSize, ChecksumSHA256: entry.ChecksumSHA256}) && entry.ObjectReference == verifiedReference(entry.ID, entry.ChecksumSHA256) && entry.Width > 0 && entry.Height > 0 && entry.Width <= 8192 && entry.Height <= 8192 && int64(entry.Width)*int64(entry.Height) <= 16_000_000 +} +func matchesVerifiedBytes(body []byte, entry VerifiedMedia) bool { + if !validVerifiedMedia(entry) || int64(len(body)) != entry.ByteSize { + return false + } + sum := sha256.Sum256(body) + if hex.EncodeToString(sum[:]) != entry.ChecksumSHA256 { + return false + } + config, err := png.DecodeConfig(bytes.NewReader(body)) + return err == nil && config.Width == entry.Width && config.Height == entry.Height +} diff --git a/backend/internal/listingmedia/reader_test.go b/backend/internal/listingmedia/reader_test.go new file mode 100644 index 0000000..834930c --- /dev/null +++ b/backend/internal/listingmedia/reader_test.go @@ -0,0 +1,85 @@ +package listingmedia + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/users" + "github.com/google/uuid" +) + +func TestReaderAuthorizesBeforeStorageAndChecksBytes(t *testing.T) { + body := testPNG(t, 2, 3) + image, err := SanitizeImage(body, imageRequest(body)) + if err != nil { + t.Fatal(err) + } + id := uuid.New() + entry := VerifiedMedia{ID: id, Ordinal: 1, ObjectReference: verifiedReference(id, image.ChecksumSHA256), ByteSize: int64(len(image.Bytes)), ChecksumSHA256: image.ChecksumSHA256, Width: image.Width, Height: image.Height} + repo := &readTestRepository{media: []VerifiedMedia{entry}} + storage := &readTestStorage{bytes: image.Bytes} + identity := &readTestIdentity{user: users.InternalUser{ID: uuid.New()}} + reader := NewReader(identity, repo, storage) + listingID := uuid.New() + photos, err := reader.List(context.Background(), users.VerifiedIdentity{}, PublicRead, listingID) + if err != nil || len(photos) != 1 || photos[0].ID != id || identity.calls != 0 { + t.Fatal("public safe projection failed") + } + data, err := reader.Get(context.Background(), users.VerifiedIdentity{Subject: "owner"}, OwnerRead, listingID, id) + if err != nil || !bytes.Equal(data, image.Bytes) || repo.actor != identity.user.ID { + t.Fatal("authorized image delivery failed") + } + storage.bytes = append(image.Bytes, 1) + if _, err := reader.Get(context.Background(), users.VerifiedIdentity{}, PublicRead, listingID, id); !errors.Is(err, ErrUnavailable) { + t.Fatal("corrupted object served") + } + repo.err = ErrNotFound + before := storage.calls + if _, err := reader.Get(context.Background(), users.VerifiedIdentity{}, PublicRead, listingID, id); !errors.Is(err, ErrNotFound) || storage.calls != before { + t.Fatal("denied image reached storage") + } + identity.err = users.ErrInvalidIdentity + before = repo.calls + if _, err := reader.List(context.Background(), users.VerifiedIdentity{}, OwnerRead, listingID); err == nil || repo.calls != before { + t.Fatal("missing identity reached repository") + } + if _, err := reader.List(context.Background(), users.VerifiedIdentity{}, ReadScope("administrator"), listingID); err == nil { + t.Fatal("unknown scope accepted") + } +} + +type readTestRepository struct { + media []VerifiedMedia + err error + actor uuid.UUID + calls int +} + +func (r *readTestRepository) ListVerified(_ context.Context, scope ReadScope, actor, _ uuid.UUID) ([]VerifiedMedia, error) { + r.calls++ + r.actor = actor + return r.media, r.err +} + +type readTestStorage struct { + bytes []byte + calls int +} + +func (s *readTestStorage) DownloadVerified(context.Context, VerifiedMedia) ([]byte, error) { + s.calls++ + return s.bytes, nil +} + +type readTestIdentity struct { + user users.InternalUser + calls int + err error +} + +func (i *readTestIdentity) Reconcile(context.Context, users.VerifiedIdentity) (users.InternalUser, bool, error) { + i.calls++ + return i.user, false, i.err +} diff --git a/backend/internal/listingmedia/repository.go b/backend/internal/listingmedia/repository.go index a6375c8..2a11bb0 100644 --- a/backend/internal/listingmedia/repository.go +++ b/backend/internal/listingmedia/repository.go @@ -11,8 +11,22 @@ type ProviderAuthorizer interface { RequireProvider(context.Context, users.VerifiedIdentity) (users.InternalUser, error) } type Repository interface { + RequireEditable(context.Context, uuid.UUID, uuid.UUID) error + FindReservation(context.Context, uuid.UUID, uuid.UUID, UploadRequest) (uuid.UUID, string, error) ReservePending(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, UploadRequest, string) error } type Storage interface { CreateUploadReservation(context.Context, uuid.UUID, UploadRequest) (StorageReservation, error) } + +type ManagedStorage interface { + Storage + FinalizationStorage + VerifiedStorage +} + +type ManagedRepository interface { + Repository + FinalizationRepository + ReadRepository +} diff --git a/backend/internal/listingmedia/service.go b/backend/internal/listingmedia/service.go index b6cd5d6..b3b31aa 100644 --- a/backend/internal/listingmedia/service.go +++ b/backend/internal/listingmedia/service.go @@ -28,7 +28,17 @@ func (s service) CreateUploadIntent(ctx context.Context, identity users.Verified if err != nil { return UploadIntent{}, err } - mediaID := uuid.New() + if err := s.repository.RequireEditable(ctx, owner.ID, listingID); err != nil { + return UploadIntent{}, err + } + mediaID, objectReference, err := s.repository.FindReservation(ctx, owner.ID, listingID, request) + if err != nil { + return UploadIntent{}, err + } + reusing := mediaID != uuid.Nil + if !reusing { + mediaID = uuid.New() + } reservation, err := s.storage.CreateUploadReservation(ctx, mediaID, request) if err != nil { return UploadIntent{}, ErrUnavailable @@ -36,8 +46,14 @@ func (s service) CreateUploadIntent(ctx context.Context, identity users.Verified if reservation.ObjectReference == "" || reservation.Capability.URL == "" || reservation.Capability.Method == "" { return UploadIntent{}, ErrUnavailable } - if err := s.repository.ReservePending(ctx, owner.ID, listingID, mediaID, request, reservation.ObjectReference); err != nil { - return UploadIntent{}, ErrUnavailable + if reusing { + if reservation.ObjectReference != objectReference { + return UploadIntent{}, ErrUnavailable + } + } else { + if err := s.repository.ReservePending(ctx, owner.ID, listingID, mediaID, request, reservation.ObjectReference); err != nil { + return UploadIntent{}, ErrUnavailable + } } return UploadIntent{MediaID: mediaID, Capability: reservation.Capability}, nil } diff --git a/backend/internal/listingmedia/service_test.go b/backend/internal/listingmedia/service_test.go index 1a080be..bdf0cb5 100644 --- a/backend/internal/listingmedia/service_test.go +++ b/backend/internal/listingmedia/service_test.go @@ -58,6 +58,24 @@ type recordingRepository struct { owner uuid.UUID objectReference string calls int + editableErr error +} + +func (r *recordingRepository) RequireEditable(context.Context, uuid.UUID, uuid.UUID) error { + return r.editableErr +} + +func (r *recordingRepository) FindReservation(context.Context, uuid.UUID, uuid.UUID, UploadRequest) (uuid.UUID, string, error) { + return uuid.Nil, "", nil +} + +func TestServiceRejectsNonEditableListingBeforeStorage(t *testing.T) { + storage := &recordingStorage{} + repository := &recordingRepository{editableErr: provideraccess.ErrForbidden} + _, err := NewService(&recordingAuthorizer{owner: users.InternalUser{ID: uuid.New()}}, repository, storage).CreateUploadIntent(context.Background(), users.VerifiedIdentity{Subject: "provider"}, uuid.New(), validRequest()) + if !errors.Is(err, provideraccess.ErrForbidden) || storage.calls != 0 || repository.calls != 0 { + t.Fatalf("non-editable listing minted storage capability: err=%v storage=%d writes=%d", err, storage.calls, repository.calls) + } } func (r *recordingRepository) ReservePending(_ context.Context, owner, listingID, mediaID uuid.UUID, request UploadRequest, objectReference string) error { diff --git a/backend/internal/listingmedia/supabase_finalization.go b/backend/internal/listingmedia/supabase_finalization.go new file mode 100644 index 0000000..6437687 --- /dev/null +++ b/backend/internal/listingmedia/supabase_finalization.go @@ -0,0 +1,106 @@ +package listingmedia + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/google/uuid" +) + +const maxImageBytes = 10 * 1024 * 1024 + +var _ FinalizationStorage = supabaseStorage{} + +func (s supabaseStorage) DownloadPending(ctx context.Context, reference string) ([]byte, error) { + if !strings.HasPrefix(reference, "pending/") || !canonicalMediaID(strings.TrimPrefix(reference, "pending/")) { + return nil, ErrInvalidUpload + } + return s.downloadObject(ctx, reference) +} + +func canonicalMediaID(value string) bool { + id, err := uuid.Parse(value) + return err == nil && id != uuid.Nil && id.String() == value +} + +func (s supabaseStorage) downloadObject(ctx context.Context, reference string) ([]byte, error) { + response, err := s.objectRequest(ctx, http.MethodGet, "/object/authenticated/"+s.bucket+"/"+reference, nil) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK || response.ContentLength > maxImageBytes { + return nil, ErrUnavailable + } + body, err := io.ReadAll(io.LimitReader(response.Body, maxImageBytes+1)) + if err != nil || len(body) == 0 || len(body) > maxImageBytes { + return nil, ErrUnavailable + } + return body, nil +} + +// WriteVerified never overwrites. Both new writes and duplicate retries must +// read back exactly the sanitized bytes before persistence can mark media ready. +func (s supabaseStorage) WriteVerified(ctx context.Context, id uuid.UUID, image SanitizedImage) (string, error) { + if id == uuid.Nil || !validSanitizedImage(image) { + return "", ErrInvalidUpload + } + reference := "verified/" + id.String() + "/" + image.ChecksumSHA256 + ".png" + response, err := s.objectRequest(ctx, http.MethodPost, "/object/"+s.bucket+"/"+reference, image.Bytes) + if err != nil { + return "", err + } + body, readErr := io.ReadAll(io.LimitReader(response.Body, 16*1024+1)) + _ = response.Body.Close() + if readErr != nil || len(body) > 16*1024 { + return "", ErrUnavailable + } + if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated { + var failure struct { + StatusCode string `json:"statusCode"` + Error string `json:"error"` + } + duplicate := json.Unmarshal(body, &failure) == nil && failure.StatusCode == "409" && failure.Error == "Duplicate" + if response.StatusCode != http.StatusConflict && !(response.StatusCode == http.StatusBadRequest && duplicate) { + return "", ErrUnavailable + } + } + stored, err := s.downloadObject(ctx, reference) + if err != nil || !bytes.Equal(stored, image.Bytes) { + return "", ErrUnavailable + } + return reference, nil +} + +func validSanitizedImage(image SanitizedImage) bool { + if image.ContentType != "image/png" || len(image.Bytes) == 0 || len(image.Bytes) > maxImageBytes || image.Width < 1 || image.Height < 1 || image.Width > 8192 || image.Height > 8192 || int64(image.Width)*int64(image.Height) > 16_000_000 { + return false + } + sum := sha256.Sum256(image.Bytes) + return image.ChecksumSHA256 == hex.EncodeToString(sum[:]) +} + +func (s supabaseStorage) objectRequest(ctx context.Context, method, path string, body []byte) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, s.origin+"/storage/v1"+path, bytes.NewReader(body)) + if err != nil { + return nil, ErrUnavailable + } + req.Header.Set("Authorization", "Bearer "+s.serverKey) + req.Header.Set("apikey", s.serverKey) + if method == http.MethodPost { + req.Header.Set("Content-Type", "image/png") + req.Header.Set("x-upsert", "false") + req.Header.Set("Cache-Control", "no-store") + } + response, err := s.client.Do(req) + if err != nil { + return nil, ErrUnavailable + } + return response, nil +} diff --git a/backend/internal/listingmedia/supabase_finalization_test.go b/backend/internal/listingmedia/supabase_finalization_test.go new file mode 100644 index 0000000..a48adb3 --- /dev/null +++ b/backend/internal/listingmedia/supabase_finalization_test.go @@ -0,0 +1,146 @@ +package listingmedia + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" +) + +func finalizationAdapter(t *testing.T, handler http.HandlerFunc) FinalizationStorage { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + storage, err := NewSupabaseStorage(SupabaseStorageConfig{Origin: server.URL, ServerKey: "unit-test-key", Bucket: "listing-images"}) + if err != nil { + t.Fatal(err) + } + adapter, ok := storage.(FinalizationStorage) + if !ok { + t.Fatal("Supabase adapter does not support finalization") + } + return adapter +} + +func TestSupabaseFinalizationPrivateRoundTripAndImmutableReplay(t *testing.T) { + ctx := context.Background() + id := uuid.New() + body := testPNG(t, 2, 3) + verified, err := SanitizeImage(body, imageRequest(body)) + if err != nil { + t.Fatal(err) + } + pending := "pending/" + id.String() + reference := "verified/" + id.String() + "/" + verified.ChecksumSHA256 + ".png" + objects := map[string][]byte{pending: body} + writes, reads := 0, 0 + storage := finalizationAdapter(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer unit-test-key" || r.Header.Get("apikey") != "unit-test-key" { + t.Error("missing server authentication") + w.WriteHeader(401) + return + } + if r.Method == http.MethodGet { + reads++ + key := strings.TrimPrefix(r.URL.Path, "/storage/v1/object/authenticated/listing-images/") + data, ok := objects[key] + if !ok { + w.WriteHeader(404) + return + } + _, _ = w.Write(data) + return + } + if r.Method != http.MethodPost || r.URL.Path != "/storage/v1/object/listing-images/"+reference || r.Header.Get("x-upsert") != "false" || r.Header.Get("Content-Type") != "image/png" { + t.Error("unsafe object write") + w.WriteHeader(400) + return + } + writes++ + if _, exists := objects[reference]; exists { + w.WriteHeader(400) + _, _ = w.Write([]byte(`{"statusCode":"409","error":"Duplicate","message":"The resource already exists"}`)) + return + } + data, readErr := io.ReadAll(r.Body) + if readErr != nil { + t.Error(readErr) + } + objects[reference] = data + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"Key":"listing-images/verified/image.png"}`)) + }) + got, err := storage.DownloadPending(ctx, pending) + if err != nil || !bytes.Equal(got, body) { + t.Fatalf("private download failed: %v", err) + } + for range 2 { + ref, err := storage.WriteVerified(ctx, id, verified) + if err != nil || ref != reference { + t.Fatalf("publication/replay failed: %v", err) + } + } + if writes != 2 || reads != 3 || !bytes.Equal(objects[reference], verified.Bytes) { + t.Fatal("immutable write did not verify stored bytes on both attempts") + } +} + +func TestSupabaseFinalizationRejectsUnsafeReferencesBeforeIO(t *testing.T) { + calls := 0 + storage := finalizationAdapter(t, func(w http.ResponseWriter, r *http.Request) { calls++; w.WriteHeader(500) }) + for _, ref := range []string{"", "pending/../key", "pending/%2e%2e", "https://evil.example", "pending/" + uuid.Nil.String(), "pending/" + uuid.New().String() + "?token=bad", "verified/" + uuid.New().String()} { + if _, err := storage.DownloadPending(context.Background(), ref); !errors.Is(err, ErrInvalidUpload) { + t.Fatalf("unsafe reference accepted: %q", ref) + } + } + if _, err := storage.WriteVerified(context.Background(), uuid.Nil, SanitizedImage{}); !errors.Is(err, ErrInvalidUpload) { + t.Fatal("invalid publication accepted") + } + if calls != 0 { + t.Fatal("invalid reference reached provider") + } +} + +func TestSupabaseFinalizationRejectsOversizeRedirectAndCorruptReadback(t *testing.T) { + body := testPNG(t, 2, 3) + verified, err := SanitizeImage(body, imageRequest(body)) + if err != nil { + t.Fatal(err) + } + for _, mode := range []string{"oversize", "redirect", "corrupt", "provider-error"} { + t.Run(mode, func(t *testing.T) { + storage := finalizationAdapter(t, func(w http.ResponseWriter, r *http.Request) { + switch mode { + case "oversize": + _, _ = w.Write(bytes.Repeat([]byte{1}, 10485761)) + case "redirect": + w.Header().Set("Location", "/leaked-key") + w.WriteHeader(302) + case "corrupt": + if r.Method == http.MethodPost { + w.WriteHeader(200) + } else { + _, _ = w.Write([]byte("wrong bytes")) + } + default: + w.WriteHeader(403) + _, _ = w.Write([]byte("unit-test-key")) + } + }) + if mode == "corrupt" { + _, err = storage.WriteVerified(context.Background(), uuid.New(), verified) + } else { + _, err = storage.DownloadPending(context.Background(), "pending/"+uuid.New().String()) + } + if !errors.Is(err, ErrUnavailable) || strings.Contains(err.Error(), "unit-test-key") { + t.Fatal("unsafe response accepted or provider error leaked") + } + }) + } +} diff --git a/backend/internal/listingmedia/supabase_reader.go b/backend/internal/listingmedia/supabase_reader.go new file mode 100644 index 0000000..9363860 --- /dev/null +++ b/backend/internal/listingmedia/supabase_reader.go @@ -0,0 +1,16 @@ +package listingmedia + +import "context" + +var _ VerifiedStorage = supabaseStorage{} + +func (s supabaseStorage) DownloadVerified(ctx context.Context, entry VerifiedMedia) ([]byte, error) { + if !validVerifiedMedia(entry) { + return nil, ErrInvalidUpload + } + body, err := s.downloadObject(ctx, entry.ObjectReference) + if err != nil || !matchesVerifiedBytes(body, entry) { + return nil, ErrUnavailable + } + return body, nil +} diff --git a/backend/internal/listingmedia/supabase_reader_test.go b/backend/internal/listingmedia/supabase_reader_test.go new file mode 100644 index 0000000..031ef52 --- /dev/null +++ b/backend/internal/listingmedia/supabase_reader_test.go @@ -0,0 +1,49 @@ +package listingmedia + +import ( + "bytes" + "context" + "errors" + "net/http" + "testing" + + "github.com/google/uuid" +) + +func TestSupabaseVerifiedReaderChecksReferenceAndStoredImage(t *testing.T) { + body := testPNG(t, 2, 3) + image, err := SanitizeImage(body, imageRequest(body)) + if err != nil { + t.Fatal(err) + } + id := uuid.New() + entry := VerifiedMedia{ID: id, Ordinal: 1, ObjectReference: verifiedReference(id, image.ChecksumSHA256), ByteSize: int64(len(image.Bytes)), ChecksumSHA256: image.ChecksumSHA256, Width: image.Width, Height: image.Height} + calls := 0 + response := image.Bytes + storage := finalizationAdapter(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != http.MethodGet || r.URL.Path != "/storage/v1/object/authenticated/listing-images/"+entry.ObjectReference || r.Header.Get("Authorization") != "Bearer unit-test-key" { + t.Error("wrong authenticated verified read") + w.WriteHeader(403) + return + } + _, _ = w.Write(response) + }) + reader, ok := storage.(VerifiedStorage) + if !ok { + t.Fatal("storage does not support verified reads") + } + got, err := reader.DownloadVerified(context.Background(), entry) + if err != nil || !bytes.Equal(got, image.Bytes) { + t.Fatal("verified read failed") + } + response = append(image.Bytes, 1) + if _, err := reader.DownloadVerified(context.Background(), entry); !errors.Is(err, ErrUnavailable) { + t.Fatal("corrupt object accepted") + } + before := calls + entry.ObjectReference = "pending/" + id.String() + if _, err := reader.DownloadVerified(context.Background(), entry); !errors.Is(err, ErrInvalidUpload) || calls != before { + t.Fatal("pending reference reached provider") + } +} diff --git a/backend/internal/listingmedia/supabase_storage.go b/backend/internal/listingmedia/supabase_storage.go new file mode 100644 index 0000000..6b5af64 --- /dev/null +++ b/backend/internal/listingmedia/supabase_storage.go @@ -0,0 +1,92 @@ +package listingmedia + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/google/uuid" +) + +// SupabaseStorageConfig is server-owned configuration, never a browser DTO. +type SupabaseStorageConfig struct { + Origin string + ServerKey string + Bucket string + HTTPClient *http.Client +} + +type supabaseStorage struct { + origin, serverKey, bucket string + client *http.Client +} + +var storageBucketPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{2,62}$`) + +func NewSupabaseStorage(config SupabaseStorageConfig) (ManagedStorage, error) { + origin := strings.TrimSuffix(strings.TrimSpace(config.Origin), "/") + u, err := url.Parse(origin) + if err != nil || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost"))) || strings.TrimSpace(config.ServerKey) == "" || strings.ContainsAny(config.ServerKey, "\r\n") || !storageBucketPattern.MatchString(config.Bucket) { + return nil, ErrUnavailable + } + client := http.Client{Timeout: 15 * time.Second} + if config.HTTPClient != nil { + client = *config.HTTPClient + } + if client.Timeout <= 0 || client.Timeout > 15*time.Second { + client.Timeout = 15 * time.Second + } + // Even a same-host redirect could forward server credentials to another path. + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return supabaseStorage{origin: origin, serverKey: config.ServerKey, bucket: config.Bucket, client: &client}, nil +} + +func (s supabaseStorage) CreateUploadReservation(ctx context.Context, id uuid.UUID, input UploadRequest) (StorageReservation, error) { + if id == uuid.Nil || !validUploadRequest(input) { + return StorageReservation{}, ErrInvalidUpload + } + object := "pending/" + id.String() + path := "/object/upload/sign/" + s.bucket + "/" + object + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.origin+"/storage/v1"+path, strings.NewReader(`{}`)) + if err != nil { + return StorageReservation{}, ErrUnavailable + } + req.Header.Set("Authorization", "Bearer "+s.serverKey) + req.Header.Set("apikey", s.serverKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-upsert", "false") + response, err := s.client.Do(req) + if err != nil { + return StorageReservation{}, ErrUnavailable + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return StorageReservation{}, ErrUnavailable + } + const limit = 16 * 1024 + body, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil || len(body) > limit { + return StorageReservation{}, ErrUnavailable + } + var result struct { + URL string `json:"url"` + } + if json.Unmarshal(body, &result) != nil || strings.Contains(result.URL, s.serverKey) { + return StorageReservation{}, ErrUnavailable + } + signed, err := url.Parse(result.URL) + if err != nil || signed.IsAbs() || signed.Host != "" || signed.User != nil || signed.Path != path || signed.RawPath != "" || signed.Fragment != "" { + return StorageReservation{}, ErrUnavailable + } + query, err := url.ParseQuery(signed.RawQuery) + tokens := query["token"] + if err != nil || len(query) != 1 || len(tokens) != 1 || tokens[0] == "" || len(tokens[0]) > 8192 || strings.ContainsAny(tokens[0], "\r\n\x00") { + return StorageReservation{}, ErrUnavailable + } + return StorageReservation{ObjectReference: object, Capability: UploadCapability{URL: s.origin + "/storage/v1" + signed.String(), Method: http.MethodPut, Headers: map[string]string{"Content-Type": strings.ToLower(input.ContentType)}}}, nil +} diff --git a/backend/internal/listingmedia/supabase_storage_test.go b/backend/internal/listingmedia/supabase_storage_test.go new file mode 100644 index 0000000..4fbff2e --- /dev/null +++ b/backend/internal/listingmedia/supabase_storage_test.go @@ -0,0 +1,111 @@ +package listingmedia + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestSupabaseStorageMintsOnlyScopedUploadCapability(t *testing.T) { + id := uuid.New() + path := "/object/upload/sign/vila-quarantine/pending/" + id.String() + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != "POST" || r.URL.Path != "/storage/v1"+path { + t.Errorf("unexpected storage route %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer synthetic-server-key" || r.Header.Get("apikey") != "synthetic-server-key" || r.Header.Get("x-upsert") != "false" { + t.Error("missing server authentication or overwrite restriction") + } + _ = json.NewEncoder(w).Encode(map[string]string{"url": path + "?token=synthetic-upload-capability"}) + })) + defer server.Close() + storage, err := NewSupabaseStorage(SupabaseStorageConfig{Origin: server.URL, ServerKey: "synthetic-server-key", Bucket: "vila-quarantine"}) + if err != nil { + t.Fatal(err) + } + r, err := storage.CreateUploadReservation(context.Background(), id, validRequest()) + if err != nil { + t.Fatal(err) + } + if r.ObjectReference != "pending/"+id.String() || r.Capability.URL != server.URL+"/storage/v1"+path+"?token=synthetic-upload-capability" || r.Capability.Method != "PUT" || len(r.Capability.Headers) != 1 || r.Capability.Headers["Content-Type"] != "image/webp" { + t.Fatal("unexpected capability projection") + } + if strings.Contains(r.Capability.URL, "synthetic-server-key") { + t.Fatal("server key exposed") + } + if _, err := storage.CreateUploadReservation(context.Background(), uuid.Nil, validRequest()); !errors.Is(err, ErrInvalidUpload) { + t.Fatal("nil media ID accepted") + } + if calls != 1 { + t.Fatalf("invalid upload performed network request: %d calls", calls) + } +} + +func TestSupabaseStorageRejectsUnsafeConfig(t *testing.T) { + for _, origin := range []string{"http://storage.example.com", "https://user:pass@storage.example.com", "https://storage.example.com/path", "https://storage.example.com?x=1", "https://storage.example.com#fragment", "file:///tmp"} { + if _, err := NewSupabaseStorage(SupabaseStorageConfig{Origin: origin, ServerKey: "synthetic-server-key", Bucket: "vila-quarantine"}); !errors.Is(err, ErrUnavailable) { + t.Errorf("accepted unsafe origin %q", origin) + } + } + for _, bucket := range []string{"", "../other", "bucket/path", "bucket?query", "Bucket"} { + if _, err := NewSupabaseStorage(SupabaseStorageConfig{Origin: "https://storage.example.com", ServerKey: "synthetic-server-key", Bucket: bucket}); err == nil { + t.Errorf("accepted unsafe bucket %q", bucket) + } + } + if _, err := NewSupabaseStorage(SupabaseStorageConfig{Origin: "https://storage.example.com", Bucket: "vila-quarantine"}); err == nil { + t.Fatal("accepted missing server key") + } +} + +func TestSupabaseStorageRejectsUntrustedCapabilityResponses(t *testing.T) { + for _, response := range []string{ + `{"url":"https://other.example.com/upload?token=abc"}`, + `{"url":"//other.example.com/upload?token=abc"}`, + `{"url":"/object/upload/sign/other/path?token=abc"}`, + `{"url":"/object/upload/sign/vila-quarantine/pending/ID"}`, + `{"url":"/object/upload/sign/vila-quarantine/pending/ID?token=a&token=b"}`, + `{"url":"/object/upload/sign/vila-quarantine/pending/ID?token=synthetic-server-key"}`, + `{"url":"/object/upload/sign/vila-quarantine/pending/ID?token=abc#fragment"}`, + strings.Repeat("x", 17000), + } { + id := uuid.New() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(strings.ReplaceAll(response, "ID", id.String()))) + })) + storage, err := NewSupabaseStorage(SupabaseStorageConfig{Origin: server.URL, ServerKey: "synthetic-server-key", Bucket: "vila-quarantine"}) + if err != nil { + t.Fatal(err) + } + r, err := storage.CreateUploadReservation(context.Background(), id, validRequest()) + server.Close() + if !errors.Is(err, ErrUnavailable) || r.Capability.URL != "" { + t.Fatal("unsafe provider response became capability") + } + } +} + +func TestSupabaseStorageDoesNotFollowCredentialedRedirect(t *testing.T) { + followed := false + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { followed = true })) + defer target.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect) + })) + defer server.Close() + storage, err := NewSupabaseStorage(SupabaseStorageConfig{Origin: server.URL, ServerKey: "synthetic-server-key", Bucket: "vila-quarantine", HTTPClient: server.Client()}) + if err != nil { + t.Fatal(err) + } + _, err = storage.CreateUploadReservation(context.Background(), uuid.New(), validRequest()) + if !errors.Is(err, ErrUnavailable) || followed { + t.Fatal("credentialed redirect followed") + } +} diff --git a/backend/internal/listingmedia/upload_retry_test.go b/backend/internal/listingmedia/upload_retry_test.go new file mode 100644 index 0000000..38ed9c5 --- /dev/null +++ b/backend/internal/listingmedia/upload_retry_test.go @@ -0,0 +1,28 @@ +package listingmedia + +import ( + "context" + "errors" + "testing" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/provideraccess" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/users" + "github.com/google/uuid" +) + +func TestUploadIntentReusesReservedObjectWithoutAnotherWrite(t *testing.T) { + owner:=users.InternalUser{ID:uuid.New()}; mediaID:=uuid.New(); reference:="pending/"+mediaID.String() + repo:=&retryRepository{mediaID:mediaID,reference:reference} + storage:=&recordingStorage{reservation:StorageReservation{ObjectReference:reference,Capability:UploadCapability{URL:"https://upload.example.invalid/scoped",Method:"PUT"}}} + intent,err:=NewService(&recordingAuthorizer{owner:owner},repo,storage).CreateUploadIntent(context.Background(),users.VerifiedIdentity{Subject:"provider"},uuid.New(),validRequest()) + if err!=nil||intent.MediaID!=mediaID||repo.calls!=0 {t.Fatalf("retry lost original reservation: id=%s writes=%d err=%v",intent.MediaID,repo.calls,err)} +} +func TestUploadIntentRejectsDifferentReservedBytesBeforeStorage(t *testing.T) { + repo:=&retryRepository{lookupErr:ErrConflict};storage:=&recordingStorage{} + _,err:=NewService(&recordingAuthorizer{owner:users.InternalUser{ID:uuid.New()}},repo,storage).CreateUploadIntent(context.Background(),users.VerifiedIdentity{Subject:"provider"},uuid.New(),validRequest()) + if !errors.Is(err,ErrConflict)||storage.calls!=0 {t.Fatalf("conflicting reservation reached storage: %v",err)} + repo.lookupErr=provideraccess.ErrForbidden + _,err=NewService(&recordingAuthorizer{owner:users.InternalUser{ID:uuid.New()}},repo,storage).CreateUploadIntent(context.Background(),users.VerifiedIdentity{Subject:"provider"},uuid.New(),validRequest()) + if !errors.Is(err,provideraccess.ErrForbidden)||storage.calls!=0 {t.Fatalf("ownership lookup was bypassed: %v",err)} +} +type retryRepository struct {recordingRepository;mediaID uuid.UUID;reference string;lookupErr error} +func(r *retryRepository) FindReservation(context.Context,uuid.UUID,uuid.UUID,UploadRequest)(uuid.UUID,string,error){return r.mediaID,r.reference,r.lookupErr} diff --git a/backend/internal/listings/listingmedia_repository_test.go b/backend/internal/listings/listingmedia_repository_test.go index 905c1e6..ee8e0b2 100644 --- a/backend/internal/listings/listingmedia_repository_test.go +++ b/backend/internal/listings/listingmedia_repository_test.go @@ -2,10 +2,13 @@ package listings import ( "context" + "errors" "testing" + entlisting "github.com/SourceSenseiTheRealOne/juntly/backend/ent/listing" entlistingmedia "github.com/SourceSenseiTheRealOne/juntly/backend/ent/listingmedia" "github.com/SourceSenseiTheRealOne/juntly/backend/internal/listingmedia" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/provideraccess" "github.com/google/uuid" ) @@ -22,6 +25,12 @@ func TestListingMediaEntRepositoryReservesOwnerPendingMediaWithoutPublicReferenc request := listingmedia.UploadRequest{Ordinal: 1, ContentType: "image/webp", ByteSize: 1024, ChecksumSHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} privateReference := "storage-internal/media/" + mediaID.String() repository := listingmedia.NewEntRepository(client) + if err := repository.RequireEditable(ctx, owner.ID, listing.ID); err != nil { + t.Fatal(err) + } + if err := repository.RequireEditable(ctx, other.ID, listing.ID); !errors.Is(err, provideraccess.ErrForbidden) { + t.Fatalf("cross-owner preauthorization: %v", err) + } if err := repository.ReservePending(ctx, owner.ID, listing.ID, mediaID, request, privateReference); err != nil { t.Fatalf("reserve: %v", err) } @@ -36,4 +45,10 @@ func TestListingMediaEntRepositoryReservesOwnerPendingMediaWithoutPublicReferenc if err != nil || count != 1 { t.Fatalf("count=%d err=%v", count, err) } + if err := client.Listing.UpdateOneID(listing.ID).SetState(entlisting.StatePendingReview).Exec(ctx); err != nil { + t.Fatal(err) + } + if err := repository.RequireEditable(ctx, owner.ID, listing.ID); !errors.Is(err, provideraccess.ErrForbidden) { + t.Fatalf("non-editable preauthorization: %v", err) + } } diff --git a/backend/internal/listings/media_finalization_integration_test.go b/backend/internal/listings/media_finalization_integration_test.go new file mode 100644 index 0000000..ac79d74 --- /dev/null +++ b/backend/internal/listings/media_finalization_integration_test.go @@ -0,0 +1,192 @@ +package listings + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "image" + "image/png" + "strings" + "sync" + "testing" + + entlisting "github.com/SourceSenseiTheRealOne/juntly/backend/ent/listing" + "github.com/SourceSenseiTheRealOne/juntly/backend/ent/listingevent" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/listingmedia" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/provideraccess" + "github.com/google/uuid" +) + +func TestListingMediaFinalizationDurableIsolationAndReplay(t *testing.T) { + client := openListingClient(t) + ctx := context.Background() + owner, localities, category := createListingProvider(t, client) + other, _, _ := createListingProvider(t, client) + listing, err := NewEntRepository(client).Create(ctx, owner.ID, integrationCreate(category, localities[0])) + if err != nil { + t.Fatal(err) + } + repo := listingmedia.NewEntRepository(client) + final, ok := repo.(listingmedia.FinalizationRepository) + if !ok { + t.Fatal("repository does not support durable finalization") + } + var buffer bytes.Buffer + if err := png.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, 2, 3))); err != nil { + t.Fatal(err) + } + body := buffer.Bytes() + sum := sha256.Sum256(body) + request := listingmedia.UploadRequest{Ordinal: 1, ContentType: "image/png", ByteSize: int64(len(body)), ChecksumSHA256: hex.EncodeToString(sum[:])} + verified, err := listingmedia.SanitizeImage(body, request) + if err != nil { + t.Fatal(err) + } + mediaID := uuid.New() + pending := "pending/" + mediaID.String() + reference := "verified/" + mediaID.String() + "/" + verified.ChecksumSHA256 + ".png" + if err := repo.ReservePending(ctx, owner.ID, listing.ID, mediaID, request, pending); err != nil { + t.Fatal(err) + } + if _, err := final.GetEditable(ctx, other.ID, listing.ID, mediaID); !errors.Is(err, provideraccess.ErrForbidden) { + t.Fatal("cross-owner read accepted") + } + if err := final.Complete(ctx, other.ID, listing.ID, mediaID, listing.Revision, reference, verified); !errors.Is(err, provideraccess.ErrForbidden) { + t.Fatal("cross-owner completion accepted") + } + media, err := final.GetEditable(ctx, owner.ID, listing.ID, mediaID) + if err != nil || media.Ready || media.ObjectReference != pending { + t.Fatalf("pending projection: %v", err) + } + if err := final.Complete(ctx, owner.ID, listing.ID, mediaID, media.Revision+1, reference, verified); err == nil { + t.Fatal("stale revision accepted") + } + if err := final.Complete(ctx, owner.ID, listing.ID, mediaID, media.Revision, "verified/wrong.png", verified); err == nil { + t.Fatal("unscoped publication reference accepted") + } + for range 2 { + if err := final.Complete(ctx, owner.ID, listing.ID, mediaID, media.Revision, reference, verified); err != nil { + t.Fatal(err) + } + } + stored, err := client.ListingMedia.Get(ctx, mediaID) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(stored) + if err != nil || !strings.Contains(string(encoded), `"verified_object_reference":"`+reference+`"`) || stored.ObjectReference != pending || string(stored.State) != "ready" { + t.Fatal("ready record lost verified bytes or original upload provenance") + } + updated, err := client.Listing.Get(ctx, listing.ID) + if err != nil || updated.Revision != media.Revision+1 { + t.Fatal("completion did not advance parent revision exactly once") + } + events, err := client.ListingEvent.Query().Where(listingevent.ListingIDEQ(listing.ID), listingevent.EventTypeEQ(listingevent.EventTypeUpdated)).Count(ctx) + if err != nil || events != 1 { + t.Fatal("completion audit is not idempotent") + } + reader, ok := repo.(listingmedia.ReadRepository) + if !ok { + t.Fatal("repository cannot enforce verified-media reads") + } + photos, err := reader.ListVerified(ctx, listingmedia.OwnerRead, owner.ID, listing.ID) + if err != nil || len(photos) != 1 || photos[0].ObjectReference != reference { + t.Fatal("owner cannot read verified photo") + } + for _, scope := range []listingmedia.ReadScope{listingmedia.PublicRead, listingmedia.OwnerRead, listingmedia.ModeratorRead} { + if _, err := reader.ListVerified(ctx, scope, other.ID, listing.ID); !errors.Is(err, listingmedia.ErrNotFound) { + t.Fatalf("draft exposed to unauthorized scope %s", scope) + } + } + if err := client.PlatformRole.Create().SetInternalUserID(other.ID).SetRole("moderator").Exec(ctx); err != nil { + t.Fatal(err) + } + if photos, err := reader.ListVerified(ctx, listingmedia.ModeratorRead, other.ID, listing.ID); err != nil || len(photos) != 1 { + t.Fatal("durably granted moderator cannot review verified photo") + } + if err := client.Listing.UpdateOneID(listing.ID).SetState(entlisting.StateActive).Exec(ctx); err != nil { + t.Fatal(err) + } + if photos, err := reader.ListVerified(ctx, listingmedia.PublicRead, uuid.Nil, listing.ID); err != nil || len(photos) != 1 { + t.Fatal("active listing photo unavailable publicly") + } + if err := client.Listing.UpdateOneID(listing.ID).SetState(entlisting.StatePaused).Exec(ctx); err != nil { + t.Fatal(err) + } + if _, err := reader.ListVerified(ctx, listingmedia.PublicRead, uuid.Nil, listing.ID); !errors.Is(err, listingmedia.ErrNotFound) { + t.Fatal("paused listing photo remained public") + } + if err := client.Listing.UpdateOneID(listing.ID).SetState(entlisting.StateDraft).Exec(ctx); err != nil { + t.Fatal(err) + } + verified.Bytes = append(verified.Bytes, 1) + if err := final.Complete(ctx, owner.ID, listing.ID, mediaID, media.Revision, reference, verified); err == nil { + t.Fatal("contradictory replay accepted") + } + if err := client.Listing.UpdateOneID(listing.ID).SetState(entlisting.StatePendingReview).Exec(ctx); err != nil { + t.Fatal(err) + } + if _, err := final.GetEditable(ctx, owner.ID, listing.ID, mediaID); !errors.Is(err, provideraccess.ErrForbidden) { + t.Fatal("reviewed listing remained editable") + } +} + +func TestListingMediaFinalizationConcurrentCompletionHasOneAudit(t *testing.T) { + client := openListingClient(t) + ctx := context.Background() + owner, localities, category := createListingProvider(t, client) + listing, err := NewEntRepository(client).Create(ctx, owner.ID, integrationCreate(category, localities[0])) + if err != nil { + t.Fatal(err) + } + repo := listingmedia.NewEntRepository(client) + final, ok := repo.(listingmedia.FinalizationRepository) + if !ok { + t.Fatal("repository does not support durable finalization") + } + var buffer bytes.Buffer + if err := png.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, 2, 3))); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(buffer.Bytes()) + request := listingmedia.UploadRequest{Ordinal: 1, ContentType: "image/png", ByteSize: int64(buffer.Len()), ChecksumSHA256: hex.EncodeToString(sum[:])} + verified, err := listingmedia.SanitizeImage(buffer.Bytes(), request) + if err != nil { + t.Fatal(err) + } + id := uuid.New() + if err := repo.ReservePending(ctx, owner.ID, listing.ID, id, request, "pending/"+id.String()); err != nil { + t.Fatal(err) + } + ref := "verified/" + id.String() + "/" + verified.ChecksumSHA256 + ".png" + start := make(chan struct{}) + results := make(chan error, 2) + var wg sync.WaitGroup + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + <-start + results <- final.Complete(ctx, owner.ID, listing.ID, id, listing.Revision, ref, verified) + }() + } + close(start) + wg.Wait() + close(results) + winners := 0 + for err := range results { + if err == nil { + winners++ + } + } + if winners == 0 { + t.Fatal("no completion committed") + } + events, err := client.ListingEvent.Query().Where(listingevent.ListingIDEQ(listing.ID), listingevent.EventTypeEQ(listingevent.EventTypeUpdated)).Count(ctx) + if err != nil || events != 1 { + t.Fatal("concurrent completion duplicated or lost audit") + } +} diff --git a/backend/internal/listings/media_retry_integration_test.go b/backend/internal/listings/media_retry_integration_test.go new file mode 100644 index 0000000..a0e50f7 --- /dev/null +++ b/backend/internal/listings/media_retry_integration_test.go @@ -0,0 +1,28 @@ +package listings + +import ( + "context" + "errors" + "strings" + "testing" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/listingmedia" + "github.com/SourceSenseiTheRealOne/juntly/backend/internal/provideraccess" + "github.com/google/uuid" +) + +func TestListingMediaReservationRecoveryIsBoundToOwnerAndBytes(t *testing.T) { + client:=openListingClient(t);ctx:=context.Background();owner,localities,category:=createListingProvider(t,client);other,_,_:=createListingProvider(t,client) + item,err:=NewEntRepository(client).Create(ctx,owner.ID,integrationCreate(category,localities[0]));if err!=nil{t.Fatal(err)} + repo:=listingmedia.NewEntRepository(client) + finder,ok:=any(repo).(interface{FindReservation(context.Context,uuid.UUID,uuid.UUID,listingmedia.UploadRequest)(uuid.UUID,string,error)}) + if !ok{t.Fatal("repository cannot recover interrupted uploads")} + request:=listingmedia.UploadRequest{Ordinal:1,ContentType:"image/png",ByteSize:128,ChecksumSHA256:strings.Repeat("a",64)} + id,_,err:=finder.FindReservation(ctx,owner.ID,item.ID,request);if err!=nil||id!=uuid.Nil{t.Fatal("empty slot recovery failed")} + mediaID:=uuid.New();reference:="pending/"+mediaID.String();if err:=repo.ReservePending(ctx,owner.ID,item.ID,mediaID,request,reference);err!=nil{t.Fatal(err)} + id,stored,err:=finder.FindReservation(ctx,owner.ID,item.ID,request);if err!=nil||id!=mediaID||stored!=reference{t.Fatalf("lost durable reservation: %v",err)} + if _,_,err:=finder.FindReservation(ctx,other.ID,item.ID,request);!errors.Is(err,provideraccess.ErrForbidden){t.Fatal("other owner recovered capability")} + changed:=request;changed.ChecksumSHA256=strings.Repeat("b",64) + if _,_,err:=finder.FindReservation(ctx,owner.ID,item.ID,changed);!errors.Is(err,listingmedia.ErrConflict){t.Fatal("different bytes reused reservation")} + if _,err:=client.Listing.UpdateOneID(item.ID).SetState("pending_review").Save(ctx);err!=nil{t.Fatal(err)} + if _,_,err:=finder.FindReservation(ctx,owner.ID,item.ID,request);!errors.Is(err,provideraccess.ErrForbidden){t.Fatal("noneditable reservation was recovered")} +} diff --git a/backend/internal/payments/refund_integrity_integration_test.go b/backend/internal/payments/refund_integrity_integration_test.go new file mode 100644 index 0000000..a0b9001 --- /dev/null +++ b/backend/internal/payments/refund_integrity_integration_test.go @@ -0,0 +1,149 @@ +package payments + +import ( + "context" + "database/sql" + "errors" + "os" + "strings" + "testing" + + "github.com/google/uuid" +) + +type paymentFixture struct { + db *sql.DB + store Store + customer, provider, order uuid.UUID + suffix string +} + +func newPaymentFixture(t *testing.T, state State) paymentFixture { + t.Helper() + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL is required") + } + db, err := sql.Open("pgx", url) + if err != nil { + t.Fatal(err) + } + f := paymentFixture{db: db, store: NewSQLStore(db), customer: uuid.New(), provider: uuid.New(), order: uuid.New(), suffix: strings.ReplaceAll(uuid.NewString(), "-", "")} + booking := uuid.New() + t.Cleanup(func() { + defer db.Close() + for _, q := range []string{ + `delete from public.stripe_webhook_receipts where stripe_event_id like 'evt_' || $1 || '%'`, + } { + if _, err := db.Exec(q, f.suffix); err != nil { + t.Error(err) + } + } + for _, q := range []string{ + `delete from public.payment_orders where id=$1`, + } { + if _, err := db.Exec(q, f.order); err != nil { + t.Error(err) + } + } + if _, err := db.Exec(`delete from public.bookings where id=$1`, booking); err != nil { + t.Error(err) + } + if _, err := db.Exec(`delete from public.provider_profiles where internal_user_id=$1`, f.provider); err != nil { + t.Error(err) + } + if _, err := db.Exec(`delete from public.platform_roles where internal_user_id=$1`, f.provider); err != nil { + t.Error(err) + } + if _, err := db.Exec(`delete from public.user_accounts where internal_user_id in($1,$2)`, f.customer, f.provider); err != nil { + t.Error(err) + } + if _, err := db.Exec(`delete from public.internal_users where id in($1,$2)`, f.customer, f.provider); err != nil { + t.Error(err) + } + }) + for _, id := range []uuid.UUID{f.customer, f.provider} { + if _, err := db.Exec(`insert into public.internal_users(id,clerk_subject) values($1,$2)`, id, "synthetic_"+id.String()); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`insert into public.user_accounts(internal_user_id,provider_enabled) values($1,$2)`, id, id == f.provider); err != nil { + t.Fatal(err) + } + } + if _, err := db.Exec(`insert into public.provider_profiles(internal_user_id,display_name,provider_type,bio,primary_locality_id,max_travel_distance_km,remote_services) select $1,'Synthetic provider','professional','Isolated payment regression fixture.',id,0,true from public.localities order by id limit 1`, f.provider); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`insert into public.platform_roles(id,internal_user_id,role) values($1,$2,'moderator')`, uuid.New(), f.provider); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`insert into public.bookings(id,customer_internal_user_id,provider_internal_user_id,source_type,idempotency_key,state,scheduled_at,private_location,agreed_price_minor) values($1,$2,$3,'direct',$4,'confirmed',now()+interval '1 day','Synthetic test location',12500)`, booking, f.customer, f.provider, f.suffix); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`insert into public.payment_orders(id,booking_id,customer_internal_user_id,provider_internal_user_id,idempotency_key,state,gross_minor,platform_fee_minor,provider_net_minor,currency,stripe_checkout_session_id,stripe_payment_intent_id) values($1,$2,$3,$4,$5,$6,12500,1250,11250,'EUR',$7,$8)`, f.order, booking, f.customer, f.provider, f.suffix, state, "cs_"+f.suffix, "pi_"+f.suffix); err != nil { + t.Fatal(err) + } + return f +} + +func (f paymentFixture) event(kind EventKind) ProviderEvent { + return ProviderEvent{ID: "evt_" + f.suffix, Kind: kind, ProviderObjectID: "cs_" + f.suffix, OrderID: f.order.String(), PaymentIntentID: "pi_" + f.suffix, AmountMinor: 12500, Currency: "EUR"} +} + +func TestRefundIntegrityWebhookWinsRace(t *testing.T) { + f := newPaymentFixture(t, StatePaid) + event := f.event(EventRefunded) + event.ProviderObjectID, event.OrderID = "ch_"+f.suffix, "" + if err := f.store.ApplyProviderEvent(context.Background(), event); err != nil { + t.Fatal(err) + } + for range 2 { + order, err := f.store.AttachRefund(context.Background(), f.provider, f.order, RefundResult{ID: "re_" + f.suffix}) + if err != nil { + t.Fatal(err) + } + if order.State != StateRefunded { + t.Fatalf("late refund response regressed terminal state to %s", order.State) + } + } + var count int + if err := f.db.QueryRow(`select count(*) from public.payment_events where payment_order_id=$1 and event_type='refund_requested'`, f.order).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("late response wrote %d redundant events", count) + } +} + +func TestRefundIntegrityRejectsUnauthorizedAttachment(t *testing.T) { + f := newPaymentFixture(t, StatePaid) + _, err := f.store.AttachRefund(context.Background(), f.customer, f.order, RefundResult{ID: "re_" + f.suffix}) + if !errors.Is(err, ErrForbidden) { + t.Fatalf("unauthorized attachment: %v", err) + } +} + +func TestRefundIntegrityReplayDoesNotDuplicateAudit(t *testing.T) { + f := newPaymentFixture(t, StatePaid) + for range 2 { + if _, err := f.store.AttachRefund(context.Background(), f.provider, f.order, RefundResult{ID: "re_" + f.suffix}); err != nil { + t.Fatal(err) + } + } + var count int + if err := f.db.QueryRow(`select count(*) from public.payment_events where payment_order_id=$1 and event_type='refund_requested'`, f.order).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("refund replay wrote %d audit events, want 1", count) + } + if _, err := f.store.AttachRefund(context.Background(), f.provider, f.order, RefundResult{ID: "re_other"}); !errors.Is(err, ErrConflict) { + t.Fatalf("different refund replaced pending refund: %v", err) + } +} + +func TestRefundIntegrityRejectsIneligibleState(t *testing.T) { + f := newPaymentFixture(t, StateDisputeLost) + if _, err := f.store.AttachRefund(context.Background(), f.provider, f.order, RefundResult{ID: "re_" + f.suffix}); !errors.Is(err, ErrConflict) { + t.Fatalf("ineligible attachment: %v", err) + } +} diff --git a/backend/internal/payments/sql_store.go b/backend/internal/payments/sql_store.go index a5fde40..3ccee69 100644 --- a/backend/internal/payments/sql_store.go +++ b/backend/internal/payments/sql_store.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" "github.com/google/uuid" @@ -193,11 +194,26 @@ func (s sqlStore) ApplyProviderEvent(ctx context.Context, event ProviderEvent) e return err } defer func() { _ = tx.Rollback() }() - result, err := tx.ExecContext(ctx, `insert into public.stripe_webhook_receipts(stripe_event_id,event_type,provider_object_id,outcome) values($1,$2,$3,'processing') on conflict do nothing`, event.ID, event.Kind, event.ProviderObjectID) + var order Order + if event.Kind != EventAccountUpdate { + order, err = loadOrderForEvent(ctx, tx, event) + if err != nil { + return err + } + } + result, err := tx.ExecContext(ctx, `insert into public.stripe_webhook_receipts(stripe_event_id,event_type,provider_object_id,outcome) values($1,$2,$3,'claimed') on conflict do nothing`, event.ID, event.Kind, event.ProviderObjectID) if err != nil { return err } if rows, _ := result.RowsAffected(); rows == 0 { + var kind EventKind + var objectID, outcome string + if err := tx.QueryRowContext(ctx, `select event_type,provider_object_id,outcome from public.stripe_webhook_receipts where stripe_event_id=$1`, event.ID).Scan(&kind, &objectID, &outcome); err != nil { + return err + } + if kind != event.Kind || objectID != event.ProviderObjectID || outcome != string(event.Kind) { + return ErrConflict + } return tx.Commit() } if event.Kind == EventAccountUpdate { @@ -214,15 +230,19 @@ func (s sqlStore) ApplyProviderEvent(ctx context.Context, event ProviderEvent) e } return tx.Commit() } - order, err := loadOrderForEvent(ctx, tx, event) - if err != nil { - return err - } from := order.State to, eventType, err := eventTransition(from, event.Kind) if err != nil { return err } + if from == to { + // Distinct Stripe events can describe the same settled payment. + // Record delivery without repeating the financial transition/audit. + if _, err := tx.ExecContext(ctx, `update public.stripe_webhook_receipts set outcome=$1,processed_at=timezone('utc',now()) where stripe_event_id=$2`, eventType, event.ID); err != nil { + return err + } + return tx.Commit() + } _, err = tx.ExecContext(ctx, `update public.payment_orders set state=$1,stripe_checkout_session_id=coalesce(nullif($2,''),stripe_checkout_session_id),stripe_payment_intent_id=coalesce(nullif($3,''),stripe_payment_intent_id),stripe_invoice_id=coalesce(nullif($4,''),stripe_invoice_id),paid_at=case when $1='paid' then coalesce(paid_at,timezone('utc',now())) else paid_at end,refunded_at=case when $1='refunded' then coalesce(refunded_at,timezone('utc',now())) else refunded_at end,updated_at=timezone('utc',now()) where id=$5`, to, checkoutID(event), event.PaymentIntentID, event.InvoiceID, order.ID) if err != nil { return err @@ -279,17 +299,49 @@ func (s sqlStore) PrepareRefund(ctx context.Context, actor, orderID uuid.UUID) ( } func (s sqlStore) AttachRefund(ctx context.Context, actor, orderID uuid.UUID, refund RefundResult) (Order, error) { + if !strings.HasPrefix(refund.ID, "re_") { + return Order{}, ErrInvalid + } tx, err := s.database.BeginTx(ctx, nil) if err != nil { return Order{}, err } defer func() { _ = tx.Rollback() }() - var from State - if err := tx.QueryRowContext(ctx, `select state from public.payment_orders where id=$1 for update`, orderID).Scan(&from); errors.Is(err, sql.ErrNoRows) { - return Order{}, ErrNotFound - } else if err != nil { + var authorized bool + if err := tx.QueryRowContext(ctx, `select exists(select 1 from public.platform_roles where internal_user_id=$1 and role in('moderator','administrator'))`, actor).Scan(&authorized); err != nil { + return Order{}, err + } + if !authorized { + return Order{}, ErrForbidden + } + order, found, err := loadOrderByID(ctx, tx, orderID, true) + if err != nil { return Order{}, err } + if !found { + return Order{}, ErrNotFound + } + if order.RefundID != "" && order.RefundID != refund.ID { + return Order{}, ErrConflict + } + if order.State == StateRefunded || order.State == StateRefundPending { + // A signed webhook can finish before the provider HTTP response arrives. + // Attach the reference without regressing state or adding another event. + if order.RefundID == "" { + if _, err := tx.ExecContext(ctx, `update public.payment_orders set stripe_refund_id=$1,updated_at=timezone('utc',now()) where id=$2`, refund.ID, orderID); err != nil { + return Order{}, err + } + order, _, err = loadOrderByID(ctx, tx, orderID, false) + if err != nil { + return Order{}, err + } + } + return order, tx.Commit() + } + if order.State != StatePaid && order.State != StateDisputeWon { + return Order{}, ErrConflict + } + from := order.State result, err := tx.ExecContext(ctx, `update public.payment_orders set state='refund_pending',stripe_refund_id=$1,updated_at=timezone('utc',now()) where id=$2 and state=$3`, refund.ID, orderID, from) if err != nil { return Order{}, err @@ -300,7 +352,7 @@ func (s sqlStore) AttachRefund(ctx context.Context, actor, orderID uuid.UUID, re if _, err := tx.ExecContext(ctx, `insert into public.payment_events(payment_order_id,event_type,from_state,to_state,provider_object_id) values($1,'refund_requested',$2,'refund_pending',$3)`, orderID, from, refund.ID); err != nil { return Order{}, err } - order, _, err := loadOrderByID(ctx, tx, orderID, false) + order, _, err = loadOrderByID(ctx, tx, orderID, false) if err != nil { return Order{}, err } @@ -343,9 +395,13 @@ func orderScan(order *Order) []any { func loadOrderForEvent(ctx context.Context, tx *sql.Tx, event ProviderEvent) (Order, error) { var order Order var row *sql.Row - if event.OrderID != "" { - row = tx.QueryRowContext(ctx, orderSelect+` where id=$1 for update`, event.OrderID) - } else if event.PaymentIntentID != "" { + checkout := event.Kind == EventProcessing || event.Kind == EventPaid || event.Kind == EventFailed + if checkout { + if !strings.HasPrefix(event.ProviderObjectID, "cs_") { + return Order{}, ErrInvalid + } + row = tx.QueryRowContext(ctx, orderSelect+` where stripe_checkout_session_id=$1 for update`, event.ProviderObjectID) + } else if strings.HasPrefix(event.PaymentIntentID, "pi_") { row = tx.QueryRowContext(ctx, orderSelect+` where stripe_payment_intent_id=$1 for update`, event.PaymentIntentID) } else { return Order{}, ErrInvalid @@ -355,12 +411,30 @@ func loadOrderForEvent(ctx context.Context, tx *sql.Tx, event ProviderEvent) (Or } else if err != nil { return Order{}, err } + if event.Currency != order.Currency || event.AmountMinor <= 0 { + return Order{}, ErrConflict + } + if checkout && (event.OrderID != order.ID || event.AmountMinor != order.GrossMinor) { + return Order{}, ErrConflict + } + if event.Kind == EventPaid && !strings.HasPrefix(event.PaymentIntentID, "pi_") { + return Order{}, ErrConflict + } + if order.PaymentIntentID != "" && event.PaymentIntentID != order.PaymentIntentID { + return Order{}, ErrConflict + } + if event.Kind == EventRefunded && (event.AmountMinor != order.GrossMinor || !strings.HasPrefix(event.ProviderObjectID, "ch_")) { + return Order{}, ErrConflict + } + if (event.Kind == EventDisputeOpened || event.Kind == EventDisputeWon || event.Kind == EventDisputeLost) && (event.AmountMinor > order.GrossMinor || !strings.HasPrefix(event.ProviderObjectID, "dp_")) { + return Order{}, ErrConflict + } return order, nil } func eventTransition(from State, kind EventKind) (State, string, error) { switch kind { case EventProcessing: - if from == StateCheckoutCreated { + if from == StateCheckoutCreated || from == StateProcessing { return StateProcessing, "processing", nil } case EventPaid: @@ -371,11 +445,11 @@ func eventTransition(from State, kind EventKind) (State, string, error) { return from, "paid", nil } case EventFailed: - if from == StateCheckoutCreated || from == StateProcessing { + if from == StateCheckoutCreated || from == StateProcessing || from == StateFailed { return StateFailed, "failed", nil } case EventRefunded: - if from == StateRefundPending || from == StatePaid || from == StateDisputed || from == StateDisputeLost { + if from == StateRefundPending || from == StatePaid || from == StateDisputed || from == StateDisputeLost || from == StateDisputeWon || from == StateRefunded { return StateRefunded, "refunded", nil } case EventDisputeOpened: diff --git a/backend/internal/payments/sql_store_integration_test.go b/backend/internal/payments/sql_store_integration_test.go index 5c1d6b0..084d6ba 100644 --- a/backend/internal/payments/sql_store_integration_test.go +++ b/backend/internal/payments/sql_store_integration_test.go @@ -70,7 +70,7 @@ func TestSQLStorePersistsCheckoutAndIdempotentPaidWebhook(t *testing.T) { if err != nil || order.State != StateCheckoutCreated { t.Fatalf("attach order=%#v err=%v", order, err) } - event := ProviderEvent{ID: "evt_123synthetic", Kind: EventPaid, ProviderObjectID: "cs_testpayment", OrderID: order.ID, PaymentIntentID: "pi_syntheticpayment", InvoiceID: "in_syntheticpayment", OccurredAt: time.Now().UTC()} + event := ProviderEvent{ID: "evt_123synthetic", Kind: EventPaid, ProviderObjectID: "cs_testpayment", OrderID: order.ID, PaymentIntentID: "pi_syntheticpayment", InvoiceID: "in_syntheticpayment", AmountMinor: 12500, Currency: "EUR", OccurredAt: time.Now().UTC()} if err := store.ApplyProviderEvent(ctx, event); err != nil { t.Fatalf("apply event: %v", err) } diff --git a/backend/internal/payments/stripe_gateway.go b/backend/internal/payments/stripe_gateway.go index 60e03e1..746563a 100644 --- a/backend/internal/payments/stripe_gateway.go +++ b/backend/internal/payments/stripe_gateway.go @@ -250,12 +250,15 @@ func projectStripeEvent(payload []byte) (ProviderEvent, error) { PaymentStatus string `json:"payment_status"` PaymentIntent string `json:"payment_intent"` Invoice string `json:"invoice"` + AmountTotal int64 `json:"amount_total"` + Currency string `json:"currency"` Metadata map[string]string `json:"metadata"` } if json.Unmarshal(envelope.Data.Object, &object) != nil || !strings.HasPrefix(object.ID, "cs_") || object.Metadata["order_id"] == "" { return ProviderEvent{}, ErrInvalid } event.ProviderObjectID, event.OrderID, event.PaymentIntentID, event.InvoiceID = object.ID, object.Metadata["order_id"], object.PaymentIntent, object.Invoice + event.AmountMinor, event.Currency = object.AmountTotal, strings.ToUpper(object.Currency) if envelope.Type == "checkout.session.async_payment_failed" { event.Kind = EventFailed } else if object.PaymentStatus == "paid" || envelope.Type == "checkout.session.async_payment_succeeded" { diff --git a/backend/internal/payments/webhook_integrity_integration_test.go b/backend/internal/payments/webhook_integrity_integration_test.go new file mode 100644 index 0000000..dd89e54 --- /dev/null +++ b/backend/internal/payments/webhook_integrity_integration_test.go @@ -0,0 +1,79 @@ +package payments + +import ( + "context" + "errors" + "testing" +) + +func TestWebhookIntegrityRejectsMismatchedFinancialIdentity(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*ProviderEvent) + }{ + {"checkout_session", func(e *ProviderEvent) { e.ProviderObjectID = "cs_wrong" }}, + {"payment_intent", func(e *ProviderEvent) { e.PaymentIntentID = "pi_wrong" }}, + {"amount", func(e *ProviderEvent) { e.AmountMinor = 1 }}, + {"missing_amount", func(e *ProviderEvent) { e.AmountMinor = 0 }}, + {"currency", func(e *ProviderEvent) { e.Currency = "USD" }}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newPaymentFixture(t, StateCheckoutCreated) + event := f.event(EventPaid) + tc.mutate(&event) + if err := f.store.ApplyProviderEvent(context.Background(), event); err == nil { + t.Fatal("mismatched event accepted") + } + var state State + var receipts int + if err := f.db.QueryRow(`select state from public.payment_orders where id=$1`, f.order).Scan(&state); err != nil { + t.Fatal(err) + } + if err := f.db.QueryRow(`select count(*) from public.stripe_webhook_receipts where stripe_event_id=$1`, event.ID).Scan(&receipts); err != nil { + t.Fatal(err) + } + if state != StateCheckoutCreated || receipts != 0 { + t.Fatalf("rejected event persisted: state=%s receipts=%d", state, receipts) + } + }) + } +} + +func TestWebhookIntegrityPartialRefundDoesNotMarkFullyRefunded(t *testing.T) { + f := newPaymentFixture(t, StatePaid) + e := f.event(EventRefunded) + e.ProviderObjectID, e.OrderID, e.AmountMinor = "ch_"+f.suffix, "", 100 + if err := f.store.ApplyProviderEvent(context.Background(), e); !errors.Is(err, ErrConflict) { + t.Fatalf("partial refund marked as full: %v", err) + } +} + +func TestWebhookIntegrityDifferentEventsDoNotDuplicatePaidAudit(t *testing.T) { + f := newPaymentFixture(t, StateCheckoutCreated) + e := f.event(EventPaid) + for _, suffix := range []string{"", "duplicate"} { + e.ID = "evt_" + f.suffix + suffix + if err := f.store.ApplyProviderEvent(context.Background(), e); err != nil { + t.Fatal(err) + } + } + var n int + if err := f.db.QueryRow(`select count(*) from public.payment_events where payment_order_id=$1 and event_type='paid'`, f.order).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("same payment produced %d paid events", n) + } +} + +func TestWebhookIntegrityContradictoryReceiptConflicts(t *testing.T) { + f := newPaymentFixture(t, StateCheckoutCreated) + e := f.event(EventPaid) + if err := f.store.ApplyProviderEvent(context.Background(), e); err != nil { + t.Fatal(err) + } + e.Kind = EventFailed + if err := f.store.ApplyProviderEvent(context.Background(), e); !errors.Is(err, ErrConflict) { + t.Fatalf("contradictory replay returned %v", err) + } +} diff --git a/backend/internal/payments/webhook_projection_test.go b/backend/internal/payments/webhook_projection_test.go new file mode 100644 index 0000000..47d9ed5 --- /dev/null +++ b/backend/internal/payments/webhook_projection_test.go @@ -0,0 +1,10 @@ +package payments + +import "testing" + +func TestCheckoutProjectionIncludesReconciliationMoney(t *testing.T) { + e, err := projectStripeEvent([]byte(`{"id":"evt_reconciliation","type":"checkout.session.completed","created":1800000000,"data":{"object":{"id":"cs_reconciliation","payment_status":"paid","payment_intent":"pi_reconciliation","amount_total":12500,"currency":"eur","metadata":{"order_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"}}}}`)) + if err != nil || e.AmountMinor != 12500 || e.Currency != "EUR" { + t.Fatalf("missing reconciliation money: amount=%d currency=%s err=%v", e.AmountMinor, e.Currency, err) + } +} diff --git a/backend/internal/payments/webhook_replay_integration_test.go b/backend/internal/payments/webhook_replay_integration_test.go new file mode 100644 index 0000000..42e1eda --- /dev/null +++ b/backend/internal/payments/webhook_replay_integration_test.go @@ -0,0 +1,41 @@ +package payments + +import ( + "context" + "testing" +) + +func TestWebhookIntegritySettledTransitionReplays(t *testing.T) { + for _, tc := range []struct { + name string + state State + kind EventKind + }{ + {"processing", StateCheckoutCreated, EventProcessing}, + {"failed", StateCheckoutCreated, EventFailed}, + {"refunded", StatePaid, EventRefunded}, + {"refund_after_dispute_won", StateDisputeWon, EventRefunded}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newPaymentFixture(t, tc.state) + e := f.event(tc.kind) + if tc.kind == EventRefunded { + e.ProviderObjectID = "ch_" + f.suffix + e.OrderID = "" + } + for _, suffix := range []string{"", "", "second"} { + e.ID = "evt_" + f.suffix + suffix + if err := f.store.ApplyProviderEvent(context.Background(), e); err != nil { + t.Fatalf("replay %q: %v", suffix, err) + } + } + var n int + if err := f.db.QueryRow(`select count(*) from public.payment_events where payment_order_id=$1`, f.order).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("transition recorded %d times", n) + } + }) + } +} diff --git a/docs/superpowers/plans/2026-09-07-vila-launch-payment-integrity.md b/docs/superpowers/plans/2026-09-07-vila-launch-payment-integrity.md new file mode 100644 index 0000000..f00a456 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-vila-launch-payment-integrity.md @@ -0,0 +1,53 @@ +# Vila Launch Payment Integrity Implementation Plan + +> **For agentic workers:** Use `superpowers:executing-plans` inline in the canonical checkout. User approved the complete launch-completion sequence and selected Supabase Storage + Resend. + +**Goal:** Prevent payment webhooks and delayed refund responses from corrupting durable financial state before completing the remaining launch integrations. + +**Architecture:** Keep verified raw Stripe events at the gateway boundary and enforce persisted processor identity, exact money, authorization and monotonic state transitions inside PostgreSQL transactions. Keep browser DTOs unchanged; no credentials or live provider operations are needed for this slice. + +**Tech Stack:** Go, database/sql + pgx, PostgreSQL, existing Stripe HTTP adapter, Go tests. + +## Global Constraints + +- Vila is the public brand; preserve internal Juntly identifiers. +- No Heroku deployment, paid resources, live payments, external credential changes, or remote database mutation. +- Canonical checkout, one writer, RED → GREEN → REFACTOR. No commits/pushes without renewed explicit authorization. +- Supabase Storage and Resend are the approved next integrations, not proof of configured provider accounts. +- Retain Portugal/EUR, optional commission-free external arrangements, and the existing exact administrator identity boundary. + +## Approved completion sequence + +1. Payment correctness and recovery. +2. Real Supabase listing uploads, verification and publication. +3. Resend email delivery with durable claiming/retry and notification preferences. +4. Paid plan and promotion Checkout/activation/renewal/cancellation. +5. Customer/provider/admin journey verification, privacy/accessibility and launch operations. + +Each subsequent subsystem gets a bounded plan after tracing its current contracts. Existing future exclusions (advanced verification/analytics, native apps, AI matching, institutional dashboards) remain exclusions. + +## Task 1: Refund completion cannot regress state + +Files: `backend/internal/payments/sql_store.go`, new `backend/internal/payments/refund_integrity_integration_test.go`. + +Interface: existing `Store.AttachRefund(context.Context, uuid.UUID, uuid.UUID, RefundResult) (Order, error)`. + +- [ ] RED: PostgreSQL tests prepare a paid order, deliver a full refund webhook first, then attach the HTTP refund response. Assert the order stays refunded and replay adds no audit event. Test unauthorized actor and a different refund ID on pending state. +- [ ] GREEN: authorize in the attachment transaction, lock and inspect the order, allow only paid/dispute-won → refund-pending; treat matching pending/refunded attachment as idempotent, retaining terminal state. +- [ ] Verify using `go test -count=1 ./internal/payments -run RefundIntegrity -v` with an isolated test database. + +## Task 2: Reconcile verified events against durable Checkout and money + +Files: `backend/internal/payments/stripe_gateway.go`, `stripe_gateway_test.go`, `sql_store.go`, `sql_store_integration_test.go`, new `webhook_integrity_integration_test.go`. + +Interface: existing `ProviderEvent.AmountMinor`, `Currency`, `ProviderObjectID`, `OrderID`, `PaymentIntentID`. + +- [ ] RED: project `amount_total` and currency; wrong/missing amount, currency, persisted Checkout ID or PaymentIntent must not change order or create a processed receipt. Partial refunds must not mark the whole order refunded. Contradictory event-ID replay must conflict. +- [ ] GREEN: validate bounded verified projection fields; resolve Checkout by its persisted session ID rather than trusting metadata alone; compare metadata/amount/currency and existing PaymentIntent under the row lock. Check duplicate receipt identity/outcome and make same-state replay a no-op without duplicate financial events. +- [ ] Verify exact paid/replay/refund tests in PostgreSQL and gateway signature-negative tests. + +## Task 3: Verification and handoff into uploads + +- [ ] Run focused race tests, full Go tests, vet/build, CodeGraph affected, and diff whitespace checks. +- [ ] Keep provider-side activation explicitly blocked until rotated credentials, legal operator/fee/tax decisions and real test-mode payment/refund/payout evidence exist. +- [ ] Record source evidence separately from external launch proof; do not call the complete product launch-ready after this slice alone. diff --git a/docs/superpowers/plans/2026-09-07-vila-supabase-media.md b/docs/superpowers/plans/2026-09-07-vila-supabase-media.md new file mode 100644 index 0000000..f18ca42 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-vila-supabase-media.md @@ -0,0 +1,38 @@ +# Vila Supabase Media Implementation Plan + +> **For agentic workers:** Execute inline with `superpowers:executing-plans` and strict RED → GREEN → REFACTOR in the canonical checkout. + +**Goal:** Replace unavailable listing storage with a private Supabase upload and validated publication flow. + +**Architecture:** Clerk/Go own identity and listing authorization. Go mints a path-specific, non-overwriting signed upload capability for a private quarantine bucket. Raw objects never become public merely because an upload completed. Finalization must verify the stored object, sanitize image metadata, persist a ready record and serve images only for authorized owners/moderators or active approved listings. + +**Tech Stack:** Existing Go HTTP adapter boundary, Ent/PostgreSQL, Supabase Storage REST, Next.js BFF, generated OpenAPI. + +## Constraints + +- Supabase Storage is owner-approved; no new remote accounts, buckets, secrets or paid resources are authorized. +- Server key never reaches browser, logs or errors; only narrow signed upload capability may be returned. +- Allow JPEG, PNG, WebP; existing 10 MiB limit. Reject path escape, cross-origin returned URLs, redirects, oversized/malformed responses. +- Do not enable raw uploads as public listing photos. Keep runtime unavailable until finalization/read controls are implemented and verified. + +## Task 1: Authorize before minting upload capabilities + +Modify `backend/internal/listingmedia/repository.go`, `ent_repository.go`, `service.go`, `service_test.go`; extend `backend/internal/listings/listingmedia_repository_test.go`. + +- [ ] RED: authorized provider uploading to another owner's/non-editable listing must cause zero storage calls. +- [ ] GREEN: add `RequireEditable(context.Context, uuid.UUID, uuid.UUID) error` to the repository; call it before storage. Keep the existing reservation ownership/state check as a second guard. +- [ ] Verify service negative tests and actual PostgreSQL/Ent owner isolation. + +## Task 2: Private Supabase signed-upload adapter + +Create `backend/internal/listingmedia/supabase_storage.go` and `supabase_storage_test.go`. + +- [ ] RED: `NewSupabaseStorage(SupabaseStorageConfig)` returns `Storage`; `CreateUploadReservation` POSTs `/storage/v1/object/upload/sign//pending/` with server Authorization/apikey and `x-upsert: false`, then exposes only the exact path-scoped PUT URL and Content-Type. +- [ ] GREEN: strict origin/bucket config, bounded response and timeout, deny redirects, exact response path and token validation; no key-bearing request/error serialization. +- [ ] Verify loopback HTTP contract, invalid config, cross-origin/wrong-path/no-token responses, redirect refusal and invalid upload before provider I/O. + +## Task 3: Finalization, delivery and runtime wiring + +Trace the existing owner listing, moderation and public discovery models before adding contracts. Finalization must verify byte count, SHA-256, supported image decoding and dimensions; re-encode to remove EXIF/private metadata, write to a separate server-only immutable key, then atomically mark ready under editable-listing authorization. Add protected owner/moderator media reads and sanitized approved-public reads. Implement Next BFF/upload controls and generated OpenAPI together, then wire complete opt-in configuration. + +Acceptance: real local upload → byte verification → ready record → moderated public rendering; cross-owner/non-public reads fail closed; replay cannot replace a verified object. Remote provider configuration and real browser proof remain explicit activation gates, not assumed from unit tests. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1a4c74e..e84c54c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1,4 +1,19 @@ { + "ListingMedia": { + "title": "Photos", + "add": "Add photo", + "hint": "JPEG, PNG or WebP, up to 10 MB. Maximum 10 photos. Use your own images without personal information.", + "publication": "Photos are only public when the listing is approved and active.", + "loading": "Loading photos…", + "empty": "No photos yet.", + "error": "Photos could not be loaded.", + "retry": "Try again", + "uploading": "Uploading and verifying the photo…", + "uploadError": "The photo could not be verified. Try again with the same file.", + "saved": "Photo verified and saved.", + "limit": "You have reached the limit of 10 photos.", + "photoAlt": "{title}, photo {index}" + }, "Metadata": { "title": "Vila: Local services, real skills", "description": "Find local people and services near you. Vila brings communities, customers and trusted providers closer together." diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 50d4cdd..cf22e5c 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1,4 +1,19 @@ { + "ListingMedia": { + "title": "Fotografías", + "add": "Añadir fotografía", + "hint": "JPEG, PNG o WebP, hasta 10 MB. Máximo de 10 fotografías. Usa imágenes propias sin datos personales.", + "publication": "Las fotografías solo son públicas cuando el anuncio está aprobado y activo.", + "loading": "Cargando fotografías…", + "empty": "Todavía no hay fotografías.", + "error": "No se han podido cargar las fotografías.", + "retry": "Intentar de nuevo", + "uploading": "Enviando y verificando la fotografía…", + "uploadError": "No se ha podido verificar la fotografía. Inténtalo de nuevo con el mismo archivo.", + "saved": "Fotografía verificada y guardada.", + "limit": "Has alcanzado el límite de 10 fotografías.", + "photoAlt": "{title}, fotografía {index}" + }, "Metadata": { "title": "Vila: Servicios locales, habilidades reales", "description": "Encuentra personas y servicios locales cerca de ti. Vila acerca comunidades, clientes y profesionales de confianza." diff --git a/frontend/messages/pt-PT.json b/frontend/messages/pt-PT.json index e39b92e..a8fee58 100644 --- a/frontend/messages/pt-PT.json +++ b/frontend/messages/pt-PT.json @@ -1,4 +1,19 @@ { + "ListingMedia": { + "title": "Fotografias", + "add": "Adicionar fotografia", + "hint": "JPEG, PNG ou WebP, até 10 MB. Máximo de 10 fotografias. Use imagens próprias, sem dados pessoais.", + "publication": "As fotografias só ficam públicas quando o anúncio é aprovado e está ativo.", + "loading": "A carregar fotografias…", + "empty": "Ainda não há fotografias.", + "error": "Não foi possível carregar as fotografias.", + "retry": "Tentar novamente", + "uploading": "A enviar e verificar a fotografia…", + "uploadError": "Não foi possível verificar a fotografia. Tente novamente com o mesmo ficheiro.", + "saved": "Fotografia verificada e guardada.", + "limit": "Atingiu o limite de 10 fotografias.", + "photoAlt": "{title}, fotografia {index}" + }, "Metadata": { "title": "Vila: Serviços locais, competências reais", "description": "Encontre pessoas e serviços locais perto de si. A Vila aproxima comunidades, clientes e prestadores de confiança." diff --git a/frontend/src/app/[locale]/moderation/listings/page.tsx b/frontend/src/app/[locale]/moderation/listings/page.tsx index 51a32ff..8ac60f4 100644 --- a/frontend/src/app/[locale]/moderation/listings/page.tsx +++ b/frontend/src/app/[locale]/moderation/listings/page.tsx @@ -30,7 +30,7 @@ export default async function ModerationListingsPage({ return (
- +
); diff --git a/frontend/src/app/api/v1/me/listings/[listingId]/media/[mediaId]/finalize/route.ts b/frontend/src/app/api/v1/me/listings/[listingId]/media/[mediaId]/finalize/route.ts new file mode 100644 index 0000000..17c5a4c --- /dev/null +++ b/frontend/src/app/api/v1/me/listings/[listingId]/media/[mediaId]/finalize/route.ts @@ -0,0 +1,11 @@ +import { mediaProxy } from "@/features/listing-media/media-proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST( + request: Request, + context: { params: Promise<{ listingId: string; mediaId: string }> }, +): Promise { + return mediaProxy(request, await context.params, "me", "finalize"); +} diff --git a/frontend/src/app/api/v1/me/listings/[listingId]/media/[mediaId]/route.ts b/frontend/src/app/api/v1/me/listings/[listingId]/media/[mediaId]/route.ts new file mode 100644 index 0000000..dd415d5 --- /dev/null +++ b/frontend/src/app/api/v1/me/listings/[listingId]/media/[mediaId]/route.ts @@ -0,0 +1,11 @@ +import { mediaProxy } from "@/features/listing-media/media-proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + context: { params: Promise<{ listingId: string; mediaId: string }> }, +): Promise { + return mediaProxy(request, await context.params, "me", "image"); +} diff --git a/frontend/src/app/api/v1/me/listings/[listingId]/media/route.ts b/frontend/src/app/api/v1/me/listings/[listingId]/media/route.ts new file mode 100644 index 0000000..4a5d96e --- /dev/null +++ b/frontend/src/app/api/v1/me/listings/[listingId]/media/route.ts @@ -0,0 +1,11 @@ +import { mediaProxy } from "@/features/listing-media/media-proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + context: { params: Promise<{ listingId: string }> }, +): Promise { + return mediaProxy(request, await context.params, "me", "list"); +} diff --git a/frontend/src/app/api/v1/me/listings/[listingId]/media/upload-intents/route.ts b/frontend/src/app/api/v1/me/listings/[listingId]/media/upload-intents/route.ts new file mode 100644 index 0000000..eb580c2 --- /dev/null +++ b/frontend/src/app/api/v1/me/listings/[listingId]/media/upload-intents/route.ts @@ -0,0 +1,11 @@ +import { mediaProxy } from "@/features/listing-media/media-proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST( + request: Request, + context: { params: Promise<{ listingId: string }> }, +): Promise { + return mediaProxy(request, await context.params, "me", "upload"); +} diff --git a/frontend/src/app/api/v1/moderation/listings/[listingId]/media/[mediaId]/route.ts b/frontend/src/app/api/v1/moderation/listings/[listingId]/media/[mediaId]/route.ts new file mode 100644 index 0000000..aa3d2cb --- /dev/null +++ b/frontend/src/app/api/v1/moderation/listings/[listingId]/media/[mediaId]/route.ts @@ -0,0 +1,11 @@ +import { mediaProxy } from "@/features/listing-media/media-proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + context: { params: Promise<{ listingId: string; mediaId: string }> }, +): Promise { + return mediaProxy(request, await context.params, "moderation", "image"); +} diff --git a/frontend/src/app/api/v1/moderation/listings/[listingId]/media/route.ts b/frontend/src/app/api/v1/moderation/listings/[listingId]/media/route.ts new file mode 100644 index 0000000..6db5c1f --- /dev/null +++ b/frontend/src/app/api/v1/moderation/listings/[listingId]/media/route.ts @@ -0,0 +1,11 @@ +import { mediaProxy } from "@/features/listing-media/media-proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + context: { params: Promise<{ listingId: string }> }, +): Promise { + return mediaProxy(request, await context.params, "moderation", "list"); +} diff --git a/frontend/src/app/api/v1/public/listings/[listingId]/media/[mediaId]/route.ts b/frontend/src/app/api/v1/public/listings/[listingId]/media/[mediaId]/route.ts new file mode 100644 index 0000000..b4485c2 --- /dev/null +++ b/frontend/src/app/api/v1/public/listings/[listingId]/media/[mediaId]/route.ts @@ -0,0 +1,11 @@ +import { mediaProxy } from "@/features/listing-media/media-proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + context: { params: Promise<{ listingId: string; mediaId: string }> }, +): Promise { + return mediaProxy(request, await context.params, "public", "image"); +} diff --git a/frontend/src/app/api/v1/public/listings/[listingId]/media/route.ts b/frontend/src/app/api/v1/public/listings/[listingId]/media/route.ts new file mode 100644 index 0000000..94c0857 --- /dev/null +++ b/frontend/src/app/api/v1/public/listings/[listingId]/media/route.ts @@ -0,0 +1,11 @@ +import { mediaProxy } from "@/features/listing-media/media-proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + context: { params: Promise<{ listingId: string }> }, +): Promise { + return mediaProxy(request, await context.params, "public", "list"); +} diff --git a/frontend/src/features/discovery/public-listing-detail.test.tsx b/frontend/src/features/discovery/public-listing-detail.test.tsx index 073abd7..b03bc3e 100644 --- a/frontend/src/features/discovery/public-listing-detail.test.tsx +++ b/frontend/src/features/discovery/public-listing-detail.test.tsx @@ -29,6 +29,10 @@ describe("PublicListingDetail", () => { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { + if (String(input).endsWith("/media")) + return Response.json({ photos: [] }); + if (String(input).endsWith("/api/v1/me/listings")) + return Response.json({ listings: [] }); expect(String(input)).toContain( "/api/v1/public/listings/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa?locale=pt-PT", ); @@ -69,6 +73,9 @@ describe("PublicListingDetail", () => { ).toBeInTheDocument(), ); expect(screen.getByText("Prestador local")).toBeInTheDocument(); + expect( + await screen.findByText("Ainda não há fotografias."), + ).toBeInTheDocument(); expect( screen.queryByText(/internalUserId|phone|email|objectReference|bio/), ).not.toBeInTheDocument(); @@ -79,6 +86,7 @@ describe("PublicListingDetail", () => { "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); + if (url.endsWith("/media")) return Response.json({ photos: [] }); if (url.includes("/api/v1/me/listings")) { return Response.json({ listings: [ diff --git a/frontend/src/features/discovery/public-listing-detail.tsx b/frontend/src/features/discovery/public-listing-detail.tsx index 67c4c37..d9de4d8 100644 --- a/frontend/src/features/discovery/public-listing-detail.tsx +++ b/frontend/src/features/discovery/public-listing-detail.tsx @@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from "react"; import { ContactRevealControl } from "@/features/contact/contact-reveal-control"; import { StartConversationControl } from "@/features/messaging/start-conversation-control"; +import { ListingPhotos } from "@/features/listing-media/listing-photos"; +import { getMediaCopy } from "@/features/listing-media/media-copy"; type Listing = { id: string; @@ -133,6 +135,12 @@ export function PublicListingDetail({

{listing.description}

+