fix(storage): support presigned PUT uploads for object stores without POST - #47
Merged
Conversation
… 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
force-pushed
the
fix/r2-presigned-put
branch
from
August 5, 2026 00:51
d77176c to
11d2d91
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Browser uploads cannot land on Cloudflare R2. R2 does not implement presigned POST:
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 andfinding 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:
Origin/ byte count200, object lands, matching ETag501 NotImplementedThe change
generate_presigned_put()— presigned PUT flavour, returning the same envelope shape.generate_presigned_upload()— dispatcher selected byAWS_S3_UPLOAD_METHOD; the nine assetviews now call it.
generate_presigned_post()is untouched.method, so an install moves between S3/MinIO and R2 with noclient-side change.
The default stays
post. S3 and MinIO both implement presigned POST and every existingdeployment relies on it — switching the default would break working installs to fix stores that
aren't in use. An unrecognised value falls back to
postrather than disabling uploads.This is stricter than the POST policy, not a relaxation
Content-TypeandContent-Lengthare signed intoSignedHeaders, so the store rejects anymismatch. Verified against live R2, driving the actual code path:
generate_presigned_uploadwith the env var setmethod: PUT, emptyfields, signed headers200, object lands at the exact size/type403 SignatureDoesNotMatchContent-Type403 SignatureDoesNotMatchPOSTcontent-length-rangepermitted anything in[1, file_size]; a signedContent-Lengthpins thesize 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 usesit, so it raises rather than uploading to a key containing the literal string.
Client details
fieldsstays present-but-empty on PUT so the response shape is stable.methodis optional on the type because older servers omit it and are always POST.Content-Lengthis dropped from the headers the browser sends — it is set from the body andcannot be set from script, and the body is the exact file that was signed for.
Gates
unknown-value fallback, signed params, and the
${filename}rejection). Note the API workflowruns
ruffonly, so these are not CI-gated.ruff check apps/api— no new findings (2 pre-existingF401s insub_issue.py, identical onmain, auto-fixed by CI's--fix).check:types,check:lint,check:format— 22/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 oxlintwith
--deny-warnings, and these files carried pre-existingno-named-as-default-memberwarnings (identical on
main) that block any commit touching them. The siblingCancelTokensuggestion 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