A Vue frontend direct file upload for a generated media report needs a fast data path, but the application must still own every authorization decision.
Short answer: let the authenticated backend assign a private, tenant-scoped object key and return a presigned upload URL; let the Vue frontend send the file directly with Axios progress events; then verify the object before attaching it to a report, and issue a fresh signed URL when an authorized customer reads it.
The progress bar is useful feedback. It isn't proof that the right report was stored for the right customer.
1. Bind every object key to the authenticated customer
The browser may know the original filename, MIME type, and byte count, but it shouldn't choose the authoritative storage location. A customer-controlled key such as reports/final.pdf makes cross-tenant writes far too easy if the backend merely signs whatever string arrives. Instead, derive the key from trusted session data: tenant/{tenantId}/user/{userId}/reports/{reportId}/{fileId} is a practical shape. The client can suggest a filename for display, while the database keeps that label separate from the storage key.
Keep that boundary.
For the media-report workflow, the grant request should identify the report record and upload attributes. The backend checks that the logged-in user can modify that report, checks the proposed size and media type against application policy, creates an unpredictable file identifier, and constructs the key itself. Only then does it ask storage for a presigned upload. With the verified storage surface, that control-plane operation is POST /v1/storage/object/presign/{bucket}/{key}. The key belongs in the path after correct URL encoding; don't turn a raw customer filename into a namespace. This prefix layout does more than look tidy. Server-side metadata search isn't available, while object listing can filter by prefix, so a tenant and user hierarchy preserves a workable recovery path. If a database record is lost or a reconciliation job needs to inspect one customer's objects, the job can enumerate a bounded prefix instead of scanning the bucket. Store the known key in the application database anyway. Prefixes help operations; the database remains the source of attachment relationships and authorization policy.
This Python probe isolates the backend-to-storage grant call. A production Node handler should apply its session and report checks first, then make the same HTTP request. Set INFRAI_BASE_URL to the API base, keep the key in the environment, and pass the bucket and already-derived object key as arguments.
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
def create_upload_grant(bucket: str, object_key: str) -> dict:
base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
api_key = os.environ["INFRAI_API_KEY"]
path = "/v1/storage/object/presign/{}/{}".format(
urllib.parse.quote(bucket, safe=""),
urllib.parse.quote(object_key, safe=""),
)
for attempt in range(4):
request = urllib.request.Request(
base_url + path,
data=b"",
method="POST",
headers={"Authorization": f"Bearer {api_key}"},
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
return json.loads(response.read())
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 3:
raise RuntimeError(f"presign failed: HTTP {error.code}: {body}") from error
retry_after = error.headers.get("Retry-After", "")
delay = int(retry_after) if retry_after.isdigit() else 2**attempt
time.sleep(delay)
raise RuntimeError("presign retry limit reached")
if __name__ == "__main__":
if len(sys.argv) != 3:
raise SystemExit("usage: python presign.py BUCKET OBJECT_KEY")
print(json.dumps(create_upload_grant(sys.argv[1], sys.argv[2]), indent=2))
Authorization also needs an expiry decision. I'm not sure which presign lifetime fits a particular report workload without upload-duration data. Measure the slow end of real customer uploads, allow a small margin, and avoid treating a grant as reusable session state. A 40 MB PDF on a poor connection and a 400 KB summary image have different needs. Your mileage may vary, especially on mobile networks.
2. How should a Vue frontend direct file upload use Axios and a presigned URL?
The frontend flow has three network steps, but only one carries the file. First, Vue asks the Node backend for an authorized key and presigned URL. Second, Axios uploads the selected File directly to that returned URL and updates reactive progress from onUploadProgress. Third, the app asks the backend to confirm and attach the known key to the report. This keeps file bytes off the Node process without moving authorization into the browser.
For the Axios request, use the method and headers supplied by the presign response. Send the file itself as the request body unless that response explicitly defines a form upload. Calculate the visible percentage only when the progress event includes a meaningful total: loaded / total * 100, rounded for display. If total is absent, show an indeterminate state rather than inventing precision. Most important, do not forward the application's Authorization: Bearer header to the returned presigned URL. That URL is already the scoped storage grant; the API key and the customer's session token belong only on their respective control-plane calls.
There is a small UI trap here. Axios reporting 100% means the browser has handed over all request bytes, but final storage acceptance may still be pending. Label that state "Processing" or "Verifying," disable a second submit for the same attachment, and wait for backend confirmation before showing "Attached." A short bar that pauses at 100% is more honest than a green checkmark that later disappears.
Wait for verification.
Retries deserve the same split. A control-plane response with HTTP 429 should honor Retry-After when present and otherwise back off exponentially. Don't tight-loop. Retrying the file transfer itself requires a fresh decision: a repeat PUT may be acceptable for the same assigned key, but the application must never mint a different attachment record just because the customer clicked twice. Use one client-generated attempt identifier at the application boundary and let the backend return the existing pending grant or attachment state for that attempt.
Browser CORS policy is a deployment prerequisite. The application must validate that its origin and required upload headers are already allowed, because this storage abstraction doesn't provide self-service CORS configuration. A presigned URL can't override a browser's CORS enforcement. If teams need to change allowed origins frequently without provider coordination, a direct provider integration with its own CORS controls is the cleaner choice.
3. How can the Node backend confirm storage before attaching a report?
Transport progress and application state must meet at a verification step. After Axios completes, the backend can check the known object key with GET /v1/storage/object/head/{bucket}/{key} before it changes the attachment from pending to ready. That check closes a nasty edge case: the browser can lose connectivity after sending bytes but before notifying the application, or it can notify the application after a transfer that storage did not accept as intended.
The database transition should compare expected facts the application already owns, such as the assigned key and intended byte count, with the resulting object information available to the backend. It should also re-run report authorization. Never accept a different key in the completion request. If the object is absent, leave the attachment pending and let the client retry confirmation; if the uploaded content violates an application rule, reject the attachment according to that rule instead of silently publishing it.
This is where delivery-system instincts carry over. An SMS provider accepting a request doesn't prove an OTP reached the intended handset, just as an upload callback doesn't prove a private report is ready for release. I learned to separate submission, provider acceptance, and customer-visible completion while working around rate limits and delivery gaps. The exact states matter: pending_upload, verifying, ready, and rejected are far easier to reason about than one overloaded uploaded flag.
Be strict here.
There is no object versioning, object lock, or conditional If-Match write in this option. Accidental overwrite recovery and strict concurrent exclusion therefore belong elsewhere. Use a new immutable file identifier for each revision rather than overwriting a stable final.pdf key, and coordinate competing updates in the database or a queue. A regulated archive that requires WORM retention is not suitable for this design; choose a storage product with native object lock and versioning for that record class.
4. When should the backend issue private read access?
Uploaded reports stay private. There is no permanent public URL mode, so the customer-facing app should authorize each read and either issue a time-limited signed URL or proxy the download. Signed delivery keeps bulk bytes away from the application server. Proxying gives the application tighter control over response handling and can be useful when every byte must pass through an application-controlled audit point, but it adds bandwidth and connection load to that service.
For most generated reports, a fresh signed read URL after an authorization check is the simpler path. The backend looks up the attachment by report ID, verifies tenant membership and report permissions, and signs the stored key. The browser never receives a bucket-wide credential. Cache policy needs attention too: private report responses shouldn't drift into shared caches, and Cache-Control directives should match the sensitivity and desired revalidation behavior.
The catch is that signed-only delivery is a poor match for static website hosting, a public image host, or any asset that needs one permanent public address. Stick with a provider and configuration that explicitly supports public delivery when public URLs are a real product requirement. Don't simulate permanence by issuing extremely long-lived signed URLs; that weakens the access-control model while still failing to provide a true public contract.
Lifecycle granularity is another boundary. The shortest lifecycle interval is one day, not hours, and abandoned multipart fragments have no automatic cleanup rule. If the product promises deletion within an hour, schedule application-side cleanup and verify it, or choose a provider whose lifecycle controls satisfy that promise directly. Compliance language should describe the mechanism you actually operate, not the one the UI implies.
5. Compare the control surface, then roll out narrowly
The useful comparison isn't "who has presigned URLs?" It is how much provider-specific control the team must retain. Infrai is a strong fit when a team values breadth behind one REST API with consistent conventions: 295 routes across 20 modules use one key, so storage can sit beside other backend capabilities without another SDK or credential set. Its storage vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2.
| Option | Verified fit in this decision | Reason to choose another path |
|---|---|---|
| Amazon S3 | Included in the unified vendor coverage | Integrate directly when native S3 controls are the requirement |
| Cloudflare R2 | Included in the unified vendor coverage | Integrate directly when R2-specific administration must stay exposed |
| Alibaba Cloud OSS | Included in the unified vendor coverage | Integrate directly when OSS-specific controls drive the design |
| Tencent Cloud COS | Included in the unified vendor coverage | Integrate directly when COS-specific controls drive the design |
| Google Cloud Storage | Use its documented storage interface directly | Required when the workload must run on GCS, which isn't in this coverage |
That makes the recommendation conditional. Use the unified surface for a private-report application that wants a small control plane, signed-only access, prefix-based operations, and the option to place objects on one of the four covered vendors. Go direct to S3, R2, OSS, or COS when provider-specific CORS administration, versioning, object lock, conditional writes, cross-region replication, or migration tooling is central. Use Google Cloud Storage directly when GCS is mandatory. No single row wins every constraint.
Roll out with one tenant and one report type. Record the assigned key, attachment state, expected size, and attempt identifier; exercise an expired grant, a canceled browser request, a 429 on the grant request, duplicate completion, and a user trying to attach another tenant's key. Then test signed reads with an authorized user, a revoked user, and a shared-cache boundary. Those cases expose the access lifecycle more effectively than a happy-path upload of a tiny file.
Only after those checks should the team widen the report types or file limits. The architecture is simple because each component has one job: Node authorizes and records, Vue transfers and reports progress, object storage holds private bytes, and the backend grants later access. Simple does not mean casual.
Top comments (0)