Signed downloads with the wrong filename or MIME type: what to fix

A presigned link saves as octet-stream or a random name because of the key and the stored content type. How to diagnose both and repair them without re-uploading.

Two different things decide what a browser does with a presigned download, and they live in two different places. The MIME type comes from the content_type stored on the object; the saved filename comes from the last segment of the object key, because Infrai’s signed responses send a bare Content-Disposition: attachment with no filename parameter at all. Neither is set by the presign call, so fixing the link means fixing the object.

The good news is that both repairs are cheap, and one of them is free — you don’t need to upload the file again.

Before theorising, look. Ask for a signed URL, then dump the response headers:

export INFRAI_API_KEY="your_infrai_api_key"

SIGNED_URL=$(curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kb-mimefix-0726/invoices/2026-07/INV-2026-0042.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":300}' \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")

curl -sS -D - -o /dev/null "${SIGNED_URL}"
HTTP/1.1 200 OK
content-type: application/pdf
content-disposition: attachment
content-length: 69
x-amz-force-download: true

Three facts are visible in four lines. The type served is whatever the object carries. The disposition is attachment with nothing after it. And x-amz-force-download means this link always saves — there’s no inline preview to be had from it, whatever MIME type you store.

Cause one: content_type was never stored

PUT /v1/storage/object/put/{bucket}/{key} takes the bytes as base64 plus an optional content_type. Omit it and the storage layer guesses from the key’s extension, which works for .pdf and falls apart for report-3311 or export.bin. Send it and it’s used verbatim — we uploaded a CSV under a .bin key with content_type: "text/csv" and the signed link served text/csv.

Build the payload in a file so the base64 never has to survive a shell quote:

python3 - <<'PY'
import base64, json, pathlib
raw = pathlib.Path("/tmp/INV-2026-0042.pdf").read_bytes()
pathlib.Path("/tmp/payload.json").write_text(json.dumps({
    "data_base64": base64.b64encode(raw).decode(),
    "content_type": "application/pdf",
    "cache_control": "private, max-age=0, no-store",
}))
PY

curl -sS -X PUT \
  "https://api.infrai.cc/v1/storage/object/put/kb-mimefix-0726/invoices/2026-07/INV-2026-0042.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @/tmp/payload.json

Already uploaded a few thousand files with the wrong type? Don’t re-upload them. POST /v1/storage/object/set_metadata/{bucket}/{key} rewrites content_type and cache_control in place, it’s free, and the change shows up on the next signed link:

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/set_metadata/kb-mimefix-0726/exports/report-3311.bin" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"content_type":"text/csv"}'

Confirm with a free head request rather than guessing — note that GET /v1/storage/object/list/{bucket} returns content_type: null for every item, so listing is no use for this check:

curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/kb-mimefix-0726/invoices/2026-07/INV-2026-0042.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": true,
    "status": "found",
    "key": "invoices/2026-07/INV-2026-0042.pdf",
    "size_bytes": 69,
    "etag": "e52be4d4d8c5ae45b32c7b4be1a92e30",
    "content_type": "application/pdf",
    "metadata": null,
    "last_modified": "2026-07-26T01:01:13Z"
  }
}

Cause two: the filename is the key

With no filename in the header, a browser falls back to the last path segment of the URL — and that segment is your object key’s tail. Store a document as 9f3c1a2b.pdf and the user’s Downloads folder gets 9f3c1a2b.pdf.

So name keys the way you want files named. invoices/2026-07/INV-2026-0042.pdf is unguessable enough at the prefix level while still landing in the browser as a filename an accountant can recognise a year later.

For objects already written under opaque names there’s no rename verb, but POST /v1/storage/object/copy does the job — it preserves the content type and custom metadata, then you delete the source:

curl -sS -X POST "https://api.infrai.cc/v1/storage/object/copy" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"src_bucket":"kb-mimefix-0726","src_key":"exports/report-3311.bin","dst_bucket":"kb-mimefix-0726","dst_key":"exports/orders-july-2026.csv"}'

What this API can’t do for you

Appending S3-style response overrides to the URL doesn’t work, and it’s worth knowing why before you spend an afternoon on it. Add response-content-disposition=inline to a signed URL and the signature no longer matches the query string, so the host answers 403. Strip the signature first and the host rejects the override with 400.

There is also no way to set a UTF-8 filename* for a document called Rechnung Müller.pdf, and no way to make a link preview inline in a viewer tab. If you need either, proxy the bytes through your own route and write your own headers:

import express from "express";

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

const app = express();

app.get("/invoices/:id/view", async (req, res) => {
  if (!/^[A-Za-z0-9-]{1,40}$/.test(req.params.id)) return res.status(400).send("bad id");
  const key = `invoices/2026-07/${req.params.id}.pdf`;
  try {
    const upstream = await fetch(`${API}/v1/storage/object/get/${BUCKET}/${key}`, {
      method: "GET",
      headers: { Authorization: `Bearer ${token}` },
    });
    const payload = await upstream.json();
    if (!upstream.ok || !payload.data?.found) return res.status(404).send("not found");
    const bytes = Buffer.from(payload.data.data_base64, "base64");
    const pretty = encodeURIComponent(`Invoice ${req.params.id}.pdf`);
    res.set("Content-Type", "application/pdf");
    res.set("Content-Disposition", `inline; filename*=UTF-8''${pretty}`);
    res.set("Cache-Control", "private, no-store");
    res.send(bytes);
  } catch (err) {
    console.error("invoice proxy failed", err);
    res.status(502).send("storage unavailable");
  }
});

app.listen(3000);

The catch is that this route reads the whole object into memory as base64 — fine for a 200 KB invoice, unwise above a few megabytes. Keep it for documents people read in the app and leave bulk exports on the signed link.

The four symptoms, mapped

What the user seesActual causeFixCost
Saves as application/octet-streamNo content_type, key has no useful extensionset_metadata with the real typeFree
Right type, meaningless filenameKey tail is a UUIDobject/copy to a readable key, then delete$0.0001 per copy
PDF downloads instead of openingFixed attachment + x-amz-force-downloadProxy route with your own headersOne object.get per view
Link 403s after you edit the URLQuery string is inside the signatureRe-sign; never append parametersFree

What the repairs cost

Verified 26 July 2026 from the live catalogue: set_metadata, head, list and presign are free and rate-limited and don’t consume the $2 of trial credit on a new account; storage.object.copy and storage.object.put are $0.0001 per call, and storage.object.get — the one the proxy route uses per view — is $0.0002 per call. Reads are published at twice the price of writes. Check today’s figure rather than this sentence:

curl -sS https://api.infrai.cc/v1/discovery \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"

Storage rates drift downward and discounts run, so the live number is likely to be lower than the one printed here; GET /v1/account/usage is what your invoice is actually built from.

When another backend is the right answer

Amazon S3 and Cloudflare R2 both accept ResponseContentDisposition and ResponseContentType as signing parameters, so one signature can serve the same object inline to a viewer and as Q3-report.pdf to a download button. If per-request presentation control is central to your product — a document viewer, a media library, anything with a preview pane — stick with them and accept the extra vendor. Infrai’s storage is the better fit when downloads are a feature rather than the product, and the value is that the same key also runs your cron jobs, queues, mail and model calls under one invoice.

References

Browse more storage developer guides