Avatar uploads: why the browser posts to your API, not the bucket

A presigned PUT from the page needs the storage host to answer a preflight. On Infrai the avatar path runs through your route instead — which for a 512px crop costs nothing you'll notice.

Send the avatar to your own API and let your API write it to the bucket. On Infrai that’s PUT /v1/storage/object/put/{bucket}/{key} from your handler, and for a cropped 512×512 image — call it 80 to 300 KB — the extra hop is invisible next to the time the user spent choosing the file. A cross-origin PUT straight from the page is a different proposition: the browser sends an OPTIONS preflight first and the storage host has to answer it, which is not something you can switch on from this API today.

So if your hard requirement is page-to-bucket with no server in the middle, use a store built for it — Cloudflare R2, Amazon S3 or Supabase Storage all let you write a CORS policy and hand the browser a URL. If the requirement is “users can change their avatar”, read on, because the relay is simpler than the thing it replaces. Everything below was run against api.infrai.cc on 27 July 2026.

Why the preflight is not skippable

Two independent things have to be true for a cross-origin upload from a page: the signature has to be valid, and the storage host has to publish a CORS rule that admits your origin, the method and the request headers. curl only exercises the first, which is why the identical URL that fails in Chrome works from a shell — curl doesn’t implement the same-origin policy, it just sends the request.

You can’t dodge it by being clever either. A PUT carrying Content-Type: image/png is not a simple request — only text/plain, multipart/form-data and application/x-www-form-urlencoded are safelisted content types — so the preflight happens before a byte moves.

Buckets do carry a CORS rule set, and POST /v1/storage/bucket/set_cors/{bucket} writes it. The rules round-trip:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_cors/avatars-demo" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"allowed_origins":["https://app.example.com"],"allowed_methods":["PUT","GET"],"allowed_headers":["content-type"],"max_age_seconds":3600}]}'
{
  "ok": true,
  "data": {
    "bucket": "avatars-demo",
    "cors_rules": [
      {
        "allowed_origins": ["https://app.example.com"],
        "allowed_methods": ["PUT", "GET"],
        "allowed_headers": ["content-type"],
        "max_age_seconds": 3600
      }
    ]
  }
}

GET /v1/storage/bucket/get/{bucket} reads them back, and the call replaces the whole rule set rather than merging into it. Treat that as the record of which origins you intend to admit — and build the upload path below regardless.

The page: crop, then post to your own route

Cropping client-side is the step that makes everything after it cheap. A 512×512 PNG or WebP is small enough that a JSON body is a perfectly reasonable transport.

export async function uploadAvatar(blob) {
  if (blob.size > 1_048_576) throw new Error("crop smaller: 1 MB limit");

  const buf = new Uint8Array(await blob.arrayBuffer());
  let binary = "";
  for (const byte of buf) binary += String.fromCharCode(byte);

  const res = await fetch("/api/avatar", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ data: btoa(binary), type: blob.type }),
  });
  if (!res.ok) throw new Error(`avatar upload failed: ${res.status}`);
  return res.json();
}

No origin, no preflight, no rule to configure — it’s a same-origin request to your own server. And you get something the direct path can’t give you: the upload is not finished until your code says it is, so the database row and the object are written in the same request.

The handler: validate, write, confirm

Node 22, ESM, no dependencies:

import { createServer } from "node:http";

const API = "https://api.infrai.cc";
const BUCKET = "avatars-demo";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");

const ALLOWED = new Set(["image/png", "image/webp", "image/jpeg"]);
const MAX_BYTES = 1_048_576;

