Skip to content

fix(storage): support presigned PUT uploads for object stores without POST - #47

Merged
aRustyDev merged 1 commit into
mainfrom
fix/r2-presigned-put
Aug 5, 2026
Merged

fix(storage): support presigned PUT uploads for object stores without POST#47
aRustyDev merged 1 commit into
mainfrom
fix/r2-presigned-put

Conversation

@aRustyDev

Copy link
Copy Markdown
Owner

The bug

Browser uploads cannot land on Cloudflare R2. R2 does not implement presigned POST:

HTTP 501  <Code>NotImplemented</Code>
          <Message>Presigned post requests are not yet implemented</Message>

Presigned GET works fine, so on R2 downloads succeed and uploads silently never arrive, leaving
the asset row at is_uploaded=false. Found by uploading a real attachment to a live instance and
finding the bucket empty.

Reproduced against a live R2 bucket with the same credential the app signs with. The control
isolates it to the verb — not credentials, CORS or object size:

Same creds / bucket / Origin / byte count Result
presigned PUT 200, object lands, matching ETag
presigned POST 501 NotImplemented

The change

  • generate_presigned_put() — presigned PUT flavour, returning the same envelope shape.
  • generate_presigned_upload() — dispatcher selected by AWS_S3_UPLOAD_METHOD; the nine asset
    views now call it. generate_presigned_post() is untouched.
  • Clients dispatch on the returned method, so an install moves between S3/MinIO and R2 with no
    client-side change
    .

The default stays post. S3 and MinIO both implement presigned POST and every existing
deployment relies on it — switching the default would break working installs to fix stores that
aren't in use. An unrecognised value falls back to post rather than disabling uploads.

This is stricter than the POST policy, not a relaxation

Content-Type and Content-Length are signed into SignedHeaders, so the store rejects any
mismatch. Verified against live R2, driving the actual code path:

Check Result
generate_presigned_upload with the env var set method: PUT, empty fields, signed headers
Real PUT of the returned URL 200, object lands at the exact size/type
control — tampered (longer) body 403 SignatureDoesNotMatch
control — wrong Content-Type 403 SignatureDoesNotMatch
No env var set still POST

content-length-range permitted anything in [1, file_size]; a signed Content-Length pins the
size exactly, and the key is part of the signed URL rather than a form field the client
supplies. ${filename} templating is POST-only and cannot be expressed as a PUT — no caller uses
it, so it raises rather than uploading to a key containing the literal string.

Client details

  • fields stays present-but-empty on PUT so the response shape is stable.
  • method is optional on the type because older servers omit it and are always POST.
  • Content-Length is dropped from the headers the browser sends — it is set from the body and
    cannot be set from script, and the body is the exact file that was signed for.

Gates

  • 16 backend unit tests pass (8 pre-existing + 8 new covering dispatch, case handling, the
    unknown-value fallback, signed params, and the ${filename} rejection). Note the API workflow
    runs ruff only, so these are not CI-gated.
  • ruff check apps/api — no new findings (2 pre-existing F401s in sub_issue.py, identical on
    main, auto-fixed by CI's --fix).
  • check:types, check:lint, check:format22/22 green across @plane/types,
    @plane/services, web, space.

One incidental change

axios.isCancel → named import in the two upload services. Not gratuitous: lint-staged runs oxlint
with --deny-warnings, and these files carried pre-existing no-named-as-default-member
warnings (identical on main) that block any commit touching them. The sibling CancelToken
suggestion is a false positive — axios v1 exports it as a type only, so the named import fails
with TS2693 — and is silenced inline with that reason recorded.

Refs: plane-7fn

🤖 Generated with Claude Code

… POST

Browser uploads cannot land on Cloudflare R2. R2 does not implement presigned
POST and answers one with

    HTTP 501 <Code>NotImplemented</Code>
             <Message>Presigned post requests are not yet implemented</Message>

while presigned GET works fine — so on R2 downloads succeed, uploads silently
never arrive, and the asset row is left with is_uploaded=false. Reproduced
against a live R2 bucket; a presigned PUT with the same credential, bucket,
Origin and byte count returns 200 and the object appears, which isolates the
failure to the verb rather than to credentials, CORS or object size.

Adds a presigned PUT flavour selected by AWS_S3_UPLOAD_METHOD, and a
generate_presigned_upload() dispatcher that the nine asset views now call.
generate_presigned_post() is untouched.

The default stays "post". S3 and MinIO both implement presigned POST and every
existing deployment already relies on it, so switching the default would break
working installs to fix stores that are not in use. An unrecognised value falls
back to "post" rather than disabling uploads.

The PUT flavour is STRICTER than the POST policy it replaces, not a relaxation.
Content-Type and Content-Length are signed into SignedHeaders, so the store
rejects a mismatch with 403 SignatureDoesNotMatch — verified with both controls
against live R2. content-length-range permitted anything in [1, file_size]
whereas a signed Content-Length pins the size exactly, and the key is part of
the signed URL rather than a form field the client supplies. `${filename}`
templating is POST-only and cannot be expressed as a PUT; no caller uses it, so
it raises rather than uploading to a key containing the literal string.

Clients dispatch on the returned `method`, so an install can move between S3 or
MinIO and R2 with no client-side change. `fields` stays present-but-empty on
PUT to keep the response shape stable, and `method` is optional on the type
because older servers omit it and are always POST. Content-Length is dropped
from the headers the browser sends: it is set from the body and cannot be set
from script, and the body is the exact file that was signed for.

Also switches axios.isCancel to the named import in the two upload services.
That is not gratuitous: lint-staged runs oxlint with --deny-warnings, and these
files carried pre-existing no-named-as-default-member warnings that block any
commit touching them. The sibling CancelToken suggestion is a false positive —
axios v1 exports it as a type only, so the named import fails to compile with
TS2693 — and is silenced inline with that reason recorded.

Refs: plane-7fn
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aRustyDev
aRustyDev force-pushed the fix/r2-presigned-put branch from d77176c to 11d2d91 Compare August 5, 2026 00:51
@aRustyDev
aRustyDev merged commit bf5ff8d into main Aug 5, 2026
15 checks passed
@aRustyDev
aRustyDev deleted the fix/r2-presigned-put branch August 5, 2026 00:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant