Skip to content
bitzorcas
中EN

Guide

Files upload sessions and direct upload

CreateUploadSession request contracts, trusted fields, presigned upload, client implementation, failure compensation, and security hardening.

Last updated

CreateUploadSession does not receive the file bytes. It registers a PendingUpload asset and grants an authorized caller a temporary PUT capability. The client then sends bytes directly to object storage so the API process does not proxy large payloads.

1. Request and response contract

Create an upload session
POST /api/files/upload-session HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-Type: application/json
{
"ownerType": "user",
"fileName": "invoice-2026-07.pdf",
"contentType": "application/pdf",
"size": 48231,
"visibility": "private"
}

The server generates fileId and storageKey; presignedUrl is a temporary credential:

UploadSession response
{
"fileId": "019c64e67fc87db8b7ca748f5442f91a",
"storageKey": "tenants/tenant-a/019c64e67fc87db8b7ca748f5442f91a",
"presignedUrl": "https://storage.example/presigned-put-token"
}

The response has no explicit expiry. S3 uses the handler’s 30-minute TTL. The connector bridge ignores this argument because its provider owns UploadUrlExpiration.

2. Trusted and untrusted fields

FieldProviderHandler behavior
TenantIdcurrent userBlank or 0 returns File.TenantRequired
OwnerIdcurrent UserId / ClientIdNeither returns File.OwnerRequired
FileIdserverUUID v7 in 32-character N format
StorageKeyservertenants/{tenantId}/{fileId}
OwnerTypeclientNon-empty and 50-character bound only
VisibilityclientNon-empty and 20-character bound only
FileNameclientNot part of the key; max 255 characters
ContentTypeclientStored as the later comparison baseline
SizeclientMust be positive; no platform maximum

OwnerId currently means “the uploading user or client,” not an order, ticket, or conversation. Business ownership still needs a separate attachment relation. OwnerType=document does not bind a document.

3. Processing order

SysFileAssetIFileStorageFiles APIClientSysFileAssetIFileStorageFiles APIClientPOST upload-sessionderive tenant and ownerregister PendingUploadpresign PUT for 30 minutesURLSave FileAssetcommittedfileId + key + URLPUT bytes

The URL is signed before the database Save. If Save fails, a client may still hold a usable storage capability without a corresponding FileAsset. Production needs compensating deletion, short TTL, isolated prefixes, and object-inventory reconciliation.

4. Complete .NET client flow

Create a session and upload a PDF
public async Task<string> UploadInvoiceAsync(
HttpClient api,
HttpClient objectStorage,
byte[] pdf,
CancellationToken cancellationToken)
{
// Files receives metadata only. TenantId and OwnerId come from the bearer identity.
using var createResponse = await api.PostAsJsonAsync(
"/api/files/upload-session",
new
{
ownerType = "user",
fileName = "invoice-2026-07.pdf",
contentType = "application/pdf",
size = pdf.LongLength,
visibility = "private"
},
cancellationToken);
createResponse.EnsureSuccessStatusCode();
var session = await createResponse.Content
.ReadFromJsonAsync<UploadSessionDto>(cancellationToken);
// The presigned URL carries storage authorization. Never forward the API bearer token.
using var put = new HttpRequestMessage(HttpMethod.Put, session!.PresignedUrl)
{
Content = new ByteArrayContent(pdf)
};
put.Content.Headers.ContentType = new("application/pdf");
using var uploadResponse = await objectStorage.SendAsync(put, cancellationToken);
uploadResponse.EnsureSuccessStatusCode();
// Return FileId only. A business relation must wait for successful finalize.
return session.FileId;
}
public sealed record UploadSessionDto(
string FileId,
string StorageKey,
string PresignedUrl);

Do not persist or log the full presigned URL. Its query usually carries signature scope and expiry data.

5. Browser direct upload

