PluginProbe
JetFormBuilder — Dynamic Blocks Form Builder / 3.6.6
JetFormBuilder — Dynamic Blocks Form Builder v3.6.6
3.6.6.1 3.6.6 3.6.5.4 3.6.5.3 3.6.5.2 3.6.5.1 3.6.5 3.6.4.2 3.6.4.1 3.6.4 3.6.3.1 3.6.3 3.6.2.2 3.6.2.1 3.6.2 3.6.1.1 3.6.1 3.6.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 All 134 releases
jetformbuilder / includes / migrations / versions / version-3-6-5-3.php

version-3-6-5-3.php in JetFormBuilder — Dynamic Blocks Form Builder 3.6.6, at includes/migrations/versions/version-3-6-5-3.php

348 lines 15.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 namespace Jet_Form_Builder\Migrations\Versions;
5
6 use JFB_Modules\Validation\Advanced_Rules\Server_Side_Rule;
7 use JFB_Modules\Validation\Advanced_Rules\Ssr_Callback_Registry;
8 use JFB_Modules\Validation\Ssr\Ssr_Blocked_Callback_Usages;
9 use JFB_Modules\Validation\Ssr\Ssr_Registry_Migration_Notice;
10
11 // If this file is called directly, abort.
12 if ( ! defined( 'WPINC' ) ) {
13 die;
14 }
15
16 /**
17 * One-time, bounded import of static custom "Server-Side callback" function names from
18 * existing forms and reusable blocks into the new global `Ssr_Callback_Registry`
19 * (issues-tracker #20361). This replaces the retired per-form allowlist
20 * (`Ssr_Callback_Allowlist`) as the SSR trust source.
21 *
22 * Only names that, at the time this migration runs: are syntactically valid, are not on
23 * the `Server_Side_Rule::NOT_ALLOWED` denylist, are not already a built-in callback, and
24 * resolve to an existing PHP function are imported. This is a live `post_content` scan,
25 * not a read of the old per-form meta — the old meta is not treated as authoritative.
26 *
27 * Imported names are merged directly into the trusted, live registry
28 * (`Ssr_Callback_Registry::import_trusted_callbacks()`) — not queued for manual review.
29 * An earlier revision of this migration queued them as "pending" and required a
30 * `manage_options` admin to explicitly approve each one before any of them became
31 * callable again; in practice this broke backward compatibility for sites where the admin
32 * never saw (or acted on) the resulting notice — every form relying on a custom callback
33 * that predated the update silently stopped validating. The denylist is therefore now the
34 * primary safety barrier for this restore path, and was substantially expanded to match
35 * (see docs/vulnerability/20361/not-allow-list.md for the audit). A brand-new custom
36 * callback name — one that was never in a form before this migration ran — still requires
37 * an admin to add it manually in Settings; this migration only restores names that were
38 * already relied upon.
39 *
40 * A denylisted (`Server_Side_Rule::NOT_ALLOWED`) name is never imported into the registry —
41 * it can never be approved — but every (form, field, name) triple encountered is still
42 * recorded via `Ssr_Blocked_Callback_Usages` so the settings tab can list exactly which
43 * existing forms still fail SSR validation, instead of leaving the admin to discover that
44 * only by noticing a live form is broken.
45 *
46 * `Ssr_Registry_Migration_Notice::mark_imported()` is always called once the scan
47 * completes (even if nothing was restored), so an admin who sees the notice gets both
48 * numbers in one place: how many callback names were restored automatically, and how many
49 * forms still need a manual fix because their callback is permanently denylisted.
50 *
51 * @since 3.6.5.3
52 */
53 class Version_3_6_5_3 extends Base_Migration {
54
55 const BATCH_SIZE = 200;
56
57 const PROGRESS_OPTION = 'jet_fb_ssr_registry_migration_progress';
58 const TIME_BUDGET_SECONDS = 25;
59
60 /**
61 * Bounded to `TIME_BUDGET_SECONDS` per call so a large forms table cannot run past the
62 * request's `max_execution_time` inside `Auto_Migrator`'s DB transaction. When the
63 * budget is exceeded before the table is fully scanned, progress (a resume cursor plus
64 * this request's summary counters) is persisted to `PROGRESS_OPTION` and this method
65 * throws so `Base_Migration::install()` never marks the migration installed; the next
66 * capable admin's `admin_init` request resumes from the persisted cursor. Uses its own
67 * progress option (distinct from `Ssr_Callback_Allowlist::REBUILD_PROGRESS_OPTION`)
68 * since both migrations may need to resume independently in the same request.
69 *
70 * Trusted-callback and blocked-usage writes happen once per completed batch (up to
71 * `BATCH_SIZE` forms) — not once for the entire scan, and not once per form either.
72 * `import_trusted_callbacks()` is additive (it merges into whatever is already trusted)
73 * and `replace_for_form()` touches only one form's own entries, so both are safe to call
74 * repeatedly; batching the calls at this granularity is a deliberate middle ground:
75 * - Once per form would mean up to `BATCH_SIZE` extra `update_option()` calls per batch,
76 * which is wasteful for the common case (most sites have at most a handful of forms
77 * using a custom SSR callback at all) to guard against a rare one (a site with
78 * hundreds of forms each using a different one).
79 * - Once per entire scan (the original version of this migration) left every form
80 * untrusted — and any form an admin fixed mid-scan un-fixed again — for however many
81 * resumed requests a large site's scan needed, not just the current batch.
82 * Writing once per batch means the "stale" window for both problems is capped at one
83 * batch (at most `BATCH_SIZE` forms) instead of the whole scan, while a site with fewer
84 * forms than `BATCH_SIZE` — the common case — finishes in a single batch and gets
85 * exactly the same one-write behavior as before (both issues-tracker #20361 follow-up
86 * findings from review).
87 *
88 * The scan's `post_status` filter includes `trash`: `Ssr_Blocked_Callback_Usages`
89 * deliberately keeps a trashed form's entry alive (see `Module::
90 * remove_blocked_callback_usages_for_deleted_form()`'s docblock — it only clears on
91 * `delete_post`, since a trashed form can still be restored), but a form already in
92 * Trash at the moment this one-time scan runs would otherwise never be scanned at all —
93 * `wp_trash_post()` does not fire `save_post`, so nothing else re-scans it either, until
94 * it is restored and resaved. Without `trash` here, restoring such a form later would
95 * silently start failing its SSR rule (denylisted callback, never recorded as blocked)
96 * or lose backward compatibility for a legitimate custom callback (never imported), with
97 * no migration notice and no indication anything needed attention (review finding,
98 * issues-tracker #20361 follow-up).
99 *
100 * @throws \Jet_Form_Builder\Migrations\Migration_Incomplete_Exception When the time
101 * budget is exceeded before the scan completes.
102 */
103 public function up( \wpdb $wpdb ) {
104 $progress = get_option( self::PROGRESS_OPTION, array() );
105 $last_id = is_array( $progress ) && isset( $progress['last_id'] ) ? (int) $progress['last_id'] : 0;
106
107 $imported_total = is_array( $progress ) && isset( $progress['imported'] ) && is_array( $progress['imported'] )
108 ? $progress['imported']
109 : array();
110 $blocked_form_ids_total = is_array( $progress ) && isset( $progress['blocked_form_ids'] ) && is_array( $progress['blocked_form_ids'] )
111 ? $progress['blocked_form_ids']
112 : array();
113
114 $started_at = microtime( true );
115
116 while ( true ) {
117 $form_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
118 $wpdb->prepare(
119 "SELECT ID FROM {$wpdb->posts}
120 WHERE post_type = %s
121 AND post_status IN ('publish', 'draft', 'pending', 'private', 'future', 'trash')
122 AND ID > %d
123 ORDER BY ID ASC
124 LIMIT %d",
125 'jet-form-builder',
126 $last_id,
127 self::BATCH_SIZE
128 )
129 );
130
131 if ( empty( $form_ids ) ) {
132 break;
133 }
134
135 $is_last_page = count( $form_ids ) < self::BATCH_SIZE;
136
137 // Batch-local accumulators, flushed to the database once this batch finishes (or
138 // once the time budget is hit, whichever comes first) — see the docblock above
139 // for why per-batch, rather than per-form or per-scan.
140 $batch_callbacks = array();
141 $batch_blocked = array();
142
143 foreach ( $form_ids as $form_id ) {
144 $form_id = (int) $form_id;
145 $last_id = max( $last_id, $form_id );
146 $post = get_post( $form_id );
147
148 if ( ! $post instanceof \WP_Post ) {
149 continue;
150 }
151
152 foreach ( $this->collect_static_callbacks( $post->post_content ) as $name ) {
153 $batch_callbacks[ $name ] = true;
154 }
155
156 $batch_blocked[ $form_id ] = Ssr_Blocked_Callback_Usages::collect_from_content( $post->post_content );
157
158 // Checked per form (not only once per batch) so even the last, short page —
159 // which never hits the `$is_last_page` break below since there is no further
160 // page to fetch — is still time-boxed. A page short enough to end the scan can
161 // still contain forms with very large post_content (many reusable blocks), and
162 // without this the last page ran fully unbounded regardless of size.
163 if ( $this->time_budget_exceeded( $started_at ) ) {
164 $this->flush_batch( $batch_callbacks, $batch_blocked, $imported_total, $blocked_form_ids_total );
165 $this->persist_progress_and_yield( $last_id, $imported_total, $blocked_form_ids_total );
166 }
167 }
168
169 $this->flush_batch( $batch_callbacks, $batch_blocked, $imported_total, $blocked_form_ids_total );
170
171 if ( $is_last_page ) {
172 break;
173 }
174
175 if ( $this->time_budget_exceeded( $started_at ) ) {
176 $this->persist_progress_and_yield( $last_id, $imported_total, $blocked_form_ids_total );
177 }
178 }
179
180 delete_option( self::PROGRESS_OPTION );
181
182 Ssr_Registry_Migration_Notice::mark_imported(
183 array_values( array_keys( $imported_total ) ),
184 count( $blocked_form_ids_total )
185 );
186 }
187
188 /**
189 * Writes one batch's (or partial batch's, if the time budget was hit mid-batch)
190 * findings to the database in one call each, then merges the outcome into this
191 * request's running totals (used only to build the final migration notice — see
192 * `persist_progress_and_yield()`'s docblock on why losing those totals to an unrelated
193 * failure is harmless).
194 *
195 * @since 3.6.5.3
196 *
197 * @param array<string, bool> $batch_callbacks Callback
198 * names found in this batch so far.
199 * @param array<int, array{field: string, name: string}[]> $batch_blocked This
200 * batch's forms mapped to their blocked usages (possibly empty).
201 * @param array<string, bool> $imported_total By
202 * reference; running total across the whole scan.
203 * @param array<int, bool> $blocked_form_ids_total By
204 * reference; running total across the whole scan.
205 */
206 private function flush_batch(
207 array &$batch_callbacks,
208 array &$batch_blocked,
209 array &$imported_total,
210 array &$blocked_form_ids_total
211 ) {
212 if ( ! empty( $batch_callbacks ) ) {
213 $result = Ssr_Callback_Registry::import_trusted_callbacks( array_values( array_keys( $batch_callbacks ) ) );
214
215 foreach ( $result['imported'] as $name ) {
216 $imported_total[ $name ] = true;
217 }
218 }
219
220 foreach ( $batch_blocked as $form_id => $form_blocked ) {
221 // Every form in the batch is visited exactly once, so this form's current set
222 // of blocked usages (possibly empty) fully replaces whatever was recorded for
223 // it before — including nothing, if a prior migration run or a later manual fix
224 // already cleared it. This is the same call `Module::
225 // refresh_blocked_callback_usages()` makes on every form save, so the two stay
226 // consistent with each other.
227 Ssr_Blocked_Callback_Usages::replace_for_form( $form_id, $form_blocked );
228
229 if ( ! empty( $form_blocked ) ) {
230 $blocked_form_ids_total[ $form_id ] = true;
231 }
232 }
233
234 $batch_callbacks = array();
235 $batch_blocked = array();
236 }
237
238 public function down( \wpdb $wpdb ) {
239 delete_option( Ssr_Callback_Registry::OPTION_KEY );
240 delete_option( self::PROGRESS_OPTION );
241 Ssr_Registry_Migration_Notice::clear();
242 Ssr_Blocked_Callback_Usages::clear();
243 }
244
245 /**
246 * Whether the migration has been running long enough that it should persist progress
247 * and yield the request instead of continuing. The budget is filterable so tests can
248 * force an early "incomplete" result without waiting on wall-clock time.
249 *
250 * @param float $started_at `microtime( true )` value captured when the scan started.
251 */
252 protected function time_budget_exceeded( float $started_at ): bool {
253 $budget = (float) apply_filters(
254 'jet-form-builder/ssr-registry-migration/time-budget',
255 self::TIME_BUDGET_SECONDS
256 );
257
258 return ( microtime( true ) - $started_at ) >= $budget;
259 }
260
261 /**
262 * Writes the resume cursor and throws to yield the request. Write the cursor FIRST, then
263 * commit: with `autocommit = 0`, an explicit `COMMIT` ends the current transaction and
264 * the next write opens a new implicit one. Committing before writing the cursor would
265 * leave the cursor itself in that fresh, uncommitted transaction — exactly what
266 * `Auto_Migrator::run()`'s catch-and-rollback of the exception below would then discard,
267 * defeating the whole point of committing early.
268 *
269 * `$imported`/`$blocked_form_ids` are this request's running summary counters only, used
270 * to build the final migration notice once the whole scan completes — the trusted
271 * registry and blocked-usages entries themselves are already durably written once per
272 * completed batch via `flush_batch()` (see `up()`'s docblock for why per-batch), so
273 * losing this summary to an unrelated failure would at worst under-report the notice's
274 * counts, never lose data. Note this method is only ever called either right after a
275 * `flush_batch()` (mid-batch time-out) or between batches (batch boundary time-out), so
276 * whatever this batch already collected has always just been flushed by the time this
277 * runs.
278 *
279 * @since 3.6.5.3
280 *
281 * @param int $last_id
282 * @param array<string, bool> $imported Callback names imported so far, keyed by
283 * name for dedup.
284 * @param array<int, bool> $blocked_form_ids Form IDs with a blocked usage found so
285 * far, keyed by ID for dedup.
286 *
287 * @throws \Jet_Form_Builder\Migrations\Migration_Incomplete_Exception Always.
288 */
289 private function persist_progress_and_yield( int $last_id, array $imported, array $blocked_form_ids ) {
290 update_option(
291 self::PROGRESS_OPTION,
292 array(
293 'last_id' => $last_id,
294 'imported' => $imported,
295 'blocked_form_ids' => $blocked_form_ids,
296 ),
297 false
298 );
299
300 \Jet_Form_Builder\Db_Queries\Execution_Builder::instance()->transaction_commit();
301
302 throw new \Jet_Form_Builder\Migrations\Migration_Incomplete_Exception( 'SSR registry migration exceeded its time budget; will resume on the next request.' );
303 }
304
305 /**
306 * Walks the form's blocks (including reusable blocks) collecting statically
307 * configured SSR callback names, skipping anything that could not pass runtime
308 * validation anyway.
309 *
310 * @param string $post_content
311 *
312 * @return string[]
313 */
314 private function collect_static_callbacks( string $post_content ): array {
315 if ( '' === trim( $post_content ) ) {
316 return array();
317 }
318
319 $found = array();
320 $blocks = \Jet_Form_Builder\Blocks\Block_Helper::get_blocks_from_content( $post_content );
321
322 foreach ( \Jet_Form_Builder\Blocks\Block_Helper::generate_blocks_in_space( $blocks ) as $block ) {
323 $rules = $block['attrs']['validation']['rules'] ?? array();
324
325 foreach ( $rules as $rule ) {
326 if ( 'ssr' !== ( $rule['type'] ?? '' ) ) {
327 continue;
328 }
329
330 $name = Server_Side_Rule::sanitize_callback_name( (string) ( $rule['value'] ?? '' ) );
331
332 if (
333 '' === $name ||
334 in_array( $name, Server_Side_Rule::NOT_ALLOWED, true ) ||
335 Server_Side_Rule::is_builtin_callback( $name ) ||
336 ! function_exists( $name )
337 ) {
338 continue;
339 }
340
341 $found[ $name ] = true;
342 }
343 }
344
345 return array_keys( $found );
346 }
347 }
348