Skip to content

Product demo video + private logistics video evidence

Two parallel, deliberately separate media features added in this PR:

  • Tranche A — Product demo video: public catalog media, shared across every seller offering a product, changed only by catalog staff.
  • Tranche B — Private logistics video evidence: private, immutable dispute evidence for a delivery handover or a return pickup/receipt/inspection.

They share nothing except the container-format validation approach; they use different authorization models, different storage buckets, and different domain entities.

1. Shared product demo video ownership

Product is shared catalog master data - many SellerProduct listings can point at the same Product. Its demo video (Product.VideoUrl) is therefore catalog media, not seller-owned media, the same way Product.SpecificationsJson and Product.Warranty* already are (see UpdateProductSpecificationsCommandHandler's own doc comment and MarketplaceCatalogController's brand/variant endpoints).

  • POST /api/products/{id}/video and DELETE /api/products/{id}/video require CatalogModerator.ProductsManage exclusively (ProductVideoAuthorization, LogisticsEvidenceExceptions.ProductMediaForbiddenException). The legacy Product.SellerId field is never treated as ownership of shared media - an ordinary seller, including the seller who happens to be Product.SellerId, is denied even though they may hold Seller.ListingsManage.
  • Product.Update(...) (the ordinary product-edit path) no longer touches VideoUrl at all - a routine name/price/stock edit can never silently clear or overwrite the video. Only Product.SetVideoUrl(...), called exclusively from the video upload/delete handlers, changes it.
  • Seller-specific listing video (a SellerProduct/SellerProductMedia-scoped video, distinct per seller) is out of scope for this PR - see the P2 backlog below. Do not overload Product.VideoUrl with it.

Upload / replace / delete

  • Public storage only (IFileStorageService), object prefix products/{productId}/videos/, server-generated file name ({Guid}.mp4) - the client never supplies a path.
  • V1 accepts MP4 only (FileValidationHelper.TryValidateProductVideo), validated by parsing the real ftyp box and its brand list, not the client's claimed Content-Type or extension. QuickTime (qt) is explicitly rejected. WebM is not accepted for product video - see "Formats and codecs" below.
  • Size ceiling: ProductMedia:MaxVideoBytes, default 75 MB, read at request time (no restart needed to change it).
  • Replacement order: validate → upload the new object → SetVideoUrl → SaveChangesAsync → only then best-effort-delete the previous object. If the commit fails, the newly uploaded object is deleted best-effort and the old VideoUrl remains authoritative - no orphan is left on a successful upload, and no premature deletion of a still-valid video.
  • Delete: clears VideoUrl, commits, then best-effort-deletes the stored object - and only when ProductVideoStorage.IsOwnedVideoUrl confirms the URL is actually this product's own products/{id}/videos/{guid}.mp4 object. A legacy external URL, another product's URL, or a non-video path under the product's folder is never passed to IFileStorageService.DeleteAsync.
  • Every mutation invalidates the products: cache pattern.

Web / Flutter playback

  • Web customer PDP (ProductDetailPage.jsx) already rendered DNBVideoPlayer unmodified - no autoplay, controls, a poster (first product image), and a graceful "video unavailable" state. captions stays [] when the backend has no caption metadata (see below) rather than fabricating one.
  • Flutter customer PDP now actually plays the video (ProductVideoPlayer, using the official video_player plugin - the one new, justified dependency in this PR). Tap-to-play poster (no autoplay), play/pause, a scrub bar, mute toggle, HTTPS-only URL validation, loading and "video unavailable" + retry states, lifecycle-safe controller disposal, and the product page renders normally with no video card when videoUrl is null.

End-to-end upload size path

Hop Before fix/nginx-video-upload-limits Now
Frontend/container Nginx (frontend/nginx.conf) default ~1 MB configured: client_max_body_size 100m; on the /api/ location only
Staging Host Nginx (infra/nginx/nginx-vps-staging.conf.template) default ~1 MB configured: client_max_body_size 100m; on the /api/ location only
Production Host Nginx (infra/nginx/nginx-vps-production.conf.template) no repository-owned template existed candidate configured: client_max_body_size 100m; on the /api/ location only; not deployed or cut over
Kestrel MaxRequestBodySize default ~28.6 MB raised to ProductMedia:MaxVideoBytes / LogisticsEvidence:MaxVideoBytes only on the video upload actions, via ConfiguredUploadSizeLimitAttribute
ASP.NET multipart form limit default ~128 MB (already generous) explicitly matched to the same configured ceiling
Axios (frontend/src/services/api/index.js) 10s request timeout timeout: 0 (no timeout) for uploadVideo/uploadEvidence, plus onUploadProgress