Browser upload with explicit failure state
type UploadSession = {
fileId: string;
storageKey: string;
presignedUrl: string;
};
export async function createAndPut(file: File): Promise<UploadSession> {
// Send untrusted metadata only; cookie/bearer context establishes the principal.
const sessionResponse = await fetch("/api/files/upload-session", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
ownerType: "user",
fileName: file.name,
contentType: file.type || "application/octet-stream",
size: file.size,
visibility: "private",
}),
});
if (!sessionResponse.ok)
throw new Error(`create session failed: ${sessionResponse.status}`);
const session = (await sessionResponse.json()) as UploadSession;
// Send only storage-approved headers; never forward application authentication.
const uploadResponse = await fetch(session.presignedUrl, {
method: "PUT",
headers: { "content-type": file.type || "application/octet-stream" },
body: file,
});
// Do not finalize after a failed PUT. Recreate instead of reusing an unknown URL.
if (!uploadResponse.ok)
throw new Error(`object upload failed: ${uploadResponse.status}`);
return session;
}

Browser upload requires storage CORS to allow the application Origin, PUT, and required headers. CORS is a browser policy, not a replacement for Files authorization or a private bucket.

6. Validation and errors

ConditionCurrent error/behaviorClient action
Size ≤ 0File.InvalidSizeFix input; do not retry
No trusted tenantFile.TenantRequiredFix authentication/tenant resolution
No UserId or ClientIdFile.OwnerRequiredUse a supported principal
Missing/oversized metadataFile.MetadataRequired/TooLongFix input
Provider cannot presignFile.UploadSessionUnavailableDo not PUT or finalize
Database Save failsRepository errorService must compensate the already signed target
Storage PUT times outObject state is unknownHEAD or create a fresh session

The command does not implement IIdempotentRequest and accepts no idempotency key. Retrying a timed-out create produces a new FileAsset and key.

7. File name and content type

The original file name is display/download metadata only. The server key is independent, and a test deliberately accepts ../file.pdf. The name remains unsafe:

  • HTML-encode it in UI;
  • sanitize Content-Disposition and support an RFC 5987 UTF-8 form;
  • length-limit structured log fields;
  • never infer MIME or content from the extension;
  • define normalization and duplicate-name policy in the business owner.

The current S3 download adapter removes only quotes, CR, and LF. The connector bridge does not pass the file name at all.

8. Target upload-admission policy

Add a centralized policy before signing a URL:

Target upload-admission contract
public sealed record FileUploadIntent(
string TenantId,
string SubjectId,
string OwnerType,
string FileName,
string DeclaredContentType,
long DeclaredSize);
public interface IFileUploadAdmissionPolicy
{
// Return normalized MIME, a byte ceiling, and the required checksum algorithm.
// Reject before URL issuance so a denied intent never receives a storage capability.
Task<Result<ApprovedUpload>> AuthorizeAsync(
FileUploadIntent intent,
CancellationToken cancellationToken);
}
public sealed record ApprovedUpload(
string ContentType,
long MaximumBytes,
string ChecksumAlgorithm);

This is a target extension contract; it is not in the current source. It should combine plan quota, OwnerType registry, MIME allowlist, risk control, and audit.

9. Production hardening checklist

  1. Keep the bucket private; key guessing must not grant anonymous reads.
  2. Constrain presigned PUT by length, type, and SHA-256.
  3. Rate-limit by tenant, subject, IP, and concurrent sessions.
  4. Persist a one-time session identity and consumption state.
  5. Enforce UploadExpiresAt during finalize.
  6. Compute the content hash in a trusted service.
  7. Upload into a quarantine prefix and promote only after scanning.
  8. Revoke/delete a signed target when session persistence fails.
  9. Monitor the difference between issued, uploaded, finalized, and cleaned assets.
  10. Never retain a full presigned URL in logs, events, or frontend state stores.

10. Verification

Terminal window
# Run create-session handler tests, including server keys and fail-closed behavior.
dotnet test tests/BitzOrcas.Application.Tests \
--filter FullyQualifiedName~CreateUploadSession
# Prove the current lack of size ceilings and expiry enforcement.
rg -n "Max.*File|Quota|UploadExpiresAt" src/Platform/Files src/Hosts -g '*.cs'
# Inspect signed PUT conditions; current S3 code has only bucket/key/verb/expiry.
rg -n "GetPreSignedUrlRequest|ContentLength|Checksum|ContentType" \
src/Framework/BitzOrcas.Infrastructure.Storage.S3Compatible -g '*.cs'

Previous: Files overview · Next: Finalization and integrity

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%