BIO.RE
Upload

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

TypeDefault MIME allowlistDefault max sizeStorage path prefix
avatarimage/jpeg, image/png, image/webp5 MBavatars/<userId>/<uuid>.<ext>
mediaimage/jpeg, image/png, image/webp, image/gif, video/mp450 MBmedia/<userId>/<uuid>.<ext>
documentapplication/pdf, image/jpeg, image/png10 MBdocuments/<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

FieldTypeRequiredValidationNotes
typeenumIsIn(['avatar','media','document'])Determines folder, MIME allowlist, size cap
contentTypestringIsString()MIME type to send in the PUT request. Must be in the admin allowlist for the type.
fileNamestringoptionalIsString()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.
HeaderRequiredNotes
Authorization: Bearer <accessToken>JWT from POST /auth/login

Response

200 OKApiResponseOf<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
  }
}
FieldTypeNotes
uploadUrlstringTreat 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.
publicUrlstringWhere the file will be reachable AFTER the PUT succeeds. Save this on your domain row (e.g. User.avatarUrl).
keystringStorage object key (path). Useful if your downstream endpoint takes a key instead of a URL.
expiresInnumberSeconds until uploadUrl becomes invalid (admin-managed upload.presign_expiry_seconds, default 300). Plan to PUT within this window.

Errors

HTTPcode / i18nKeyReason
400error.upload.not_configuredActive object storage provider isn't configured server-side — admin must set credentials
400error.upload.invalid_typetype not one of avatar / media / document (DTO catches this earlier; service guard is defense-in-depth)
400error.upload.invalid_content_typecontentType 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

  1. Resolve active storage client; if not configured → throw not_configured.
  2. Lookup folder from type (avatars / media / documents); guard against unknown types.
  3. Read MIME allowlist from upload.<type>_mimes (admin config); reject if contentType isn't in the list. Error payload includes { contentType, type }.
  4. Read maxSizeMb from upload.max_<type>_size_mb (admin config; defaults 5 / 50 / 10).
  5. Read presignExpiry from upload.presign_expiry_seconds (admin config; default 300).
  6. Generate key = <folder>/<userId>/<randomUUID>.<ext> (where ext is parsed from contentType).
  7. Build PutObjectCommand({ Bucket, Key: key, ContentType, ContentLength: maxSizeMb * 1024 * 1024, Metadata: { userId, type } }).
  8. getSignedUrl(client, command, { expiresIn: presignExpiry }) — wrapped in withRetry for transient SDK failures.
  9. Compute publicUrl from active CDN config or fall back to a provider-default host.
  10. 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

POST
/api/v1/upload/presign
AuthorizationBearer <token>

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

SourcePathLines
Controllerapps/api-core/src/modules/upload/upload.controller.ts56–92 (getPresignedUrl), 16–41 (PresignedUploadDto inline)
DTO (response)apps/api-core/src/modules/upload/dto/upload-response.dto.ts5–28 (PresignedUrlResponseDto)
Serviceapps/api-core/src/modules/upload/upload.service.ts158–206 (getPresignedUploadUrl)
Storage client(admin-managed via external.storage.active_provider)getActiveClient(), getActiveBucket(), getActivePublicUrl()
Config keysapps/api-core/src/modules/config/config.service.tsupload.<type>_mimes (allowlists), upload.max_<type>_size_mb (caps), upload.presign_expiry_seconds (URL TTL)
Downstream consumersvariousPATCH /users/avatar (avatar URL), PATCH /creators/:creatorId/bio (avatarUrl), POST /messages (no attachments yet — future)

On this page