100 MiB was chosen deliberately: enough headroom above the largest configured application ceiling (75 MiB product video) without being unbounded (client_max_body_size 0 is never used). It is scoped to the /api/ proxy location only in all three proxy configs - static SPA assets, the SPA fallback, and the Host templates' /api/health/ready and /health/live probes are untouched. The staging 3200/3201 and production 3300/3301 loopback contracts remain isolated.

Regression coverage: scripts/test-vps-deployment-safety.py's test_frontend_nginx_api_body_size_limit, test_vps_nginx_api_body_size_limit, and the production Host Nginx isolation tests assert the directive's presence, value, and scoping in source and after envsubst rendering. They also protect the staging 3200/3201 and production 3300/3301 routing boundaries. scripts/test-nginx-video-upload-limits.sh is a disposable, local-only functional proof (real nginx:alpine + a throwaway Docker network, never staging/production, no customer data, everything removed on exit) that a real ~75 MiB and ~50 MiB request pass through frontend/nginx.conf's unmodified /api/ location while a >100 MiB request is rejected by nginx itself with 413, before any upstream is reached.

Production Host Nginx remains a separately authorized cutover: the repository now contains the candidate infra/nginx/nginx-vps-production.conf.template, including the API-only 100 MiB limit, TLS safety, direct health routes, forwarded headers, and loopback production ports. The template and its disposable syntax test do not edit a host, obtain certificates, reload Nginx, deploy the production stack, or switch traffic. Applying and externally certifying it still requires the explicit process in Production Host Nginx cutover.

Application-level enforcement is unchanged and verified in VideoUploadRequestSizeTests over a real Kestrel socket: a video at the configured ceiling is admitted; the server default (no attribute) rejects the same file; a video over the ceiling gets an explicit 413 UPLOAD_TOO_LARGE before the body is read. The Nginx ceiling above is only the outer proxy ceiling - it does not, by itself, let any other endpoint accept a 100 MB body; every non-video endpoint is still bounded by Kestrel's ~28.6 MB default, and the video endpoints still enforce their own exact configured ceiling, MP4/WebM container validation, authorization, the private R2 namespace, and the database evidence constraints exactly as before.

2. Private logistics video evidence

LogisticsEvidence records dispute evidence for one of four closed event types:

DeliveryHandover   -> Shipment only
ReturnPickup       -> ReturnRequest only
ReturnReceived     -> ReturnRequest only
ReturnInspection   -> ReturnRequest only

Domain model and database invariants

  • LogisticsEvidence is immutable: every property has a private setter, there is no update method, and no API edits or deletes a committed row. CreateForShipment/CreateForReturn are the only ways to construct one and enforce the type-to-context mapping in-process.
  • Exactly one context (ShipmentId XOR ReturnRequestId) is enforced three times: the factory methods, CK_LogisticsEvidences_SingleContext, and CK_LogisticsEvidences_TypeContext (also enforces the type-to-context mapping at the database level).
  • One authoritative evidence row per (context, type): unique indexes IX_LogisticsEvidences_ShipmentId_EvidenceType and IX_LogisticsEvidences_ReturnRequestId_EvidenceType. MySQL treats NULL as distinct in a unique index, so a return-evidence row (ShipmentId NULL) never collides with a shipment row - verified against real MySQL 8 in MySqlLogisticsEvidenceTests.
  • Additive migration AddLogisticsEvidence only - no historical migration was edited. Verified: fresh apply, release-DB → new migration, second apply is a no-op, rollback + re-apply, and dotnet ef migrations has-pending-model-changes reports none.

Chain of custody

Computed and persisted server-side on every upload: SHA-256 of the exact stored bytes, size, uploader user id, and the server's own ingestion timestamp (UploadedAtUtc/CreatedAt - authoritative). CapturedAtUtc may come from client metadata but is explicitly documented as non-authoritative. Nothing here is ever logged: EvidenceUploaded is logged with the event's metadata only, deliberately never including the storage key, the video bytes, or a signed URL.

Private R2 namespace

