Skip to content

Blob Storage Endpoints — direct-to-cloud uploads, validated

Uploading a file through your own server costs CPU you don’t have, RAM that should serve real requests, and bandwidth you already paid the cloud provider for. The right move is direct-to-cloud: the client gets a presigned URL, uploads straight to S3 / Azure Blob, and confirms back. That flow has three landmines: the client can upload garbage and never confirm (orphans), the file can change between upload and confirm (TOCTOU), and the secrets that signed the URL leak if you hand-roll it.

Granit.BlobStorage.Endpoints is the admin API that handles the full lifecycle correctly: initiate → presigned URL → client uploads → confirm with validation pipeline → presigned download URL on demand → crypto-shred on delete → orphan cleanup job. Every endpoint behind a permission policy, per-operation rate limit, and OpenAPI-typed.

PainThis package’s answer
Streaming uploads through your server burns CPU/RAMPOST /upload returns a presigned URL — the client uploads direct-to-cloud
TOCTOU between upload and usePOST /{id}/confirm runs the validation pipeline before the descriptor becomes usable
Abandoned uploads accumulate foreverPOST /cleanup-orphans finalises Pending/Uploading descriptors past TTL
”Delete” leaves shreddable data behindCrypto-shred on delete, audit record retained
Need to expose a temporary download linkPOST /{id}/download-url generates a short-lived presigned URL
Listing blobs requires custom pagingBuilt-in MapGranitQuery<BlobDescriptor> — filter, sort, paginate
  • DirectoryGranit.BlobStorage.Endpoints/
    • DirectoryEndpoints/
      • BlobReadEndpoints.cs GET /{id}
      • BlobWriteEndpoints.cs POST /upload, DELETE /{id}
      • BlobOperationEndpoints.cs confirm, download-url, cleanup-orphans
    • DirectoryPermissions/ BlobStoragePermissions + BlobStorageRateLimitPolicies
    • DirectoryOptions/ BlobStorageEndpointsOptions
  • Granit.BlobStorage Core abstractions (IBlobStorage, BlobDescriptor)
  • Granit.BlobStorage.EntityFrameworkCore Persistence + queryable source
Program.cs
app.MapGranitBlobStorage();
// With custom options:
app.MapGranitBlobStorage(opts =>
{
opts.RoutePrefix = "admin/blobs";
opts.TagName = "Storage";
});

All routes mount under {RoutePrefix}/blobs/.... With defaults the listing endpoint sits at GET /blob-storage/blobs?….

OptionDefaultDescription
RoutePrefix"blob-storage"Outer route prefix
TagName"Blob Storage"OpenAPI tag for endpoint grouping

Configuration section: BlobStorage:Endpoints.

Two permissions, declared by BlobStoragePermissionDefinitionProvider and auto-discovered by Granit.Authorization:

PermissionEndpoints
BlobStorage.Administration.ReadGET /blobs/{id}, MapGranitQuery<BlobDescriptor>
BlobStorage.Administration.ManagePOST /upload, DELETE /{id}, POST /{id}/confirm, POST /{id}/download-url, POST /cleanup-orphans

Both permissions are scoped MultiTenancySides.Both — usable in tenant-scoped admin UIs and host operator consoles alike.

MethodRouteDescription
GET/blobs/{id:guid}Get a blob descriptor by ID
GET/blobs (via MapGranitQuery<BlobDescriptor>)Filter, sort, paginate, groupBy on the descriptor
MethodRouteDescription
POST/blobs/uploadInitiate a direct-to-cloud upload; returns a presigned URL + descriptor ID
POST/blobs/{id:guid}/confirmConfirm the upload completed; runs the validation pipeline (size, MIME, hash, MIME-sniff)
sequenceDiagram
    participant C as Client
    participant App as App
    participant Cloud as Cloud Storage

    C->>App: POST /blob-storage/blobs/upload
    App->>App: create descriptor (Pending)
    App-->>C: { id, presignedUploadUrl }
    C->>Cloud: PUT presignedUploadUrl (binary)
    C->>App: POST /blob-storage/blobs/{id}/confirm
    App->>Cloud: HEAD object → size/etag
    App->>App: validation pipeline
    App-->>C: { id, status: "Available" }
MethodRouteDescription
POST/blobs/{id:guid}/download-urlGenerate a short-lived presigned download URL
DELETE/blobs/{id:guid}Crypto-shred the encryption key; descriptor + audit record retained
POST/blobs/cleanup-orphansFinalise Pending/Uploading descriptors past TTL (e.g. browser closed mid-upload)
DTODirectionUsed by
BlobUploadInitiateRequestInputPOST /upload
BlobUploadInitiateResponseOutputPOST /upload — includes presigned URL
BlobConfirmUploadRequestInputPOST /{id}/confirm
BlobConfirmUploadResponseOutputPOST /{id}/confirm — includes validation outcome
BlobDownloadUrlRequestInputPOST /{id}/download-url
BlobDownloadUrlResponseOutputPOST /{id}/download-url — includes presigned URL
BlobDeleteRequestInputDELETE /{id}
BlobDescriptorResponseOutputGET /{id}
BlobCleanupOrphansResponseOutputPOST /cleanup-orphans

Every mutation endpoint is bound to a rate-limit policy. Configure the policies in Granit.RateLimiting, then the endpoints apply them automatically via .RequireGranitRateLimiting(...):

EndpointPolicy constantConfig keyRecommended algorithm
POST /upload, POST /{id}/confirmBlobStorageRateLimitPolicies.Uploadblob-uploadTokenBucket (burst-friendly)
POST /{id}/download-urlBlobStorageRateLimitPolicies.Downloadblob-downloadSlidingWindow, 100/min
POST /cleanup-orphansBlobStorageRateLimitPolicies.Adminblob-adminFixedWindow, 10/min
{
"RateLimiting": {
"Policies": {
"blob-upload": { "Algorithm": "TokenBucket", "TokenLimit": 20, "TokensPerPeriod": 5, "ReplenishmentPeriod": "00:00:10" },
"blob-download": { "Algorithm": "SlidingWindow", "PermitLimit": 100, "Window": "00:01:00" },
"blob-admin": { "Algorithm": "FixedWindow", "PermitLimit": 10, "Window": "00:01:00" }
}
}
}

Rate-limited responses return 429 Too Many Requests with Retry-After. See Rate Limiting for partition strategies and dynamic plan-based quotas.

All request DTOs are auto-validated through MapGranitGroup(). Key rules:

  • Container names — lowercase alphanumeric with hyphens, max 128 chars.
  • File names — non-empty, max 1024 chars.
  • Content types — valid MIME format (type/subtype).
  • File size — must be positive.
  • Deletion reason — max 500 chars (optional).

The confirm pipeline layers content-time validation on top: size cap per container, declared vs. detected MIME consistency (AppendUserData style sniffing to defeat extension spoofing), and SHA-256 hash check against the value declared at initiation.