Get Presigned Upload URL
Two-step upload flow — server returns a signed PUT URL; client uploads file bytes directly to object storage. Three types (avatar / media / document) with admin-managed MIME and size allowlists.
POST /api/v1/upload/presign — 🔑 Bearer · Rate limit: 30 req / minute
Generates a presigned PUT URL for direct client-to-storage upload. The server validates the upload type (avatar / media / document) and contentType (MIME) against admin-managed allowlists, then issues a signed URL pre-bound to a max size and a 5-minute (default) expiry. Client then uploads the file body directly to the signed URL (skipping the platform server entirely for file bytes).
Two-step flow. Step 1: call this endpoint, get back { uploadUrl, publicUrl, key, expiresIn }. Step 2: HTTP PUT your file body to uploadUrl with the same Content-Type header you sent in the body. After successful PUT, the file is reachable at publicUrl. Pass either publicUrl or key to downstream endpoints that accept user-uploaded files (e.g. PATCH /users/avatar).
The signed URL is pre-bound to the max size for your type. The server signs the URL with Content-Length = max bytes (e.g. 5 * 1024 * 1024 for avatar). The storage backend rejects PUTs that violate this. You don't need to enforce size client-side — the storage provider does it — but surface a friendly error if the PUT fails with a 4xx body.
Vendor abstraction. The active object storage provider is admin-managed; the client sees only the resulting uploadUrl (a signed PUT URL host-keyed to whichever provider is active). Don't parse the host/path of uploadUrl to infer the vendor — treat it as opaque.
Upload type matrix
| Type | Default MIME allowlist | Default max size | Storage path prefix |
|---|---|---|---|
avatar | image/jpeg, image/png, image/webp | 5 MB | avatars/<userId>/<uuid>.<ext> |
media | image/jpeg, image/png, image/webp, image/gif, video/mp4 | 50 MB | media/<userId>/<uuid>.<ext> |
document | application/pdf, image/jpeg, image/png | 10 MB | documents/<userId>/<uuid>.<ext> |
All three columns are admin-managed via ConfigService:
- MIME allowlists:
upload.<type>_mimes(JSON array) - Size caps:
upload.max_<type>_size_mb(number) - URL expiry:
upload.presign_expiry_seconds(number, default 300 = 5 min)
Request
Body — PresignedUploadDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
type | enum | ✓ | IsIn(['avatar','media','document']) | Determines folder, MIME allowlist, size cap |
contentType | string | ✓ | IsString() | MIME type to send in the PUT request. Must be in the admin allowlist for the type. |
fileName | string | optional | IsString() | Original file name — stored only for support / log reference. The server-generated key uses a random UUID for the filename, so this doesn't affect the storage path. |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | JWT from POST /auth/login |
Response
200 OK — ApiResponseOf<PresignedUrlResponseDto>
{
"success": true,
"data": {
"uploadUrl": "https://<active-storage-host>/avatars/<userId>/<uuid>.png?X-Amz-Signature=...",
"publicUrl": "https://cdn.bio.re/avatars/<userId>/<uuid>.png",
"key": "avatars/<userId>/<uuid>.png",
"expiresIn": 300
}
}| Field | Type | Notes |
|---|---|---|
uploadUrl | string | Treat as opaque. Send PUT <uploadUrl> with the file body and the same Content-Type header you put in the request. URL signature is host-locked to the active provider. |
publicUrl | string | Where the file will be reachable AFTER the PUT succeeds. Save this on your domain row (e.g. User.avatarUrl). |
key | string | Storage object key (path). Useful if your downstream endpoint takes a key instead of a URL. |
expiresIn | number | Seconds until uploadUrl becomes invalid (admin-managed upload.presign_expiry_seconds, default 300). Plan to PUT within this window. |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | error.upload.not_configured | Active object storage provider isn't configured server-side — admin must set credentials |
400 | error.upload.invalid_type | type not one of avatar / media / document (DTO catches this earlier; service guard is defense-in-depth) |
400 | error.upload.invalid_content_type | contentType not in the admin allowlist for the supplied type. Payload includes { contentType, type } so the UI can render the actual allowlist. |
400 | (DTO validation) | Missing fields; non-string |
401 | (guard) | Missing / invalid bearer token |
429 | (throttle) | Rate limit exceeded (30 req/min) |
Side effects
- Resolve active storage client; if not configured → throw
not_configured. - Lookup
folderfromtype(avatars/media/documents); guard against unknown types. - Read MIME allowlist from
upload.<type>_mimes(admin config); reject ifcontentTypeisn't in the list. Error payload includes{ contentType, type }. - Read
maxSizeMbfromupload.max_<type>_size_mb(admin config; defaults 5 / 50 / 10). - Read
presignExpiryfromupload.presign_expiry_seconds(admin config; default 300). - Generate
key = <folder>/<userId>/<randomUUID>.<ext>(whereextis parsed fromcontentType). - Build
PutObjectCommand({ Bucket, Key: key, ContentType, ContentLength: maxSizeMb * 1024 * 1024, Metadata: { userId, type } }). getSignedUrl(client, command, { expiresIn: presignExpiry })— wrapped inwithRetryfor transient SDK failures.- Compute
publicUrlfrom active CDN config or fall back to a provider-default host. - Return
{ uploadUrl, publicUrl, key, expiresIn: presignExpiry }. No DB writes — the row that references this file is created by whichever downstream endpoint the client calls next (e.g.PATCH /users/avatar).
Code samples
curl -X POST https://api.bio.re/api/v1/upload/presign \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"type": "avatar",
"contentType": "image/png",
"fileName": "profile-photo.png"
}'# Step 1: get signed URL
RESPONSE=$(curl -s -X POST https://api.bio.re/api/v1/upload/presign \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"type":"avatar","contentType":"image/png"}')
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.data.uploadUrl')
PUBLIC_URL=$(echo "$RESPONSE" | jq -r '.data.publicUrl')
# Step 2: PUT the file body directly to the storage URL
curl -X PUT "$UPLOAD_URL" \
-H 'Content-Type: image/png' \
--data-binary @./profile-photo.png
# Step 3: tell the platform about the new file (e.g. set as avatar)
curl -X PATCH https://api.bio.re/api/v1/users/avatar \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"avatarUrl\": \"$PUBLIC_URL\"}"type UploadType = 'avatar' | 'media' | 'document';
type PresignedUrl = {
uploadUrl: string;
publicUrl: string;
key: string;
expiresIn: number;
};
async function presignUpload(
accessToken: string,
type: UploadType,
file: File,
): Promise<PresignedUrl> {
const res = await fetch('https://api.bio.re/api/v1/upload/presign', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type,
contentType: file.type,
fileName: file.name,
}),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Presign failed'), {
code: json?.error?.code,
});
}
return json.data;
}
// Two-step upload — usable as a single helper
async function uploadFile(
accessToken: string,
type: UploadType,
file: File,
): Promise<{ publicUrl: string; key: string }> {
const { uploadUrl, publicUrl, key } = await presignUpload(accessToken, type, file);
// Step 2: PUT directly to storage. Use the SAME contentType as in the body.
const putRes = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
if (!putRes.ok) {
throw new Error(`Upload failed: ${putRes.status}`);
}
return { publicUrl, key };
}import { useMutation } from '@tanstack/react-query';
export function useUploadFile() {
return useMutation({
mutationFn: async (vars: { type: UploadType; file: File }) => {
// Step 1: get presigned URL
const presignRes = await fetch('/api/v1/upload/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: vars.type,
contentType: vars.file.type,
fileName: vars.file.name,
}),
});
const presignJson = await presignRes.json();
if (!presignRes.ok || !presignJson.success) {
throw Object.assign(new Error(presignJson?.error?.message ?? 'Presign failed'), {
code: presignJson?.error?.code,
i18nKey: presignJson?.error?.i18nKey,
});
}
const { uploadUrl, publicUrl, key } = presignJson.data as PresignedUrl;
// Step 2: PUT directly to storage
const putRes = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': vars.file.type },
body: vars.file,
});
if (!putRes.ok) {
throw new Error(`Upload failed: ${putRes.status}`);
}
return { publicUrl, key };
},
});
}Try it
Authorization
bearer In: header
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
curl -X POST "https://loading/api/v1/upload/presign" \ -H "Content-Type: application/json" \ -d '{ "type": "avatar", "contentType": "image/png" }'{
"success": true,
"data": {
"uploadUrl": "https://biore-uploads.r2.dev/avatars/user-uuid/file-uuid.webp?X-Amz-Signature=...",
"publicUrl": "https://cdn.bio.re/avatars/user-uuid/file-uuid.webp",
"key": "avatars/user-uuid/file-uuid.webp",
"expiresIn": 300
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/upload/upload.controller.ts | 56–92 (getPresignedUrl), 16–41 (PresignedUploadDto inline) |
| DTO (response) | apps/api-core/src/modules/upload/dto/upload-response.dto.ts | 5–28 (PresignedUrlResponseDto) |
| Service | apps/api-core/src/modules/upload/upload.service.ts | 158–206 (getPresignedUploadUrl) |
| Storage client | (admin-managed via external.storage.active_provider) | getActiveClient(), getActiveBucket(), getActivePublicUrl() |
| Config keys | apps/api-core/src/modules/config/config.service.ts | upload.<type>_mimes (allowlists), upload.max_<type>_size_mb (caps), upload.presign_expiry_seconds (URL TTL) |
| Downstream consumers | various | PATCH /users/avatar (avatar URL), PATCH /creators/:creatorId/bio (avatarUrl), POST /messages (no attachments yet — future) |
Get Active Theme
Public read of the platform's currently active client-web theme preset. Includes layout + assets (full theming surface). MUST pass ?target=CLIENT — default differs.
Get User Profile
Read the calling user's full profile — identity fields + flags (twoFactorEnabled, isCreator, emailVerified) + linked OAuth accounts + attribution data.