R2PrivateFileStorageService's key validator was a closed allowlist (grievances/..., invoices/...) before this PR. PrivateStorageKeyPolicy (shared by the R2 implementation and the Development-only local implementation, so a key R2 would reject also fails locally and in tests) adds two exact shapes:

evidence/shipments/{shipmentId:guid}/{evidenceId:guid}.{mp4|webm}
evidence/returns/{returnRequestId:guid}/{evidenceId:guid}.{mp4|webm}

Rejected: any other top-level namespace, a non-GUID context or file name, any other extension, .., a backslash, a leading /, and a drive-rooted path - see the parameterized tests in LogisticsEvidenceTests.PrivateKeyPolicy_RejectsEverythingElse and the real-R2-service test that proves these are rejected before any network call is attempted.

Upload atomicity (storage and MySQL are not one transaction)

LogisticsEvidenceWriter (used by both the shipment and return upload handlers) handles every failure window explicitly:

  • A. Upload fails → the exception propagates; no row, no transition.
  • B. Upload succeeds, the database insert fails → the new object is deleted best-effort, the exception propagates.
  • C. The database uniqueness race is lost (two concurrent uploads for the same context+type) → the losing upload's object is deleted best-effort and the request resolves against the winning row: the same bytes (by SHA-256) is an idempotent success, different bytes raises LogisticsEvidenceAlreadyRecordedException. Proven against real MySQL/InnoDB with 6-8 genuinely concurrent uploads in MySqlLogisticsEvidenceTests (ConcurrentDifferentVideos..., ConcurrentIdenticalRetries..., ConcurrentReturnPickupUploads...).
  • D. Evidence is committed, a later state transition fails → the evidence is untouched and immutable; the transition can simply be retried, and the gate below then re-checks the (still present) evidence and passes.

Orphan cleanup is deliberately narrow: only the exact object a failed/losing request itself uploaded is ever deleted. There is no listing/sweep job that could remove valid evidence.

Manual-workflow gating (opt-in, default off)

LogisticsEvidence:MaxVideoBytes                          (default 50 MB)
LogisticsEvidence:RequireManualDeliveryVideo              (default false)
LogisticsEvidence:RequireManualReturnPickupVideo          (default false)
LogisticsEvidence:RequireManualReturnReceivedVideo        (default false)
LogisticsEvidence:RequireManualReturnInspectionVideo      (default false)

LogisticsEvidenceGate is consulted from inside MarkDeliveredCommandHandler, MarkReturnPickedUpCommandHandler, ReceiveReturnCommandHandler, and InspectReturnCommandHandler, only at the moment each would actually perform its transition (an already-completed retry keeps its own idempotency exception, e.g. ShipmentAlreadyDeliveredException).

  • Manual vs. carrier: Shipment.Provider/ReturnShipment.Provider is "Manual" or "Test" in this codebase (no real courier integration exists yet - see marketplace-logistics.md "Provider abstraction"). DeliveryHandover, ReturnPickup and ReturnReceived are gated only when the provider is Manual - a future carrier-driven provider is never blocked by a flag a courier could never satisfy. ReturnInspection is always a human seller/warehouse QC step (no provider performs it), so it is gated by its flag regardless of provider.
  • Every flag defaults to false, so the existing delivery/return transitions are byte-for-byte unchanged until an operator deliberately turns one on. No flag is enabled in this PR - no production/staging enablement, no deployment.
  • Evidence never affects the inspection/refund arithmetic - InspectReturnCommandHandler's restock/damaged/refund calculation is unchanged; the gate only runs before that calculation and can only block or allow the step, never influence its numbers. Proven by ReturnsFlowTests.EvidenceGatesOn_ManualReturn_..., which asserts byte-identical RefundTransaction amounts with the gates on (with evidence) vs. off (with none).

Upload authority (actor matrix)

Upload authority follows the actor that performs the corresponding business step - not "can view this shipment/return":

Evidence type Who may upload Matches
DeliveryHandover Owning seller or Admin MarkDeliveredCommandHandler
ReturnPickup Owning seller or Admin MarkReturnPickedUpCommandHandler
ReturnReceived Owning seller or Admin ReceiveReturnCommandHandler
ReturnInspection Owning seller or Admin InspectReturnCommandHandler

