Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/gustavogomez092/gravity-forms-uploads-to-gcp-bucket/archive/refs/heads/main.zipOffload Gravity Forms file uploads directly to Google Cloud Storage via signed-URL resumable uploads. File bytes never touch your web server.
A WordPress plugin that adds a GCS Upload field to Gravity Forms. The browser uploads files straight to Google Cloud Storage using a short-lived, V4-signed resumable URL — your PHP workers and local disk are completely bypassed.
Table of contents
- Why this exists
- Features
- How it works
- Requirements
- Installation
- Configuration
- Using the field
- Permanent download URLs
- Merge tags
- Security model
- Operations
- Development
- FAQ
- Changelog
- License
Why this exists
The stock Gravity Forms File Upload field streams every byte through PHP. For 100 MB+ files this:
- saturates the PHP worker pool (one busy worker per in-flight upload),
- fills local disk (
wp-content/uploads/gravity_forms/...), - breaks under flaky networks (no resume — a dropped connection means restart from byte 0),
- and leaves uploaded files reachable at predictable URLs unless additional protection is bolted on.
This plugin replaces that path. The browser PUTs directly to GCS over a signed resumable session; the server only ever sees small JSON payloads (init, finalize, validate).
Features
- Direct-to-GCS uploads — bytes never traverse your web server.
- Resumable sessions — the JS client resumes from the last acknowledged byte on network drops; uploads of hundreds of MB complete reliably.
- Server-side verification before entry save — a finalize step HEADs the object in GCS so webhooks never fire pointing at a missing file.
- Permanent, single-click download URLs — HMAC-signed proxy URLs you can paste into CRMs, ticket systems, or notification emails.
- Private bucket by default — files are never publicly readable; downloads flow through a per-site signed proxy.
- Kill switch — rotating the signing secret invalidates every outstanding permanent URL in one click.
- Auto-attaches to AJAX-loaded forms — multi-step forms, AJAX confirmation reloads, and dynamic embeds work without extra wiring (
gform_post_render+MutationObserverfallback). - Orphan cleanup cron — paginated
objects.listscan that removes uploads which were initialized but never finalized into a form entry. - Per-form bucket overrides — route different forms to different buckets if needed.
- Trusted-proxy aware rate limiting — opt-in
X-Forwarded-For/CF-Connecting-IPsupport; default isREMOTE_ADDR.
How it works
┌──────────┐ 1. POST /init (form, file meta) ┌──────────────┐
│ Browser │ ─────────────────────────────────────────▶│ WordPress │
│ (JS) │ │ (this │
│ │ ◀───── 2. signed resumable upload URL ─── │ plugin) │
│ │ └──────┬───────┘
│ │ 3. PUT bytes directly (resumable) │
│ │ ──────────────────────────────────────────────────┼──────▶ ┌──────────┐
│ │ │ │ GCS │
│ │ ◀──────── 4. 200 OK (upload complete) ────────────┼─────── │ bucket │
│ │ │ └────┬─────┘
│ │ 5. POST /finalize (upload id) │ │
│ │ ─────────────────────────────────────────────────▶│ 6. HEAD object
│ │ │ ─────────▶ │
│ │ ◀───── 7. entry saved, permanent URL returned ────│ │
└──────────┘ └─────────────┘
- Init — JS calls
admin-ajax.php?action=gfgcs_initwith form id, field id, and file metadata. The plugin mints a service-account-signed resumable upload URL scoped to a single object key under a UUID-keyed prefix. - Upload — the browser PUTs bytes directly to GCS. The JS client handles resume on disconnect.
- Finalize — on success, JS calls
gfgcs_finalize. The plugin HEADs the object server-side to confirm it actually landed and matches the declared size/type before the form entry is saved. - Download — the entry stores a permanent proxy URL of the form
…/?gfgcs_dl=<sig>&k=<object-key>. Requests to that URL are HMAC-verified against the per-site signing secret and 302-redirected to a short-lived V4-signed GCS URL.
Requirements
- WordPress 6.0+
- PHP 7.4+
- Gravity Forms (active)
- A Google Cloud project with a Storage bucket
- A service account with
Storage Object Adminon the bucket AUTH_KEYandSECURE_AUTH_KEYdefined inwp-config.php(used to encrypt the service-account JSON at rest — required; the plugin hard-errors if they are missing)
Installation
-
Clone or download this repo into
wp-content/plugins/gf-gcs-uploads/. -
Activate the plugin in Plugins → Installed Plugins.
-
Create a service account in GCP and grant it
Storage Object Adminon the target bucket. Download the JSON key. -
Configure CORS on the bucket to allow the resumable upload handshake from your site origin(s):
[ { "origin": [ "https://your-site.example", "https://staging.your-site.example" ], "method": ["GET", "PUT", "POST", "OPTIONS"], "responseHeader": [ "Location", "Content-Type", "Content-Range", "ETag", "x-goog-resumable", "X-Upload-Content-Length", "X-Upload-Content-Type" ], "maxAgeSeconds": 3600 } ]List every scheme+host+port the form is served from —
origindoes not support wildcards.Locationandx-goog-resumableare required: the browser readsLocationto learn the upload session URL returned by thePOSTinit, andx-goog-resumableis the custom request header sent on init.Apply with:
gsutil cors set cors.json gs://your-bucket # or, with the newer gcloud: gcloud storage buckets update gs://your-bucket --cors-file=cors.jsonVerify with
gsutil cors get gs://your-bucket. -
Go to Forms → Settings → GCS Storage, paste the JSON, set the default bucket name, and click Test Connection.
Configuration
The settings screen lives at Forms → Settings → GCS Storage and is split into sub-tabs:
| Sub-tab | What lives here |
|---|---|
| Storage | Service-account JSON, default bucket, connection test, signing-secret rotation. |
| Access | Rate-limit policy, trusted-proxy header opt-in (X-Forwarded-For, CF-Connecting-IP). |
| Cleanup | Orphan-upload cron schedule, last-run summary, per-form override buckets. |
When a service account is already configured, the Storage tab shows a ✓ Service account configured banner with the SA email and project ID above the textarea. Leaving the textarea blank on save preserves the existing credential.
Using the field
- Edit any form. Open Add Fields → Advanced Fields.
- Drag in GCS Upload.
- In the field's General tab, optionally set:
- Allowed file types (comma-separated extensions),
- Max file size (MB),
- Bucket override (defaults to the global bucket).
- Save the form. The frontend now uses the GCS upload widget for that field.
Permanent download URLs
Every uploaded file gets a permanent URL of the form:
https://your-site.example/?gfgcs_dl=<sig>&k=<object-key>
These URLs:
- are HMAC-signed with the per-site signing secret,
- never expire on their own,
- 302-redirect to a short-lived V4-signed GCS URL (default TTL: 10 minutes) on each request,
- can be safely pasted into Gravity Forms notifications, Zapier webhooks, ticket systems, etc.
To revoke every outstanding URL in a single action, click Rotate signing secret on the Storage tab. All previously issued URLs immediately return 404.
Merge tags
The GCS Upload field exposes the following merge tags for use in notifications, confirmations, and webhooks:
| Merge tag | Value |
|---|---|
{GCS Upload:N} |
Permanent download URL (the proxy URL described above). |
{GCS Upload:N:filename} |
Original filename as uploaded. |
{GCS Upload:N:size} |
File size in bytes. |
{GCS Upload:N:mime} |
Content-Type as declared by the browser. |
{GCS Upload:N:key} |
Raw GCS object key (use with care — exposing this leaks bucket layout). |
Where N is the field ID.
Security model
- Files are private. The bucket should have uniform bucket-level access enabled and zero public IAM bindings. The plugin only ever generates time-limited V4-signed URLs.
- Service-account JSON is encrypted at rest using a key derived from
AUTH_KEY+SECURE_AUTH_KEY. The plugin refuses to operate if those constants are missing rather than silently falling back to a weaker key. - The signing secret is generated on activation (64 random characters) and stored in
wp_optionsundergfgcs_signing_secret. Rotating it is the kill switch for every outstanding permanent URL. - Init requests are rate limited per source IP. Trusted-proxy headers (
X-Forwarded-For,CF-Connecting-IP) are honored only when explicitly opted in — by default the plugin usesREMOTE_ADDR. - The
submission_uuidis validated against the canonical UUID-v4 shape; malformed values are rejected. - OAuth token cache is invalidated when the service-account JSON is rotated so a stale token can't survive credential replacement.
- Content-Disposition headers follow RFC 5987 (ASCII-safe
filename=plus UTF-8filename*=); control characters are stripped to prevent header injection.
Operations
Orphan cleanup
A WP-Cron task scans the bucket for objects whose corresponding init record was never finalized into a form entry, and deletes them. The scan paginates objects.list so buckets with more than 1000 objects are fully covered. Per-form override buckets are also scanned. The last run summary (target count, per-target errors) is stored in the gfgcs_cleanup_last_run option and surfaced on the Cleanup tab.
Logs
OAuth token-exchange failures are typed (GFGCS_OAuth_Exception) and classified as transient (retry) or permanent (admin action required). Permanent errors include actionable hints — for example, an invalid_grant will suggest checking clock skew and rotating the service-account key.
Health checks
- Test Connection button on the Storage tab — does a real OAuth exchange and a bucket
GET. gfgcs_cleanup_last_runoption — last orphan-cleanup summary.
Development
# Install dev dependencies (PHPUnit + Brain Monkey + Mockery for PHP;
# Vitest + jsdom for JS). Both are dev-only — runtime install does not
# require either.
composer install
npm install
# Run the PHP test suite
./vendor/bin/phpunit
# Run the JS test suite
npm test
No build step. Frontend assets (assets/js/*.js, assets/css/*.css) are vanilla and load directly. Both vendor/ and node_modules/ are gitignored.
The plugin source lives under includes/:
| File | Responsibility |
|---|---|
class-gfgcs-addon.php |
Gravity Forms Add-On bootstrap, hooks. |
class-gfgcs-field.php |
The GCS Upload field type. |
class-gfgcs-ajax.php |
gfgcs_init and gfgcs_finalize AJAX endpoints. |
class-gfgcs-gcs-client.php |
GCS API client (objects.list, objects.delete, HEAD). |
class-gfgcs-oauth.php |
Service-account → access-token exchange, with typed errors. |
class-gfgcs-signer.php |
V4 signing for resumable upload URLs and download redirects. |
class-gfgcs-proxy.php |
HMAC-verified permanent-URL proxy → 302 to signed GCS URL. |
class-gfgcs-validator.php |
Finalize-time HEAD checks (size, type, existence). |
class-gfgcs-cleanup.php |
Orphan-upload cron. |
class-gfgcs-settings.php |
Settings screen (Storage / Access / Cleanup sub-tabs). |
class-gfgcs-merge-tags.php |
Merge tags. |
FAQ
Why not the regular Gravity Forms File Upload field? It streams every byte through your PHP server. For hundreds-of-megabyte files this saturates the worker pool and fills your local disk. This plugin uploads browser → GCS directly.
What happens to the permanent URLs if I rotate the signing secret? They all return 404 — immediately. This is the kill switch; use it if a webhook recipient is compromised or if signed URLs have been leaked.
How does it handle network dropouts mid-upload? GCS resumable upload sessions persist server-side; the JS client resumes from the last acknowledged byte automatically. No restart from byte 0.
Can different forms use different buckets? Yes — each field has an optional Bucket override setting. The orphan-cleanup cron scans per-form override buckets in addition to the global default.
Does the plugin work with multi-step / AJAX-loaded forms?
Yes. The JS uploader auto-attaches on gform_post_render and via a MutationObserver fallback for dynamic embeds. Attachment is idempotent.
Where is the service account JSON stored?
In wp_options, encrypted with a key derived from AUTH_KEY + SECURE_AUTH_KEY. If those constants are missing from wp-config.php, the plugin hard-errors instead of falling back to a weaker key.
Changelog
See readme.txt for the full changelog. Highlights:
0.2.7
- Fix: webhook payloads now contain proxy URLs for
gcs_uploadfields. The GF Webhooks add-on builds its payload outside any field-value filter, so the raw GCS descriptor was reaching webhook recipients in bothall_fieldsandselect_fieldsmodes. Newgform_webhooks_request_datahandler rewrites each file descriptor soobject_pathcarries the HMAC-signed proxy URL while the rest of the shape (original_name,size,mime,file_uuid) is preserved.
0.2.3
- Fix: entry-detail screen renders the per-file proxy URLs again. The
gform_entry_field_valuefilter was decoding the already-formatted<ul>returned byget_value_entry_detail()as JSON, getting null, and wiping the cell. Filter now reads the raw descriptor from$entry[$field->id]directly.
0.2.2
- Fix:
inputType=fileuploadcorruption no longer breaks submission —get_input_type()hard-pins togcs_upload. - Fix: outer wrapper class renamed (
gform_fileupload_multifile→gfgcs-multifile) so GF's plupload init no longer attaches to our element. - Fix: JS uploader does the real GCS resumable two-step (POST init → PUT to Location URL). Previous direct PUT was a protocol mismatch.
- Fix:
object_metadataHTTP timeout bumped 5s → 20s; GCS HEAD occasionally exceeded the lower bound and submission failed verification. - Fix: preview-row + delete-button styling restored after wrapper rename broke the native CSS chain. Inline
×glyph rendered as a real child of the button.
0.2.1
- Fix:
gfgcs_aborthonors per-form bucket override. - Fix: editor no longer corrupts
gcs_uploadfield's inputType when toggling multi-file. - Fix: 422 responses include
messagefield forsize_exceededandmime_not_allowed.
0.2.0
- Feature: field UI matches native Gravity Forms File Upload — General-tab settings, dashed-box dropzone, per-file progress rows with remove button.
- Feature:
gfgcs_abortAJAX endpoint allows mid-upload cancel + post-upload removal from GCS. - Change: per-field file-type filter is now extension-based. One-time 0.2.0 migration translates existing MIME values.
0.1.4
- UX: settings page now shows a
✓ Service account configuredbanner (SA email + project ID) when credentials are already saved.
0.1.3
- Fix: pasting service-account JSON no longer triggers a fatal error caused by Gravity Forms' Settings v2
maybe_decode_json()decoding the JSON-shaped POST value into a PHP array. The SA JSON field is now rendered outside the Settings v2 input pipeline.
0.1.2
- Fix: hard-error when
AUTH_KEY/SECURE_AUTH_KEYare missing instead of silently falling back to a degraded site-URL-based encryption key. - Fix: settings-page asset enqueue is now scoped to the GCS Storage subview.
- Fix: bucket name is no longer disclosed in the
gfgcs_initAJAX response. - Fix: orphan-cleanup cron paginates
objects.listso buckets > 1000 objects are fully scanned. - Fix: JS uploader auto-attaches to AJAX-loaded forms.
- Fix:
Content-Dispositionfollows RFC 5987 for non-ASCII filenames; control characters stripped. - Fix: OAuth errors classified as
transientvspermanentvia typed exceptions, with actionable hints.
0.1.1
- Fix: OAuth access-token cache invalidated when the service-account JSON is replaced.
- Fix: validator no longer runs HEAD checks on admin-side entry edits.
- Fix: tightened
submission_uuidregex to canonical UUID-v4 shape. - Fix: rate-limit IP source now opt-in for trusted-proxy headers.
- Fix: orphan cleanup scans per-form override buckets.
0.1.0
- Initial release.
License
GPL-2.0-or-later. See the plugin header for details.