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 only one of them lives on the object. The MIME type comes from the content_type stored at upload. The saved filename comes from the last segment of the object key — unless you pass response_disposition when you mint the link, in which case Infrai signs that header into the URL and the storage host echoes it back verbatim.

So one repair happens on the object and one happens per link, and neither of them requires uploading 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, because we asked for nothing. And x-amz-force-download rides along on a default link, which is why an unqualified signed URL saves instead of previewing whatever MIME type you stored.

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. GET /v1/storage/object/list/{bucket} reports the stored content_type per item too, which is the faster way to audit a whole prefix at once:

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"
  }
}

One field, one call, no re-upload.

Cause two: the filename is the key, unless you say otherwise

Ask for nothing and 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.

The per-link fix is response_disposition on the presign call. It goes into the signature with everything else, so it survives the trip, and it accepts the RFC 5987 filename* form that any non-ASCII name needs:

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,\"response_disposition\":\"attachment; filename*=UTF-8''Rechnung%20M%C3%BCller.pdf\"}" \
  | 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; filename*=UTF-8''Rechnung%20M%C3%BCller.pdf
content-length: 69
cache-control: private, max-age=0, no-store

Two details worth noticing there. The value round-trips byte for byte, percent-encoding included, so the umlaut arrives intact. And x-amz-force-download is absent — supplying your own disposition replaces the default rather than merging with it.

That covers the download button. It doesn’t cover a key that’s ugly everywhere else, so name keys the way you want files named anyway: invoices/2026-07/INV-2026-0042.pdf is unguessable enough at the prefix level while still reading like a filename an accountant recognises 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"}'

Editing the URL by hand is the first. The whole query string sits inside the signature, so pasting response-content-disposition=inline onto a link you already hold earns a 403 rather than a preview. The parameter has to go through presign, where it gets signed along with everything else.

Inline preview is the second, and it’s the real boundary on this route. Ask for response_disposition: "inline" and the object still comes back as Content-Disposition: attachment with x-amz-force-download: true alongside it, which is a deliberate posture rather than an oversight: a signed URL on a private bucket is a bearer token handed to a browser, and a bucket that never renders anything in a tab is a bucket that can’t be turned into a hosting surface by anyone who scrapes one link out of an email. Useful when the product is invoices; annoying when the product is a document viewer. If you need the PDF to open in place, 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 filenameNothing passed at presign timeresponse_disposition on the presign callFree
Umlauts and accents mangledPlain filename= can’t carry themfilename*=UTF-8''… inside response_dispositionFree
Key itself is a UUID everywhereHistorical upload namingobject/copy to a readable key, then delete$0.0001 per copy
PDF downloads instead of openingattachment is fixed on the signed routeProxy route with your own headersEgress per view
Link 403s after you edit the URLQuery string is inside the signatureRe-sign with the parameter, don’t append itFree

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. Every filename and MIME-type repair on this page is therefore free — which is the honest reason to fix the metadata rather than re-upload.

The two billable pieces meter on different units. storage.object.copy and storage.object.put are $0.0001 a call, so renaming ten thousand objects is ten thousand very cheap calls.

storage.object.get — the route the proxy handler above uses — meters $0.104 per GB of egress. That unit is what decides whether an inline viewer is worth building: a 200 KB invoice opened a thousand times moves 200 MB through your handler, while the same thousand opens over signed links never touch the route at all. Check today’s figures 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 accept ResponseContentType as a signing parameter as well as the disposition, so one signature there 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 reason is what sits beside them: the job that renders the invoice can be queued at POST /v1/queue/publish, the finished link mailed with POST /v1/email/send, and a failed render recorded at POST /v1/errors/capture — all already on the same account, with no second vendor and no second bill to reconcile at month end.

References

Browse more storage developer guides