The customer who filed a return can view its evidence but never upload any type in this PR - return pickup/receive/inspection are all seller/admin-performed steps in this codebase today. Customer self-recorded pickup evidence is not implemented; if wanted later, it needs its own explicit design and tests, not a silent authorization widening.

Capture windows (also enforced server-side, LogisticsEvidenceInvalidException otherwise):

  • DeliveryHandover: shipment must be OutForDelivery or already Delivered.
  • ReturnPickup: return shipment must be PickupScheduled, PickedUp, or InTransit.
  • ReturnReceived: return shipment must be InTransit or Received.
  • ReturnInspection: return request must be Received or RefundRecorded.

Authorization matrix (read/download)

Object-level, never role-alone:

Viewer Shipment evidence Return evidence
Owning seller ✅ ✅
Ordering/filing customer ✅ (view only) ✅ (view only)
Unrelated seller/customer ❌ ❌
Admin ✅ ✅
Logistics staff (Logistics.ShipmentManage, non-Admin) ✅ via admin/logistics/shipments/{id}/evidence -
Finance/support staff (Finance.RefundsRead, non-Admin) - ✅ via admin/returns/{id}/evidence
Anonymous ❌ (401, everywhere) ❌ (401, everywhere)

An evidence id that belongs to a different shipment/return than the one in the URL resolves as LOGISTICS_EVIDENCE_NOT_FOUND, not the evidence itself - cross-shipment/cross-return access is indistinguishable from "doesn't exist".

Private download response

Mirrors the existing grievance-evidence download exactly: the StorageKey is never present in any client-facing DTO; the video streams server-side through IPrivateFileStorageService.OpenReadAsync only after the object-level authorization above; response headers are X-Content-Type-Options: nosniff and Cache-Control: private, no-store; the download file name is server-generated from the evidence type and id, never the client's original file name. Range/seek streaming is not implemented (P2 below) - the private store returns a forward-only stream.

3. Formats and codecs

No transcoding pipeline exists in this codebase and none is added here. Product video accepts MP4 only (the one container every supported client - web DNBVideoPlayer, Flutter video_player on Android/iOS - is guaranteed to play); logistics evidence additionally accepts WebM (what browser MediaRecorder produces on Android Chrome, since evidence is downloaded by authorized staff rather than streamed to an arbitrary public client). Both are validated by parsing the real container header (ftyp box / EBML DocType), never the client's claimed extension or Content-Type.

4. Recording privacy notice

The capture UI (LogisticsEvidenceManager.jsx) shows, before every upload:

"Record the package/product condition and handover only. Avoid recording faces or unrelated personal information where possible."

5. Privacy / retention

VIDEO EVIDENCE PRIVACY / RETENTION REVIEW REQUIRED. This PR does not define a retention period or automatic deletion - no number of days is invented. The design is retention-compatible (an explicit CreatedAt/UploadedAtUtc per row, a closed evidence-type enum, private-bucket-only storage) but any future expiry, legal/dispute hold, or controlled deletion must go through an explicit legal/product decision and its own PR, not an automatic sweep. No existing policy approved=false state was touched.

6. Mobile scope

Flutter customer product-video playback: implemented (see above). Flutter seller/logistics evidence capture: not applicable in this PR - the app has no seller shipment/return fulfilment workflow to attach evidence capture to; the web capture surface (LogisticsEvidenceManager.jsx, mobile-responsive, capture="environment" on the file input for in-browser camera capture) is the only capture surface. A dedicated Flutter seller/logistics mobile app is out of scope (see P2).

7. P2 backlog (not implemented, do not silently build)

  • Seller-specific SellerProduct demo/listing video (distinct from the shared Product.VideoUrl).
  • Automatic video transcoding / multi-codec normalization / HLS generation.
  • Generated video thumbnails (product video currently reuses the first product image as poster).
  • Caption authoring / real WebVTT caption metadata on the backend - the web player already supports rendering caption tracks, but nothing here fabricates one; captions stays empty.
  • Automated content moderation for video.
  • Resumable/chunked multipart upload for very large files.
  • An offline background upload queue for evidence capture on an unreliable connection (today: the in-flight upload can be retried or cancelled, but a fully offline queue is not implemented).
  • Range/byte-seek streaming for private evidence playback.
  • A dedicated delivery-agent mobile application.
  • Permanent/shareable evidence links (explicitly against the "never a public/permanent URL" rule).
  • Customer self-recorded return-pickup evidence (would need its own authorization design).