Cloud Storage CORS Support for Direct Browser Receipt Uploads (A Go Retention Boundary)

2026-11-05来源 dev.to GoEnglish

TL;DR: For a property-management receipt workflow, the decisive cloud-storage question is not which service has the lowest advertised rate. It is whether the upload, retention, audit, and deletion boundaries are explicit enough to survive an audit. Put a short-lived upload capability in front of object storage, validate the receipt server-side after upload, and make deletion a state transition with an observable record. CORS only lets the browser make the request; it does not enforce retention.

A useful design keeps the original receipt immutable, stores processing results separately, and gives the platform one place to decide when an original may be deleted. That design can sit above S3-compatible storage, including Cloudflare R2, Amazon S3, or Wasabi, but the provider comparison should happen after the lifecycle contract is written. Otherwise an apparently simple upload path becomes an expensive retention problem.

Keep it boring.

What should survive a browser upload?

I model the production event as a small, bounded transaction. A property manager selects a PDF or image, the browser uploads the bytes directly to an object key, and the application records an upload intent before issuing a capability. A worker then checks the object, extracts receipt fields, and records the result without replacing the original.

The original needs a deliberately boring identity: a tenant or property identifier, a receipt identifier, and a server-generated object key. The browser should not choose an arbitrary path. The metadata record should include the expected size, a content-type allowlist, creation time, processing state, retention class, and the audit reference that explains why the file is being held.

The failure mode I care about is a successful HTTP upload followed by an application timeout. If the client retries with a new key, the property system can retain two originals. If it retries with the same key, a partial or overwritten object can become indistinguishable from the accepted receipt. The fix is an idempotency key tied to the upload intent, plus a server-side state machine such as issued, uploaded, verified, retained, and deletion_pending. I initially treat this as a client-retry problem; the harder problem is deciding which copy the audit record is allowed to name after the timeout.

That state machine is an operational control, not decoration. A deletion request should record who or what initiated it, the reason, the object version or checksum where available, and the result. The worker that performs deletion must be safe to retry. A missing object is not automatically a mystery; it can be a successful prior deletion, but the metadata must make that conclusion auditable.

Where does CORS stop helping?

CORS answers a narrow browser question: may JavaScript from this origin make this cross-origin request, and which response headers may it read? It does not authenticate the person, decide which receipt key is legal, or stop a client from retaining a local copy. It also does not turn a public object into a private one.

For direct upload, configure the smallest practical origin set, methods, request headers, and exposed response headers. Keep the upload capability short-lived and scoped to one object key. The API that mints it should check the authenticated property-management action, not trust an object name supplied by the browser.

Here is the control path in Go. The storage interface is intentionally generic, because the important contract is the state transition and cleanup behavior, not a provider-specific SDK.

type ReceiptStore interface { \tPutUploadIntent(ctx context.Context, intent UploadIntent) error \tMarkUploaded(ctx context.Context, receiptID string, size int64) error \tDeleteOriginal(ctx context.Context, objectKey string) error } func IssueUpload(ctx context.Context, store ReceiptStore, receiptID string) (string, error) { \tkey := "receipts/" + receiptID + "/original" \tintent := UploadIntent{ \t\tReceiptID: receiptID, \t\tObjectKey: key, \t\tState: "issued", \t\tMaxBytes: 10 * 1024 * 1024, \t} \tif err := store.PutUploadIntent(ctx, intent); err != nil { \t\treturn "", err \t} \treturn SignScopedUpload(ctx, key, intent.MaxBytes) } 

The 10 MiB value is an example policy, not a universal limit. Capacity planning starts with the actual receipt distribution, retry rate, quarantine duration, and retention schedule. I would alert on pending uploads, verification age, deletion age, and bytes retained by lifecycle class; aggregate storage size alone hides a stuck state machine.

How should cloud storage support direct browser uploads?

A comparison is a test checklist, not a winner's podium. Each candidate has to be tested against the same browser origin, upload headers, object-key rules, lifecycle semantics, deletion observability, export path, and incident tooling. A CORS example copied from one service can still fail when its allowed headers, endpoint shape, or signed-request rules differ.

Decision boundary What to verify before adoption Why it matters for receipts
Browser upload Preflight behavior, allowed methods and headers, exposed response headers A failed preflight looks like an application outage while leaving no receipt record
Original retention Lifecycle rules, legal hold behavior, versioning, and deletion timing Processing success must not silently authorize deletion
Portability Object export, metadata preservation, checksums, and readable audit records A retention policy is weaker if the evidence cannot be moved or inspected
Operations Metrics, event delivery, retry behavior, and access logs SLOs need signals for missing, duplicated, and late receipts

The table is the easy part.

The build-versus-managed decision is also less binary than it sounds. A managed object store removes much of the durability machinery, while the application still owns identity, authorization, validation, retention classification, and evidence. Self-hosting can expose more control over placement and deletion workflows, but it transfers capacity planning, repair, replication, and on-call work to the platform team. I would reject any design whose cost model counts bytes but omits operator hours and recovery testing. The limitation is material: a managed service cannot decide whether a receipt is under dispute, while a self-hosted system cannot outsource its repair queue merely by exposing an S3-shaped API.

Retention is the primary decision axis here. Start with classes such as quarantine, active_audit, and eligible_for_deletion, then attach an owner and an exit condition to every class. Do not use a bucket-wide expiry as the only policy when a receipt may be under dispute. A lifecycle rule can be a backstop; it should not be the only record of why a file disappeared.

How do I make deletion observable and repeatable?

The deletion worker should consume a durable command or scheduled record, acquire the receipt's current state, and refuse to delete an object that is still required by policy. It should emit a result for both success and a provider-reported absence. Retries need bounded backoff and a dead-letter path with an owner.

func DeleteIfEligible(ctx context.Context, store ReceiptStore, r Receipt) error { \tif r.State != "deletion_pending" || r.HoldReason != "" { \t\treturn nil \t} \tif err := store.DeleteOriginal(ctx, r.ObjectKey); err != nil { \t\treturn fmt.Errorf("delete receipt %s: %w", r.ID, err) \t} \treturn MarkDeleted(ctx, r.ID, time.Now().UTC()) } 

This path has an important boundary: it does not prove that every replica, cache, browser download, or exported backup vanished. The application can prove that its controlled object and audit record reached the intended state. The retention policy must say what that proof covers. GDPR Article 20 also makes portability a real design consideration for data subjects, so an export format and metadata mapping deserve tests rather than a last-minute script.

A practical test matrix includes a denied preflight, an expired upload capability, a duplicate idempotency key, a truncated object, a content-type mismatch, a worker crash after verification, a deletion retry, and a hold placed while deletion is pending. Run the same matrix against each candidate endpoint. The test is more valuable than a feature comparison because it exercises the exact browser and retention boundary this system depends on.

The decision I would put in the design record

Choose the storage boundary whose CORS behavior, retention controls, portability, and operational signals meet the receipt policy with the fewest application-specific exceptions. Do not choose from a price table first. The storage bill is only one input; the harder cost is an incomplete deletion proof, a duplicate original, or an audit trail that cannot explain a missing file. A provider is not suitable when its lifecycle or export behavior forces the application to invent an exception to the retention policy.

For this workflow, the durable invariant is simple: processing may add derived data, but it must never silently rewrite the original or advance it past its retention obligation. CORS gets bytes from a browser to storage. The state machine, policy owner, and deletion record decide what those bytes mean later.

Sources

原文:https://www.a8q9.cn/en/article/cloud-storage-cors-support-for-direct-browser-receipt-uploads-a-go-retention-bou-c6e97c.html

相关文章