async function call(path, init = {}) {
  const res = await fetch(`${API}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
  });
  const out = await res.json();
  if (!out.ok) throw new Error(`${path}: ${out.error?.code} ${out.error?.message}`);
  return out.data;
}

async function storeAvatar(userId, dataBase64, contentType) {
  if (!ALLOWED.has(contentType)) throw new Error(`unsupported type ${contentType}`);
  if (Buffer.from(dataBase64, "base64").byteLength > MAX_BYTES) throw new Error("too large");

  const key = `u/${userId}/avatar-512.${contentType.split("/")[1]}`;
  const written = await call(`/v1/storage/object/put/${BUCKET}/${key}`, {
    method: "PUT",
    body: JSON.stringify({ data_base64: dataBase64, content_type: contentType }),
  });
  return { key, etag: written.etag, size: written.size_bytes };
}

createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/api/avatar") { res.writeHead(404).end(); return; }
  const chunks = [];
  for await (const c of req) chunks.push(c);
  try {
    const { data, type } = JSON.parse(Buffer.concat(chunks).toString());
    const stored = await storeAvatar("usr_8412", data, type);
    res.writeHead(201, { "content-type": "application/json" }).end(JSON.stringify(stored));
  } catch (err) {
    res.writeHead(400, { "content-type": "application/json" })
      .end(JSON.stringify({ error: String(err.message ?? err) }));
  }
}).listen(3000);

The user id comes from your session, never from the request body — that’s what stops one account overwriting another’s avatar. Decoding the base64 before checking the length matters too: blob.size in the browser is a claim, and base64 inflates by roughly a third on the wire.

Confirm the object independently:

curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/avatars-demo/u/usr_8412/avatar-512.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": true,
    "status": "found",
    "key": "u/usr_8412/avatar-512.png",
    "size_bytes": 70,
    "etag": "2cd8bde463f5d82aae0f0cec061d6b8f",
    "content_type": "image/png",
    "metadata": null,
    "last_modified": "2026-07-27T12:00:28Z"
  }
}

head is free, so assert found, size_bytes and content_type before you write the avatar URL to your users table.

Serving it back

Buckets are private by default, which is the right default for user photos. Mint a short-lived read URL when the page renders:

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/avatars-demo/u/usr_8412/avatar-512.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":600}'

Fetch the URL it returns and you get the bytes; strip the query string and the same object answers 403. Past expires_at it’s STORAGE_PRESIGN_EXPIRED, so a page a user leaves open for an hour needs a re-mint rather than a longer TTL. And the caveat worth keeping in your head: a presigned URL is a bearer token, so keep the window tight and derive keys server-side instead of from anything a user picked.

Where the bytes live, for the US/EU question

POST /v1/storage/bucket/create takes a region, and it’s checked against where the store is physically provisioned:

{
  "ok": false,
  "error": {
    "code": "INVALID_ARGUMENT",
    "http_status": 400,
    "message": "COS is physically provisioned in ap-singapore; requested region eu-central-1 is unavailable",
    "retryable": false
  }
}

That’s the honest answer to “can we keep EU user photos in the EU”: you get a 400 naming where the bytes would actually have gone, rather than an acknowledgement that means nothing. If regional residency is a contractual promise you’ve made to customers, stick with a provider that has a bucket in that region.

ApproachPreflightBytes through your serverGood for
Browser → your API → object/putnone — same originyesavatars and other sub-1 MB images
Browser → your API → presigned upload from the servernoneyesfiles too big for a JSON body
Presigned slot handed to a CLI or workern/anobatch and back-office uploads
Browser → bucket, directrequirednouse R2, S3 or Supabase Storage for this

What happens next is on the same key

An avatar is rarely finished when it lands. Squaring it off is POST /v1/image/crop, free within rate limits and taking the same Authorization header as the bucket call — one credential, no image-processing account to provision, no second SDK in your deploy. GET /v1/account/usage then attributes the storage and the crop to the same tenant in one query. That adjacency is the thing a single-purpose upload widget can’t match, and it usually matters more than a per-call rate.

The limitation to weigh honestly: your server is now on the upload path, so it wears the bandwidth and the request time. For avatars that’s nothing. For a video-upload product it would be the wrong architecture, and one of the stores named above is the better buy.

Signing, head, list and bucket administration are free and rate-limited. PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 per call and GET /v1/storage/object/get/{bucket}/{key} is billed by egress volume at $0.104 per GB, both read on 27 July 2026. Rates drift downward and campaigns run, so read the live block:

curl -sS "https://api.infrai.cc/v1/discovery?namespace=storage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

A type the bucket refuses comes back as STORAGE_CONTENT_TYPE_BLOCKED, which reads like a permissions problem and isn’t — check it against your ALLOWED set before you blame the key.

References

Browse more storage developer guides