Bump version v2.10.1 - #205
Merged
Merged
Conversation
…ries
The custom-action administrator gate and the flow dispatcher normalized the
action type differently: the gate compared flow_details->type verbatim while
Flow::execute() ran it through ucfirst(str_replace(' ', '', ...)). A type of
"customAction" or "Custom Action" therefore skipped the manage_options check yet
still resolved to CustomActionController, which include()s a path carried in the
same caller-supplied JSON. Both sides now share Flow::normalizeActionType(), and
the include is confined independently: CustomFuncValidator::resolveCustomFunctionFile()
resolves through realpath(), requires a .php extension, rejects stream wrappers,
and demands the result sit inside the plugin's custom-function directory. It is
applied at both include sites, so neither one trusts an existing file path alone.
Log re-execution runs a flow's action for real but skipped the custom-action
gate that save/update/delete/toggle already apply, letting manage_integrations
alone invoke admin-authored PHP. It now calls the same guard.
Other fixes in the same area:
- Scrape key and validation temp filename used md5(wp_rand()); wp_rand() returns
an int below mt_getrandmax(), leaving ~2^31 keys to enumerate against an
unauthenticated route. Both use random_bytes(16).
- The custom-function ABSPATH check matched the substring anywhere in the file,
which a comment satisfied. The directory's .htaccess/web.config guards do not
apply on nginx, where uploads/**/*.php is normally passed to PHP-FPM, so the
in-file guard is the only portable defence. It must now be a real guard that
exits, with nothing executable preceding it.
- Mail: only the array branch of validateAddresses() applied is_email(), so a
scalar recipient or header was interpolated from form input unchecked. Both
branches filter now, and the header display name is sanitized.
- Integration-owned routes (per-action and per-trigger Routes.php) authorize
credentials and call third-party APIs with no capability check of their own,
yet the route baseline accepted view_integrations. They are registered under a
write-only baseline via Route::defaultAccess(), set centrally in HookService so
no integration file changes.
- Trigger ids are filtered before being interpolated into a _test option name.
- Hash::secretKey() honours a BIT_INTEGRATIONS_SECRET_KEY constant, but only when
no key has been persisted yet: switching keys under existing ciphertext would
make every stored credential undecryptable.
- _bi_random_digit_num returned time(), fully predictable despite its name and a
hazard when mapped into a token or reference field. Use _bi_current_time for a
timestamp.
Outgoing webhooks could only resolve smart tags inside the query string,
so a REST endpoint like /v1/users/{id}/orders had no way to take its id
from trigger data.
The url path now resolves two notations:
- {name} mapped to a value through the new `pathParams` config
- ${field} smart tag written inline in the path
Resolution happens after wp_parse_url() and only on the path component,
so a dynamic value can never rewrite the scheme, host or port. Values are
raw url encoded, keeping them inside the single segment they were written
in (no injected segments, query or fragment). A mapped variable that
resolves empty aborts the request with a WP_Error and logs it, instead of
firing a malformed url; the Test Webhook run keeps such placeholders
literal since it has no trigger data. Unmapped placeholders are left
untouched, so existing flows behave as before.
Frontend adds a Path Variables tab that reads the variables straight out
of the url, keeps `pathParams` in sync with it and maps each one to a
trigger field. Inherited by every webhook based integration (IFTTT, N8n,
Pabbly, WPFusion, ...) since they share the same component.
Also fixes an undefined $response when the url was empty and a stray
trailing "?" when the webhook had no query parameters.
Only the Body tab exposed smart codes; the field picker in Params, Headers and Path Variables listed trigger fields only, even though the backend already resolves smart codes in those places through Common::replaceFieldWithValue(). Replace the MultiSelect in all three with the same native select the Body tab uses, listing FlowFormFieldsOptions plus SmartTagOptions, so every webhook field picker offers the same values. Smart codes stay Pro gated by SmartTagOptions itself. Both option groups read from recoil, so the formFields prop those three components no longer need is dropped along with it.
Panel from @bumaga/tabs renders null while another tab is active, so
<PathParams /> was unmounted and the effect creating `pathParams` only ran
once the user actually visited the tab. Writing {id} in the url and saving
from any other tab left the variable unmapped, and the request went out
with the literal brace in it instead of being skipped.
Move that effect into usePathParamsSync() and call it from
WebHooksIntegration, which is always mounted. An unmapped variable now
resolves to an empty value, which aborts and logs the run as intended.
The hook is disabled in info view so read only rendering never writes to
the config.
Also report every empty variable in the error instead of only the last one
found, and log both url failures as the WP_Error itself, matching the
shape the existing http error path already saves.
A remote answering 404 or 500 still hands back a decoded body, so a run was logged as a success unless the body happened to carry an `error` key. Test Webhook reported the same way, telling the user the endpoint worked when it had just refused the request. Read HttpHelper::$responseCode after the request and treat anything outside 200-299 as a failure. An unknown status is not a failure on its own, so runs that logged success before only flip when the remote really answered non-2xx. Failed runs now log the status next to the response, and Test Webhook reports it in both directions. The body's `error` property is read through an is_object() guard as well; on a plain text response the old check raised a php 8 warning.
The info page rendered each integration's authorization step read-only, so pointing a saved flow at another account meant walking the whole edit wizard. Enable just the connection dropdown there, including creating a new connection, and persist the pick through flow/update. Switch mode travels by context rather than a new prop, so the ~164 integration wrappers that forward isInfo stay untouched. The sub-forms write config after saving and the info page has no integration setter, so those writes are captured and merged into the flow update instead. Because the field mapping still targets the old account, a Next button appears after a switch and hands the user off to the edit wizard. Also fixed while wiring this up: - the create form no longer opens itself on the info page when an integration's connection list comes back empty - IntegInfo's SWR key is now scoped by flow id, so one flow's cached response can't back another flow's update
IntegrationInfo's type switch fell through to an empty fragment for any
action it had no case for, so 17 of the flow types in a real install
opened a blank page.
Two causes. Some stored type strings never matched their case label
("Tutor Lms", "Wishlist Member", "Fluent Crm", "Ant Apps"), so those get
alias cases. Others had an authorization component that was simply never
mapped: LearnDash, LifterLms, GamiPress, Affiliate, BuddyBoss, SliceWp
and CustomApi.
What remains unmapped — site-local actions with no credentials at all,
and anything this build ships no component for — now falls back to a
read-only card naming the integration and linking to its settings,
instead of rendering nothing.
CustomApi's Next button called setStep, which the info page never passes,
so it is hidden under isInfo the way the shared authorization step
already hides its own.
Settings and Documentation & support painted themselves #f8f9fd while every other route showed the white .route-wrp shell through, so moving between them flickered between two backgrounds. Give the whole app that canvas instead: the plugin root, the route shell and the table surfaces all read from $pg-canvas, which moves up to the global variables now that it is no longer a two-page concern. Table hover needed fixing to survive the change. It was #fbfcff, lighter than the canvas and so invisible, and the cells painted an opaque background over the row's hover anyway — only the cell under the cursor went transparent. Rows now hover a step darker than the canvas and the cells stay transparent, so the whole row lights up.
The stylesheet still carried most of its Bit Form builder ancestry:
block/sidebar/accordion/color-picker/date-picker rules whose markup this
plugin never renders, styles for react-date-picker and react-time-picker
which are not even dependencies, and a tag-assign set that the live tag
UI replaced. 8868 lines down to 6494; compiled CSS 121KB to 91KB.
Selectors were resolved through their & nesting and kept if the class
appears anywhere in the frontend, the plugin PHP or the Pro plugin, or if
it is applied at runtime rather than written in markup: WordPress core
classes, classes shipped by a dependency's own stylesheet (this is what
saves the msl-* multiselect rules), the -enter/-exit variants that
react-transition-group synthesises from a base name, and names built by
template literal such as btcd-opt-icn--${tone}. A block went only when
every class in its selector failed all of those.
Also removed 15 blocks duplicated verbatim (including a ~90-line
tutoriallink/ai-tool section), 47 commented-out declarations and 5 unused
variables. Same-selector blocks with differing bodies were left alone;
they are cascade layering, not duplication.
Verified by diffing the compiled CSS rule by rule: 330 rules gone, none
added, no declaration body altered.
react-multiple-select-dropdown-lite paints a background only on the
active and disabled control, so an idle dropdown was transparent — which
read as white until the pages moved onto the #f8f9fd canvas. Its border
is also #9c9c9c, darker than the #e2e2e2 every other input in the plugin
uses.
.btcd-paper-drpdwn already corrected both, but plenty of integrations
render the dropdown without that wrapper ("w-5", "mt-2 w-5",
"msl-wrp-options"), so match the library's own markup instead. It has to
out-specify a bare .msl: the library's border shorthand would overwrite a
border-color at equal specificity, and its stylesheet ships in a lazy
chunk, so it loads after this file and wins ties. Its
`.msl-disabled > .msl` grey stays more specific, so read-only fields keep
looking disabled.
Replace PHP functions that have WordPress equivalents across backend/,
following up on a security scan that flagged them.
Add Core/Util/FileSystem as the single owner of local file access:
- instance() always returns a WP_Filesystem_Base, falling back to
WP_Filesystem_Direct when WP_Filesystem() cannot get FTP/SSH
credentials. Every path handled is local, so callers no longer have to
branch on init failure, and a credential-less site no longer uploads an
empty file body.
- Removes the duplicate private getFilesystem() in CustomFuncValidator,
along with its two now-unreachable "Unable to initialize filesystem"
paths. DISALLOW_FILE_MODS is still gated by fileModsDisabled().
Function swaps:
- file_get_contents()/file_put_contents() -> FileSystem::read()/write()
(15 and 2 sites). Semantics are unchanged: read() returns string|false
like file_get_contents(), so no call site needed new error handling.
Route.php keeps file_get_contents('php://input') -- the filesystem API
cannot read PHP streams.
- json_encode() -> wp_json_encode() (11 sites).
- urlencode() -> rawurlencode() (8 sites). In Hash::encrypt() this is
safe both ways: base64 output never contains a space, the only
character where the two differ, so decrypt()'s urldecode() still reads
envelopes written by either version.
Also drop commented-out dead code from the touched files, including the
disused sideBarMenu() block in Config. phpcs directives, translator
comments and rationale comments are kept.
The $submenu global was written unconditionally, so a user without the manage_wp_integrations capability got submenu entries recorded against a parent menu that add_menu_page() had never registered. Move the write inside the capability check alongside it. Keep the direct $submenu assignment rather than switching to add_submenu_page(): add_menu_page() auto-creates a first submenu entry duplicating the parent, and overwriting the array is what replaces it. Note the reason inline.
"GPLv2 or later" is not a recognised SPDX identifier. Use GPL-2.0-or-later and add the License URI header, both of which WordPress.org expects. Declared license is unchanged.
get_post_meta() returns an empty array for a field with no stored value, so indexing [0] unconditionally emitted an "Undefined offset" warning and produced no value. Fall back to null instead.
…info Integrations saved before 2.10.0 keep their credentials inline in flow_details rather than pointing at a connection row, so the info page renders a connection dropdown with nothing behind it. Users read the blank state as lost credentials. Add a notice on the info page for flows with no connection_id: why the fields are empty, that the integration still runs unchanged, and how to move it onto a saved connection. It also introduces the connection manager itself — authorize an app once and reuse that connection across every integration for the same app. Scoped to the shared Authorization view and skipped for wp_plugin_check auth types, which have no credentials to explain.
The connection dropdown rendered empty and interactive while the saved connections were still being fetched, so the list looked like it had no entries until the request landed. - Authorization: start isLoading as true so the first paint reflects the in-flight fetch; clear it on the early-return paths (missing app slug, WP plugin check) so it cannot stick - Authorization: stop folding isLoading into isInfo — that hid the refresh button during load, leaving nowhere to surface a spinner - ConnectionAccountSelect: swap the refresh glyph for LoaderSm, disable the dropdown, and switch the placeholder to "Loading connections..." - Add a delayed fade-in helper text so fast loads never flash it, with a reduced-motion override
The link sat at the end of a long explainer as underlined text, so the one action the notice offers read as body copy rather than a control. - Fill it as a pill button: solid accent background, white label, 8px radius, inline-flex so the padding actually applies - Add hover darken + soft shadow, a scale(0.97) press state, and a focus-visible outline that stays legible on the tinted notice - Restyle the legacy amber variant to match instead of only recoloring the old link text - Skip the press scale under prefers-reduced-motion
Sender, Instasent, WP Post Creation and WP User Registration pointed at renamed or placeholder doc pages. zendeskSupport, weDocs and wsms were already referenced by their components but absent from the map, and Webba Booking Calendar, MainWP, WordPress, MoreConvert Wishlist, Secure Custom Fields, Heffl CRM and IvyForms had no tutorial link wiring at all, so none of them showed a Documentation link.
Connections store app_slug as the UI display name, while $authConfig carries a bare slug, so belongsToIntegration() reduced both to alphanumerics and compared them. A display name with a second brand word cannot reduce to the slug: "Brevo(Sendinblue)" never equals "sendinblue", so CredentialInjector skipped injection and Brevo's refresh endpoints answered "Requested parameter is empty" while a saved flow reported its fields as missing. $authConfig now takes an optional aliases list holding the stored names, and the check accepts slug-or-alias. Kept it an explicit list rather than a substring match, which would let one Zoho connection satisfy every zoho* controller — the cross-app leak the check exists to stop. Audited every action's slug against its frontend type; five integrations needed aliases: Brevo(Sendinblue), Kit(ConvertKit), Sarbacane(Mailify), Zoho Marketing Automation(Zoho Marketing Hub) and License Manager For WooCommerce.
The row-action menu (.btcd-menu-list / .btcd-m-a) unrolls from the edge its button sits on, and nothing else in the plugin did: the columns menu flipped between display none and block, and the bulk-actions menu and the AI popover were mounted and unmounted, so both simply appeared. All three now share menu-book-closed/menu-book-open. The panels stay mounted, hidden by visibility so they keep out of the tab order, and a class toggle drives the reveal, which also gives them a close animation. Each menu declares the width it opens to and the corner it rests at, and passes the side its trigger hinges on — right for the two table menus, left for the AI popover. The original unrolls by animating width, which relays out the panel and its contents on every frame. This reveals a full-size panel with a clip-path inset instead, so the work is a paint rather than a layout; the insets run negative so the drop shadow falls outside the clip and survives. Height stays out of the transition entirely — it cannot interpolate to auto, and collapsing it swallows the reveal on close, which is why the original writes a measured pixel height inline and leaves its own `height: 0` dead. Verified in headless Chrome against the built CSS: the panels report an identical width open and closed, so nothing reflows, and the captured frames show each one uncovering from its hinge with the shadow intact.
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
The plugin-not-installed error interpolates the plugin name into a translatable string with no translators comment, which WordPress Plugin Check flags. Add one, and align the $event param annotation with the rest of the block.
…y hooks Plugin Check infers the PrefixAllGlobals prefix list by scanning the plugin's own do_action/apply_filters names rather than using the plugin slug. Because this plugin fires third-party hooks from its Actions, the inferred list fills with names like 'academy/admin/course_complete' and never contains 'bit_integrations' -- so correctly prefixed functions and variables get flagged as unprefixed. - bitwpfi.php, ActionHook/Hooks.php, FallbackTrigger/Hooks.php and the failure notification template: ignore NonPrefixedFunctionFound/NonPrefixedVariableFound. All of these already carry the plugin slug as their prefix. - BitCrmActionHelper: ignore NonPrefixedHooknameFound for the file. Every do_action there fires one of Bit CRM's own bit_crm/* hooks on its behalf, because the service methods it calls do not fire them themselves. The names must match Bit CRM's namespace for its listeners to react. Verified with 'wp plugin check bit-integrations --checks=prefixing'. The only remaining warning is InvalidPrefixPassed on bitwpfi.php line 1, which reports a malformed entry in the scanner's own inferred list and cannot be suppressed.
Return the cached filesystem before declaring the global so the hot path skips the lookup, and collapse the assign-then-return blocks into single assignment returns. Behavior is unchanged; the global $wp_filesystem is still left untouched on the WP_Filesystem_Direct fallback path.
Assign the resolved filesystem in a branch and return it once at the end instead of returning from each path.
abdul-kaioum
approved these changes
Jul 30, 2026
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.
Description
Ships the 2.10.1 release of the free plugin: dynamic URL path variables and smart codes for outgoing webhooks, connection switching and clearer connection state on the integration info page, a round of security hardening around custom actions and AJAX route capabilities, and a styling pass that puts every page and table on a shared canvas.
Motivation & Context
2.10.0 introduced Connections but left gaps: an action's connection could not be changed after saving, integrations created before 2.10.0 showed an unexplained empty credentials panel, and renamed integrations failed to resolve their stored credentials. Outgoing webhooks could only target a static URL, which made REST-style endpoints (
/users/{id}/orders) unusable without a separate flow per record. Separately, a review of the custom-action path turned up an administrator gate that could be bypassed by spelling the action type differently, plus several weaker auth boundaries worth closing in the same pass.Related Links: (if applicable)
Type of Change
Key Changes
Webhooks
{name}placeholder anywhere in the outgoing webhook path becomes a mappable field filled from trigger dataConnections
Security
Flow::execute()normalized the action type differently, socustomAction/Custom Actionskipped themanage_optionscheck while still resolving toCustomActionController; both now shareFlow::normalizeActionType()CustomFuncValidator::resolveCustomFunctionFile()(realpath,.phpextension required, stream wrappers rejected, must live inside the plugin's custom-function directory), applied at both include sitesRoute::defaultAccess(), set centrally inHookServicevalidateAddresses()now filters the scalar branch too, and sanitizes header display namesrandom_bytes(16)instead ofmd5(wp_rand())ABSPATHguard must now be a real guard that exits, with nothing executable before it_testoption names;_bi_random_digit_numno longer returnstime()Frontend / Styling
app.scssDocs & Chores
Checklist
Changelog