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}/videoandDELETE /api/products/{id}/videorequireCatalogModerator.ProductsManageexclusively (ProductVideoAuthorization,LogisticsEvidenceExceptions.ProductMediaForbiddenException). The legacyProduct.SellerIdfield is never treated as ownership of shared media - an ordinary seller, including the seller who happens to beProduct.SellerId, is denied even though they may holdSeller.ListingsManage.Product.Update(...)(the ordinary product-edit path) no longer touchesVideoUrlat all - a routine name/price/stock edit can never silently clear or overwrite the video. OnlyProduct.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 overloadProduct.VideoUrlwith it.
Upload / replace / delete¶
- Public storage only (
IFileStorageService), object prefixproducts/{productId}/videos/, server-generated file name ({Guid}.mp4) - the client never supplies a path. - V1 accepts MP4 only (
FileValidationHelper.TryValidateProductVideo), validated by parsing the realftypbox 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 oldVideoUrlremains 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 whenProductVideoStorage.IsOwnedVideoUrlconfirms the URL is actually this product's ownproducts/{id}/videos/{guid}.mp4object. A legacy external URL, another product's URL, or a non-video path under the product's folder is never passed toIFileStorageService.DeleteAsync. - Every mutation invalidates the
products:cache pattern.
Web / Flutter playback¶
- Web customer PDP (
ProductDetailPage.jsx) already renderedDNBVideoPlayerunmodified - no autoplay, controls, a poster (first product image), and a graceful "video unavailable" state.captionsstays[]when the backend has no caption metadata (see below) rather than fabricating one. - Flutter customer PDP now actually plays the video (
ProductVideoPlayer, using the officialvideo_playerplugin - 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 whenvideoUrlis 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¶
LogisticsEvidenceis immutable: every property has a private setter, there is no update method, and no API edits or deletes a committed row.CreateForShipment/CreateForReturnare the only ways to construct one and enforce the type-to-context mapping in-process.- Exactly one context (
ShipmentIdXORReturnRequestId) is enforced three times: the factory methods,CK_LogisticsEvidences_SingleContext, andCK_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_EvidenceTypeandIX_LogisticsEvidences_ReturnRequestId_EvidenceType. MySQL treatsNULLas distinct in a unique index, so a return-evidence row (ShipmentIdNULL) never collides with a shipment row - verified against real MySQL 8 inMySqlLogisticsEvidenceTests. - Additive migration
AddLogisticsEvidenceonly - no historical migration was edited. Verified: fresh apply, release-DB → new migration, second apply is a no-op, rollback + re-apply, anddotnet ef migrations has-pending-model-changesreports 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 inMySqlLogisticsEvidenceTests(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.Provideris"Manual"or"Test"in this codebase (no real courier integration exists yet - seemarketplace-logistics.md"Provider abstraction").DeliveryHandover,ReturnPickupandReturnReceivedare gated only when the provider is Manual - a future carrier-driven provider is never blocked by a flag a courier could never satisfy.ReturnInspectionis 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 byReturnsFlowTests.EvidenceGatesOn_ManualReturn_..., which asserts byte-identicalRefundTransactionamounts 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 beOutForDeliveryor alreadyDelivered.ReturnPickup: return shipment must bePickupScheduled,PickedUp, orInTransit.ReturnReceived: return shipment must beInTransitorReceived.ReturnInspection: return request must beReceivedorRefundRecorded.
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
SellerProductdemo/listing video (distinct from the sharedProduct.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;
captionsstays 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).