Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,22 @@ tests/*
!tests/_data/
tests/_data/*
!tests/_data/late-ssr-callback.php
!tests/_support/
tests/_support/*
!tests/_support/Ajax_Die_Signal.php
!tests/wpunit/
tests/wpunit/*
!tests/wpunit/GeneratorContextSecurityTest.php
!tests/wpunit/PaypalPayNowTransactionTest.php
!tests/wpunit/TrustedPriceResolverTest.php
!tests/wpunit/PresetAccessControlTest.php
!tests/wpunit/FormMessageShortcodeSecurityTest.php
!tests/wpunit/SsrCallbackAllowlistTest.php
!tests/wpunit/SsrCallbackRegistryTest.php
!tests/wpunit/SsrCallbackMigrationBatchingTest.php
!tests/wpunit/AutoMigratorTest.php
!tests/wpunit/AutoMigratorTransactionTest.php
!tests/wpunit/SsrBlockedCallbackUsagesTest.php
!tests/wpunit/SsrCallbacksHandlerMigrationGuardTest.php
!tests/wpunit/MediaFieldPreviewEscapingTest.php
test-results/
AGENTS.md
Expand Down
2 changes: 1 addition & 1 deletion assets/build/admin/pages/jfb-settings.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('wp-i18n'), 'version' => '7c4b81a0908947caebc0');
<?php return array('dependencies' => array('wp-i18n'), 'version' => '4ed55da3016deca11753');
2 changes: 1 addition & 1 deletion assets/build/admin/pages/jfb-settings.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/build/editor/form.builder.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-components', 'wp-data', 'wp-editor', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-plugins'), 'version' => '4240dd1de0e6638b9e5d');
<?php return array('dependencies' => array('react', 'wp-components', 'wp-data', 'wp-editor', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-plugins'), 'version' => 'f883ad071db9f5c9fcab');
2 changes: 1 addition & 1 deletion assets/build/editor/form.builder.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/build/editor/package.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '19bbd01a0fccdbb0c11d');
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '5135b360fd67dac36e88');
2 changes: 1 addition & 1 deletion assets/build/editor/package.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions assets/src/admin/pages/jfb-settings/SettingsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import * as paymentGateways from './tabs/payments-gateways';
import * as options from './tabs/options';
import * as userJourney from './tabs/user-journey';
import * as phoneField from './tabs/phone-field';
import * as ssrCallbacks from './tabs/ssr-callbacks';
import SettingsSideBar from './sidebar/SettingsSideBar';

const { applyFilters, doAction } = wp.hooks;
Expand Down Expand Up @@ -84,6 +85,7 @@ const settingTabs = applyFilters( 'jet.fb.register.settings-page.tabs', [
phoneField,
mailchimp,
getResponse,
ssrCallbacks,
] );

const changeHash = hash => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
<template>
<div>
<div v-if="migrationInProgress" class="jfb-ssr-migration-wait">
<span class="jfb-ssr-migration-wait__spinner" aria-hidden="true"></span>
<div>
<strong>{{ __( 'Migration in progress…', 'jet-form-builder' ) }}</strong>
<p>{{ help.migrationInProgress }}</p>
</div>
</div>
<template v-else>
<cx-vui-component-wrapper
:label="loading.callbacks ? `${label.callbacks} (loading...)` : label.callbacks"
:description="help.callbacks"
:wrapper-css="[ 'equalwidth' ]"
>
<textarea
class="jfb-ssr-callbacks-textarea"
rows="10"
:disabled="isLoading"
:value="storage.callbacks"
@input="onInput( $event.target.value )"
></textarea>
<cx-vui-button
button-style="accent"
:disabled="isLoading || !hasUnsavedCallbacksChange"
@click="onSaveCallbacks"
>
<span slot="label">{{ __( 'Save', 'jet-form-builder' ) }}</span>
</cx-vui-button>
</cx-vui-component-wrapper>
<div v-if="hasRejected" class="jfb-ssr-callbacks-rejected">
<strong>{{ __( 'Not saved:', 'jet-form-builder' ) }}</strong>
<ul class="jfb-ssr-callbacks-rejected__list">
<li
v-for="name in rejectedNames"
:key="name"
>{{ name }} — {{ rejected[ name ] }}</li>
</ul>
</div>
<cx-vui-component-wrapper
v-if="blocked.length"
:label="`${label.blocked} (${blocked.length})`"
:description="help.blocked"
:wrapper-css="[ 'equalwidth' ]"
>
<ul class="jfb-ssr-blocked__list">
<li v-for="( usage, index ) in blocked" :key="index">
<span class="jfb-ssr-blocked__form">{{ usage.form_title || `#${usage.form_id}` }}</span>
<span class="jfb-ssr-blocked__meta">
({{ __( 'field', 'jet-form-builder' ) }} "{{ usage.field }}" → <code>{{ usage.name }}</code>)
</span>
<a :href="usage.edit_url" target="_blank" rel="noopener noreferrer">
{{ __( 'Edit form →', 'jet-form-builder' ) }}
</a>
</li>
</ul>
</cx-vui-component-wrapper>
</template>
</div>
</template>

<script>

import {
help,
label,
} from './source';

const { SaveTabByAjax, i18n } = window.JetFBMixins;

const MIGRATION_POLL_INTERVAL_MS = 8000;

export default {
name: 'ssr-callbacks-tab',
props: {
incoming: {
type: Object,
default: {},
},
},
mixins: [ SaveTabByAjax, i18n ],
data() {
return {
label, help,
storage: JSON.parse( JSON.stringify( this.incoming ) ),
// Tracks the last value confirmed by the server (initial load or a completed
// save), independent of `storage.callbacks`, which changes on every keystroke.
// Used only to gate the Save button — see `hasUnsavedCallbacksChange`.
savedCallbacks: 'string' === typeof this.incoming.callbacks ? this.incoming.callbacks : '',
blocked: Array.isArray( this.incoming.blocked ) ? [ ...this.incoming.blocked ] : [],
// While the one-time legacy-migration scan is still restoring previously used
// callback names (only possible on a large site, where it spans several
// `admin_init` requests), this tab is read-only: `import_trusted_callbacks()`
// only merges its results into the trusted list once the whole scan completes,
// and it does an unlocked read-merge-write of the same option a manual save
// here would race against (review finding, issues-tracker #20361 follow-up).
// The server enforces this independently in `on_get_request()`; this flag only
// drives the wait-state UI and is re-synced by `pollMigrationStatus()`.
migrationInProgress: !! this.incoming.migrationInProgress,
isLoading: false,
loading: {},
pendingSave: false,
rejected: {},
pollTimer: null,
};
},
computed: {
hasRejected() {
return Object.keys( this.rejected ).length > 0;
},
// `rejected` is a plain object, so a trailing "; " baked into each rendered item
// (rather than joined between items) left a stray "; " after the last — and only —
// entry whenever exactly one name was rejected (review finding, issues-tracker
// #20361 follow-up). Listing names separately lets the template only add the
// separator between items, not after the final one.
rejectedNames() {
return Object.keys( this.rejected );
},
// Gates the Save button: saving only happens on an explicit click now (no
// blur-triggered autosave), specifically so an accidental select-all-and-delete in
// the textarea can't wipe the whole trusted allowlist without the admin
// deliberately clicking Save on the emptied content (review finding, issues-tracker
// #20361 follow-up).
hasUnsavedCallbacksChange() {
return this.storage.callbacks !== this.savedCallbacks;
},
},
created() {
jfbEventBus.$on( 'request-state', this.onChangeState.bind( this ) );

if ( this.migrationInProgress ) {
this.schedulePoll();
}
},
beforeDestroy() {
this.clearPoll();
},
methods: {
getSavableData() {
return { callbacks: this.storage.callbacks };
},
getRequestOnSave() {
return {
data: this.getSavableData(),
};
},
onSaveDoneSuccess( response ) {
this.rejected = response?.data?.rejected || {};

if ( 'string' === typeof response?.data?.callbacks ) {
this.$set( this.storage, 'callbacks', response.data.callbacks );
this.savedCallbacks = response.data.callbacks;
}
},
onChangeState( { state, slug } ) {
if ( 'ssr-callbacks-tab' !== slug ) {
return;
}

if ( 'end' === state ) {
this.loading = {};
this.$set( this, 'isLoading', false );

if ( this.pendingSave ) {
this.pendingSave = false;
this.saveByAjax( this, this.$options.name );
}

return;
}

this.$set( this, 'isLoading', state === 'begin' );
},
onInput( value ) {
this.$set( this.storage, 'callbacks', value );
},
onSaveCallbacks() {
if ( ! this.hasUnsavedCallbacksChange || this.migrationInProgress ) {
return;
}

this.$set( this.loading, 'callbacks', true );

if ( this.isLoading ) {
this.pendingSave = true;
return;
}

this.saveByAjax( this, this.$options.name );
},
schedulePoll() {
this.clearPoll();
this.pollTimer = window.setTimeout( this.pollMigrationStatus, MIGRATION_POLL_INTERVAL_MS );
},
clearPoll() {
if ( this.pollTimer ) {
window.clearTimeout( this.pollTimer );
this.pollTimer = null;
}
},
// Reuses the same save endpoint as a read-only status check: omitting `callbacks`
// from the request body means `Ssr_Callbacks_Handler::on_get_request()` never
// attempts to write anything — it just reports whether the migration is still
// running. Once it reports finished, the page is reloaded rather than patching
// state in place: the migration can also have changed the "Forms Using Blocked
// Functions" list (`Ssr_Blocked_Callback_Usages`), which this endpoint doesn't
// return, so a full reload is the simplest way to guarantee everything on the page
// — not just the callbacks textarea — reflects what the migration produced.
pollMigrationStatus() {
jQuery.ajax( {
url: window.ajaxurl,
type: 'POST',
dataType: 'json',
data: {
action: 'jet_fb_save_tab__ssr-callbacks-tab',
_nonce: window?.JetFBPageConfigPackage?.nonce,
},
} ).done( ( response ) => {
if ( response?.data?.migrationInProgress ) {
this.schedulePoll();
return;
}

window.location.reload();
} ).fail( () => {
// Transient network hiccup — keep waiting rather than getting stuck.
this.schedulePoll();
} );
},
},
};

</script>

<style scoped>
.jfb-ssr-callbacks-textarea {
width: 100%;
font-family: monospace;
margin-bottom: 8px;
}

.jfb-ssr-callbacks-rejected {
color: #dc2626;
font-size: 14px;
padding: 0 20px;
margin: -10px 0 20px;
}

.jfb-ssr-callbacks-rejected__list {
margin: 4px 0 0;
padding: 0;
list-style: none;
}

.jfb-ssr-callbacks-rejected__list li {
margin-bottom: 2px;
padding-left: 14px;
position: relative;
}

.jfb-ssr-callbacks-rejected__list li::before {
content: '–';
position: absolute;
left: 0;
}

.jfb-ssr-blocked__list {
margin: 0;
padding: 0;
list-style: none;
}

.jfb-ssr-blocked__list li {
margin-bottom: 6px;
}

.jfb-ssr-blocked__form {
font-weight: 600;
}

.jfb-ssr-blocked__meta {
color: #646970;
margin: 0 6px;
}

.jfb-ssr-blocked__meta code {
color: #dc2626;
}

.jfb-ssr-migration-wait {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px 20px;
background: #f0f6fc;
border: 1px solid #c3dcf1;
border-radius: 4px;
}

.jfb-ssr-migration-wait__spinner {
flex: 0 0 auto;
width: 18px;
height: 18px;
margin-top: 2px;
border: 2px solid #c3dcf1;
border-top-color: #2271b1;
border-radius: 50%;
animation: jfb-ssr-migration-wait-spin 0.8s linear infinite;
}

@keyframes jfb-ssr-migration-wait-spin {
to {
transform: rotate( 360deg );
}
}
</style>
13 changes: 13 additions & 0 deletions assets/src/admin/pages/jfb-settings/tabs/ssr-callbacks/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import SsrCallbacksTab from './SsrCallbacksTab.vue';

const { __ } = wp.i18n;

const title = __( 'Allowed Server-Side Callbacks', 'jet-form-builder' );
const component = SsrCallbacksTab;
const displayButton = false;

export {
title,
component,
displayButton,
};
Loading
Loading