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-6.php

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

793 lines 38.1 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 Jet_Form_Builder\Blocks\Block_Helper;
7 use JFB_Modules\Form_Record\Models\Record_Field_Model;
8 use JFB_Modules\Form_Record\Models\Record_Model;
9 use JFB_Modules\Media_Cleanup\Media_Ownership_Migration_Notice;
10
11 // If this file is called directly, abort.
12 if ( ! defined( 'WPINC' ) ) {
13 die;
14 }
15
16 /**
17 * Backfills `_jfb_uploaded_by_form` / `_jfb_uploaded_by_user` on attachments that were
18 * legitimately saved through a Media field's Insert Post action *before* the fix in
19 * issues-tracker #20547 started recording those markers at upload time
20 * (`Uploaded_File::add_attachment()`).
21 *
22 * Without this backfill, every such pre-existing attachment is indistinguishable from an
23 * attacker-supplied ID the moment a site upgrades: `Media_Field_Parser::
24 * sanitize_submitted_attachment_ids()` and `Media_Cleanup::delete_attachments()` both now
25 * require the marker, so the very first time an affected post's form is re-submitted
26 * without changing that field, the attachment silently drops out of the post meta (the
27 * field "forgets" it) even though the file itself is never deleted (see both methods'
28 * docblocks - an unmarked ID is simply skipped, never treated as a deletion candidate).
29 * That is a real, if non-destructive, regression for any site that already had this
30 * feature in production; this migration closes the gap for the common case instead of
31 * leaving every upgrading site to discover it one edited post at a time.
32 *
33 * Three-phase scan, each phase batched and time-boxed independently (same resumable
34 * pattern as `Version_3_6_5_3`, which this migration otherwise mirrors):
35 *
36 * Phase 1 - find which post meta keys can even hold plugin-uploaded attachment IDs.
37 * Scans every `jet-form-builder` post's `post_content` for `jet-forms/media-field`
38 * blocks with `insert_attachment: true`, resolves each field's `name` through that same
39 * form's Insert Post action `fields_map` (`_jf_actions` meta) to the post meta key it is
40 * actually saved under, and collects the unique set of those keys across every form. A
41 * key is only collected when a form still resolves to it; a meta key some now-deleted or
42 * reconfigured form once used is not guessed at.
43 *
44 * Phase 2 - using that key set, scans every post's meta for values under those keys,
45 * normalizes them the same way `Media_Cleanup::normalize_attachment_ids()` does (single
46 * ID, comma string, list, or `both`-shape array), and backfills each resulting attachment
47 * ID as described below.
48 *
49 * Phase 3 - independently of post meta entirely (but NOT independently of forms - see
50 * `run_record_backfill_phase()`'s docblock), scans the plugin's own Form Record storage
51 * (`{$wpdb->prefix}jet_fb_records_fields` joined to `{$wpdb->prefix}jet_fb_records`) for
52 * saved attachment values whose `(form_id, field_name)` matches an `insert_attachment`
53 * Media field Phase 1 actually found on that record's own form, and backfills those the
54 * same way. This covers a case Phase 1/2 cannot: `Media_Field_Parser::get_response()` has,
55 * since issues-tracker #4422 (long
56 * before #20547), deliberately let a Media field's *current* value be populated by
57 * `Preset_Source_Form_Record` from a previously saved Form Record (via
58 * `record_query_var` in the URL) rather than from that request's own post meta - the
59 * record's `field_value` is a wholly separate value the Insert Post meta-key scan in
60 * Phase 1/2 never touches. Without this phase, a site that used that preset flow before
61 * #20547's fix would have the exact same field "forgets" its attachment regression
62 * Phase 1/2 exists to prevent, just sourced from records instead of post meta - silently
63 * breaking the pre-existing #4422 behavior this branch's own `sanitize_submitted_attachment_ids()`
64 * docblock says it must preserve.
65 *
66 * For each attachment ID phase 2/3 resolve that is a real `attachment` post NOT already
67 * carrying `_jfb_uploaded_by_form`:
68 * - sets `_jfb_uploaded_by_form = 1`;
69 * - sets `_jfb_uploaded_by_user` from that attachment's own `post_author`, but ONLY when
70 * `post_author` is a real (non-zero) user - see `maybe_backfill_attachment()`'s docblock
71 * for why a zero `post_author` is left unbackfilled rather than written as an explicit
72 * `0` "anonymous uploader" marker.
73 *
74 * Backfilling `_jfb_uploaded_by_user` from a non-zero `post_author` (rather than leaving it
75 * unset, which `Media_Cleanup::is_owned_by_current_actor()` already treats as a compatible
76 * legacy attachment) is deliberate, not redundant: `Uploaded_File::add_attachment()` never
77 * passes an explicit `post_author` to `wp_insert_attachment()`, so WordPress core itself
78 * fills it in from `get_current_user_id()` at upload time - the exact same source the fixed
79 * code now reads into `_jfb_uploaded_by_user` going forward. For a genuinely plugin-uploaded
80 * legacy attachment, `post_author` is therefore already the same value the marker would
81 * hold had it existed from the start, not merely a guess.
82 *
83 * This IS, however, only a plausible guess, not an observed fact - unlike a live upload's
84 * uploader ID - because `post_author` can be legitimately reassigned after upload for
85 * reasons unrelated to who actually uploaded the file (editorial cleanup, bulk
86 * ownership-transfer tools, or simply reflecting a different user's later edit of the
87 * attachment's own post record). `maybe_backfill_attachment()` therefore also writes
88 * `_jfb_uploaded_by_user_heuristic = 1` alongside a non-zero `_jfb_uploaded_by_user`, and
89 * `is_owned_by_current_actor()` reads that marker to allow its verified-identity
90 * `$owning_post_id` exception for a heuristic value too (the same exception the anonymous-
91 * uploader case already uses), instead of binding the attachment with absolute, no-exception
92 * strictness to whichever user happened to be its `post_author` at migration time. Without
93 * that exception, a mismatch between the backfilled guess and the post's actual current
94 * editor would make the attachment permanently unmanageable by anyone but that one guessed
95 * user (or a webhook) - a real regression from the pre-migration state, where an unmarked
96 * attachment was freely manageable by any `edit_post`-authorized editor of its post; see
97 * `is_owned_by_current_actor()`'s own docblock for the exact mechanics.
98 *
99 * PHASE 2 IS A HEURISTIC, NOT A GUARANTEED-EXACT MATCH, AND THIS IS LOAD-BEARING FOR
100 * DELETION DECISIONS, not just cosmetic: Phase 2 scans `wp_postmeta` for VALUES stored
101 * under a given meta KEY NAME (e.g. `gallery`), because that is the only thing a post meta
102 * row can be matched on - it carries no record of which form or action actually wrote it.
103 * If a different form's Insert Post action (or a completely unrelated meta box, plugin, or
104 * direct SQL/import) happens to reuse the same meta key name for a different purpose, or a
105 * post of a different type reuses it coincidentally, Phase 2 cannot tell that apart from a
106 * genuine legacy attachment ID and will grant it `_jfb_uploaded_by_form`. Unlike Phase 3
107 * (which - see `run_record_backfill_phase()` - CAN and does verify the `(form_id,
108 * field_name)` pair a value actually came from, because `records_fields` records that
109 * unambiguously), Phase 2 has no equivalent join available: `wp_postmeta` rows do not
110 * self-identify their writer. This is an accepted, structural limitation of scanning
111 * existing WordPress data after the fact, not an oversight fixable by tightening the query
112 * further within Phase 2 itself.
113 *
114 * The blast radius of a Phase 2 false positive is bounded and strictly less severe than the
115 * vulnerability this migration exists to smooth the upgrade for: it only ever grants an
116 * attachment the SAME trust level a genuinely plugin-uploaded one has post-fix (marker
117 * present, uploader-bound deletion) - never the pre-fix, unrestricted trust the original
118 * vulnerability relied on. It cannot, by itself, cause a deletion; `delete_attachments()`
119 * still requires the field to observe that ID being removed from the post meta on some
120 * later request, AND to pass the post-authorization and ownership checks in
121 * `delete_attachments()`/`is_owned_by_current_actor()`, before it is ever a deletion
122 * candidate. A false-positive marker's worst realistic outcome is therefore the same
123 * bounded risk a genuine attachment already carries post-fix, not a new deletion primitive.
124 *
125 * Progress across ALL THREE phases is persisted in a single `PROGRESS_OPTION` so a large
126 * site's scan can span many `admin_init` requests without losing its place, exactly like
127 * `Version_3_6_5_3`. `Media_Ownership_Migration_Notice::mark_backfilled()` is always
128 * called once every phase completes (even if nothing was found), so an admin who never had
129 * this feature in production sees no notice at all, and one who did gets a single, honest
130 * count of how many attachments were restored across both sources.
131 *
132 * KNOWN WINDOW: `Auto_Migrator` (see its own docblock) deliberately has no activation-hook
133 * or deploy-time trigger - by design, since `register_activation_hook` does not fire on a
134 * plugin file update, only on explicit (re)activation - so this migration only runs on an
135 * `admin_init` request from a `manage_options` user, and a large site's scan can take
136 * several such requests to finish (`Auto_Migrator::is_migration_in_progress()` reports this
137 * state, though nothing in this fix currently reads it). Public form submission is not
138 * gated on migration completion: between the moment a site's plugin files are updated and
139 * the moment this migration finishes running, `Media_Field_Parser::
140 * sanitize_submitted_attachment_ids()` already enforces the fixed, strict marker check
141 * against attachments this migration has not backfilled yet, so a legacy attachment can
142 * still transiently "drop out" of a field during that window even though it would have
143 * been correctly recognized moments later. This is bounded and non-destructive by the same
144 * fail-closed design as the rest of this fix - it cannot cause a wrongful deletion, only a
145 * possibly-premature "forgets the value" outcome identical to what Phase 1/2/3 exist to
146 * minimize, just not yet eliminated for that specific attachment at that specific moment -
147 * and it closes itself out automatically as soon as an admin's next `wp-admin` page load
148 * lets the migration run (or resume). Fully eliminating this window would require either a
149 * synchronous, deploy-time migration trigger or gating public form submission on migration
150 * completion, both a materially larger and riskier change than this fix's scope; not
151 * addressed here.
152 *
153 * @since 3.6.6
154 */
155 class Version_3_6_6 extends Base_Migration {
156
157 const FORM_BATCH_SIZE = 200;
158 const ATTACHMENT_BATCH_SIZE = 500;
159 const RECORD_FIELD_BATCH_SIZE = 500;
160
161 const PROGRESS_OPTION = 'jet_fb_media_ownership_migration_progress';
162 const TIME_BUDGET_SECONDS = 25;
163
164 const PHASE_COLLECT_KEYS = 'collect_keys';
165 const PHASE_BACKFILL = 'backfill';
166 const PHASE_RECORD_BACKFILL = 'record_backfill';
167
168 /**
169 * @throws \Jet_Form_Builder\Migrations\Migration_Incomplete_Exception When the time
170 * budget is exceeded before all phases complete.
171 */
172 public function up( \wpdb $wpdb ) {
173 $progress = $this->load_progress();
174 $started_at = microtime( true );
175
176 if ( self::PHASE_COLLECT_KEYS === $progress['phase'] ) {
177 $this->run_collect_keys_phase( $wpdb, $progress, $started_at );
178 }
179
180 // Each phase method above only returns (instead of yielding via exception) once it
181 // has fully advanced `$progress['phase']` past itself, so reaching each line below
182 // always means every phase before it is done for this call.
183 if ( self::PHASE_BACKFILL === $progress['phase'] ) {
184 $this->run_backfill_phase( $wpdb, $progress, $started_at );
185 }
186
187 $this->run_record_backfill_phase( $wpdb, $progress, $started_at );
188
189 delete_option( self::PROGRESS_OPTION );
190
191 Media_Ownership_Migration_Notice::mark_backfilled( $progress['backfilled_count'] );
192 }
193
194 public function down( \wpdb $wpdb ) {
195 delete_option( self::PROGRESS_OPTION );
196 Media_Ownership_Migration_Notice::clear();
197
198 // Deliberately does not strip `_jfb_uploaded_by_form` / `_jfb_uploaded_by_user`
199 // from attachments this migration backfilled: by the time anyone runs `uninstall()`
200 // on a migration, those markers may already be load-bearing for real form activity
201 // that happened since (new uploads on the same meta keys, cleanup decisions already
202 // made using them). Reverting the schema/option state is safe; reverting live
203 // ownership data an admin may already be relying on is not.
204 }
205
206 /**
207 * @param \wpdb $wpdb
208 * @param array $progress By reference; mutated in place and persisted on yield.
209 * @param float $started_at
210 *
211 * @throws \Jet_Form_Builder\Migrations\Migration_Incomplete_Exception
212 */
213 private function run_collect_keys_phase( \wpdb $wpdb, array &$progress, float $started_at ) {
214 while ( true ) {
215 $form_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
216 $wpdb->prepare(
217 "SELECT ID FROM {$wpdb->posts}
218 WHERE post_type = %s
219 AND post_status IN ('publish', 'draft', 'pending', 'private', 'future', 'trash')
220 AND ID > %d
221 ORDER BY ID ASC
222 LIMIT %d",
223 'jet-form-builder',
224 $progress['last_form_id'],
225 self::FORM_BATCH_SIZE
226 )
227 );
228
229 if ( empty( $form_ids ) ) {
230 break;
231 }
232
233 $is_last_page = count( $form_ids ) < self::FORM_BATCH_SIZE;
234
235 foreach ( $form_ids as $form_id ) {
236 $form_id = (int) $form_id;
237 $progress['last_form_id'] = max( $progress['last_form_id'], $form_id );
238
239 $collected = $this->collect_meta_keys_for_form( $form_id );
240
241 foreach ( $collected['meta_keys'] as $meta_key ) {
242 $progress['meta_keys'][ $meta_key ] = true;
243 }
244
245 if ( ! empty( $collected['field_names'] ) ) {
246 $progress['field_names_by_form'][ $form_id ] = $collected['field_names'];
247 }
248
249 if ( $this->time_budget_exceeded( $started_at ) ) {
250 $this->persist_progress_and_yield( $progress );
251 }
252 }
253
254 if ( $is_last_page ) {
255 break;
256 }
257
258 if ( $this->time_budget_exceeded( $started_at ) ) {
259 $this->persist_progress_and_yield( $progress );
260 }
261 }
262
263 $progress['phase'] = self::PHASE_BACKFILL;
264 $this->persist_progress( $progress );
265 }
266
267 /**
268 * A form's Media fields only matter here if `insert_attachment` is on - that is the
269 * only setting that ever caused `Uploaded_File::add_attachment()` to run and create a
270 * real WP attachment for this field in the first place (see that method's docblock);
271 * without it, whatever the field saved was never an attachment ID to begin with, so
272 * there is nothing here for `_jfb_uploaded_by_form` to legitimately describe.
273 * `delete_uploaded_attachment` is NOT required to collect the key: an attachment
274 * deserves the "this came from a form" marker regardless of whether THIS site ever
275 * had cleanup-on-removal enabled, since a later edit turning that setting on should not
276 * need every attachment re-uploaded to become eligible for it.
277 *
278 * Returns BOTH the resolved post meta keys (for Phase 2) and the raw `insert_attachment`
279 * field names (for Phase 3, keyed by this `$form_id` - see `run_record_backfill_phase()`'s
280 * docblock for why Phase 3 needs a per-form field name set rather than a global one: a
281 * Form Record row only proves the field belongs to a real `insert_attachment` field of
282 * the record's OWN form, never of some other form that happens to reuse the same field
283 * name).
284 *
285 * @param int $form_id
286 *
287 * @return array{meta_keys: string[], field_names: string[]}
288 */
289 private function collect_meta_keys_for_form( int $form_id ): array {
290 $empty = array(
291 'meta_keys' => array(),
292 'field_names' => array(),
293 );
294
295 $post = get_post( $form_id );
296
297 if ( ! $post instanceof \WP_Post || '' === trim( $post->post_content ) ) {
298 return $empty;
299 }
300
301 $field_names = array();
302
303 $blocks = Block_Helper::get_blocks_from_content( $post->post_content );
304
305 foreach ( Block_Helper::generate_blocks_in_space( $blocks ) as $block ) {
306 if ( 'jet-forms/media-field' !== ( $block['blockName'] ?? '' ) ) {
307 continue;
308 }
309
310 $attrs = $block['attrs'] ?? array();
311
312 if ( empty( $attrs['insert_attachment'] ) || empty( $attrs['name'] ) ) {
313 continue;
314 }
315
316 $field_names[ (string) $attrs['name'] ] = true;
317 }
318
319 if ( empty( $field_names ) ) {
320 return $empty;
321 }
322
323 $actions = jet_form_builder()->post_type->get_actions( $form_id );
324
325 if ( ! is_array( $actions ) ) {
326 // No Insert Post action → no post meta key for Phase 2, but the
327 // `insert_attachment` field names themselves are still real for Phase 3 (a Form
328 // Record can be saved regardless of which/whether a post-insert action ran).
329 return array(
330 'meta_keys' => array(),
331 'field_names' => array_keys( $field_names ),
332 );
333 }
334
335 $meta_keys = array();
336
337 foreach ( $actions as $action ) {
338 if (
339 ! is_array( $action )
340 || 'insert_post' !== ( $action['type'] ?? '' )
341 // `Action_Handler::save_form_action()` refuses to even construct a
342 // disabled action ($form_action['is_execute'] === false throws before the
343 // action object exists), so a disabled Insert Post action never ran
344 // `Post_Meta_Property`/wrote any legacy meta under its `fields_map` keys.
345 // `jet_form_builder()->post_type->get_actions()` above reads the raw
346 // `_jf_actions` record directly, bypassing that guard, so this method must
347 // apply it itself - otherwise a meta key that only ever existed in a
348 // disabled action's config (never actually written by this form) enters
349 // Phase 2's site-wide scan and can mark unrelated attachments as
350 // plugin-owned purely because some other post happens to reuse that key
351 // name.
352 || ! ( $action['is_execute'] ?? true )
353 ) {
354 continue;
355 }
356
357 /**
358 * `_jf_actions` stores an action's settings in one of two shapes, and both are
359 * live/current, not legacy-vs-new: `Action_Handler::save_form_action()`
360 * (includes/actions/action-handler.php) reads
361 * `$form_action['settings'][$type] ?? $form_action['settings']` - i.e. nested
362 * under the action type key (`settings.insert_post.fields_map`) when present,
363 * falling back to a flat `settings.fields_map` when it isn't. A form saved with
364 * the flat shape has a real, currently-working Insert Post action that this
365 * migration must resolve the same way the request-time handler does; reading
366 * only the nested shape would silently skip such a form's meta key entirely,
367 * and every legacy attachment behind it would never be backfilled.
368 */
369 $insert_post_settings = $action['settings']['insert_post'] ?? $action['settings'] ?? array();
370 $fields_map = is_array( $insert_post_settings ) ? ( $insert_post_settings['fields_map'] ?? array() ) : array();
371
372 if ( ! is_array( $fields_map ) ) {
373 continue;
374 }
375
376 foreach ( $fields_map as $field_name => $meta_key ) {
377 if (
378 isset( $field_names[ $field_name ] )
379 && is_string( $meta_key )
380 && '' !== $meta_key
381 // System post properties (ID, post_title, post_status, ...) are never
382 // where an attachment ID list would be stored; only an actual meta key
383 // is a candidate.
384 && ! in_array( $meta_key, array( 'ID', 'post_title', 'post_status', 'post_type', 'post_author', 'post_date', 'post_name', 'post_content', 'post_excerpt', 'post_parent' ), true )
385 ) {
386 $meta_keys[ $meta_key ] = true;
387 }
388 }
389 }
390
391 return array(
392 'meta_keys' => array_keys( $meta_keys ),
393 'field_names' => array_keys( $field_names ),
394 );
395 }
396
397 /**
398 * @param \wpdb $wpdb
399 * @param array $progress By reference; mutated in place and persisted on yield.
400 * @param float $started_at
401 *
402 * @throws \Jet_Form_Builder\Migrations\Migration_Incomplete_Exception
403 */
404 private function run_backfill_phase( \wpdb $wpdb, array &$progress, float $started_at ) {
405 $meta_keys = array_keys( $progress['meta_keys'] );
406
407 if ( empty( $meta_keys ) ) {
408 $progress['phase'] = self::PHASE_RECORD_BACKFILL;
409 $this->persist_progress( $progress );
410
411 return;
412 }
413
414 $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
415
416 while ( true ) {
417 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
418 // $placeholders is a fixed, code-generated run of `%s` tokens (one per $meta_keys
419 // entry), not user input - the sniff does not special-case an IN() clause combined
420 // with trailing %d placeholders, so both its interpolation and placeholder-count
421 // checks are disabled for this call; the actual values are still bound entirely
422 // through prepare()'s own arguments below.
423 $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
424 $wpdb->prepare(
425 "SELECT meta_id, meta_value FROM {$wpdb->postmeta}
426 WHERE meta_key IN ({$placeholders})
427 AND meta_id > %d
428 ORDER BY meta_id ASC
429 LIMIT %d",
430 array_merge( $meta_keys, array( $progress['last_meta_id'], self::ATTACHMENT_BATCH_SIZE ) )
431 )
432 );
433 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
434
435 if ( empty( $rows ) ) {
436 break;
437 }
438
439 $is_last_page = count( $rows ) < self::ATTACHMENT_BATCH_SIZE;
440
441 foreach ( $rows as $row ) {
442 $progress['last_meta_id'] = max( $progress['last_meta_id'], (int) $row->meta_id );
443
444 foreach ( $this->extract_attachment_ids( $row->meta_value ) as $attachment_id ) {
445 if ( $this->maybe_backfill_attachment( $attachment_id ) ) {
446 ++$progress['backfilled_count'];
447 }
448 }
449
450 if ( $this->time_budget_exceeded( $started_at ) ) {
451 $this->persist_progress_and_yield( $progress );
452 }
453 }
454
455 if ( $is_last_page ) {
456 break;
457 }
458
459 if ( $this->time_budget_exceeded( $started_at ) ) {
460 $this->persist_progress_and_yield( $progress );
461 }
462 }
463
464 $progress['phase'] = self::PHASE_RECORD_BACKFILL;
465 $this->persist_progress( $progress );
466 }
467
468 /**
469 * Phase 3 - see the class docblock for why Form Record values are a separate source
470 * from post meta (Phase 1/2) entirely: `Preset_Source_Form_Record` can populate a
471 * Media field's current value from a previously saved Form Record, and
472 * `Media_Field_Parser::get_response()` has deliberately accepted that value verbatim
473 * (issues-tracker #4422) since long before #20547.
474 *
475 * Joins `{$wpdb->prefix}jet_fb_records_fields` to `{$wpdb->prefix}jet_fb_records` (via
476 * `record_id`) rather than reading `records_fields` alone, and filters each row against
477 * `$progress['field_names_by_form'][$record->form_id]` - the exact `insert_attachment`
478 * field-name set Phase 1 found for THAT SPECIFIC form. `field_type = 'media-field'`
479 * alone is not sufficient: a saved Form Record's `field_attrs` column does not persist
480 * `insert_attachment` (`Form_Record\Controller::get_attrs_by_field_type()` only adds
481 * `label`, and `field_type` for `text-field`), so there is no per-row way to tell a
482 * genuine attachment-holding Media field apart from a `media-field` block whose
483 * `insert_attachment` was off (in which case its value is a URL string or empty, not an
484 * attachment ID - `normalize_attachment_ids()` would usually no-op on that anyway, but
485 * relying on that incidental behavior instead of checking the actual setting would be
486 * fragile) - or, more importantly, from a `media-field` on a COMPLETELY DIFFERENT form
487 * that happens to use the same `field_name`, whose value the requesting form's own
488 * config says nothing trustworthy about. The `(form_id, field_name)` pair from Phase 1
489 * is the only reliable proof a given record row's value actually came from an
490 * attachment-holding field of ITS OWN form.
491 *
492 * `records_fields` has no index on `field_type` (adding one is a schema change on the
493 * plugin's own table, out of scope for this fix), only on its primary key `id` and on
494 * `record_id`. Paginating by "rows matched" (`WHERE field_type = ... LIMIT N`, as Phase
495 * 1/2 do against `wp_postmeta`, which DOES index `meta_key`) would let MySQL scan an
496 * unbounded number of physical rows per call while it looks for `N` matches: on a site
497 * with years of Form Record history dominated by non-media fields, a single
498 * `$wpdb->get_results()` could scan most or all of the remaining table before the
499 * `LIMIT` is satisfied, and the time-budget check between loop iterations cannot
500 * interrupt a single already-running query. Pagination here is therefore by a FIXED `id`
501 * range instead: `record_field_max_id` (the table's highest `id` at the start of this
502 * phase, captured once) bounds how many pages will ever be walked, and each page's
503 * `WHERE id > :cursor AND id <= :cursor + RECORD_FIELD_BATCH_SIZE` scans at most
504 * `RECORD_FIELD_BATCH_SIZE` physical rows no matter how few (or none) are `media-field` -
505 * trading a possibly-larger number of small, bounded queries for the guarantee that no
506 * single query's cost depends on how sparse matching rows are.
507 *
508 * @param \wpdb $wpdb
509 * @param array $progress By reference; mutated in place and persisted on yield.
510 * @param float $started_at
511 *
512 * @throws \Jet_Form_Builder\Migrations\Migration_Incomplete_Exception
513 */
514 private function run_record_backfill_phase( \wpdb $wpdb, array &$progress, float $started_at ) {
515 if ( empty( $progress['field_names_by_form'] ) ) {
516 return;
517 }
518
519 $fields_table = Record_Field_Model::table();
520 $records_table = Record_Model::table();
521
522 if ( ! $progress['record_field_max_id'] ) {
523 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $fields_table is this model's own table name (Base_Db_Model::table()), not user input.
524 $progress['record_field_max_id'] = (int) $wpdb->get_var( "SELECT MAX(id) FROM {$fields_table}" );
525 }
526
527 while ( $progress['last_record_field_id'] < $progress['record_field_max_id'] ) {
528 $range_start = $progress['last_record_field_id'];
529 $range_end = $range_start + self::RECORD_FIELD_BATCH_SIZE;
530
531 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
532 // $fields_table/$records_table are these models' own table names
533 // (Base_Db_Model::table()), not user input; the actual values are still bound
534 // entirely through prepare()'s own arguments below.
535 $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
536 $wpdb->prepare(
537 "SELECT rf.id, rf.field_name, rf.field_value, rf.field_attrs, r.form_id
538 FROM {$fields_table} rf
539 INNER JOIN {$records_table} r ON r.id = rf.record_id
540 WHERE rf.field_type = %s
541 AND rf.id > %d
542 AND rf.id <= %d
543 ORDER BY rf.id ASC",
544 'media-field',
545 $range_start,
546 $range_end
547 )
548 );
549 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
550
551 foreach ( $rows as $row ) {
552 $form_id = (int) $row->form_id;
553 $allowed_field_set = $progress['field_names_by_form'][ $form_id ] ?? array();
554
555 if ( ! in_array( $row->field_name, $allowed_field_set, true ) ) {
556 continue;
557 }
558
559 foreach ( $this->extract_record_field_attachment_ids( $row ) as $attachment_id ) {
560 if ( $this->maybe_backfill_attachment( $attachment_id ) ) {
561 ++$progress['backfilled_count'];
562 }
563 }
564
565 // A mid-range timeout deliberately does NOT advance
566 // `last_record_field_id` past `$range_start` (set below, only once the
567 // whole range is done) - persisting a cursor inside a partially-processed
568 // range would skip this range's remaining rows forever on resume.
569 // `maybe_backfill_attachment()` is idempotent, so redoing the same range
570 // from its start next time is safe, just not free.
571 if ( $this->time_budget_exceeded( $started_at ) ) {
572 $this->persist_progress_and_yield( $progress );
573 }
574 }
575
576 $progress['last_record_field_id'] = $range_end;
577
578 if ( $this->time_budget_exceeded( $started_at ) ) {
579 $this->persist_progress_and_yield( $progress );
580 }
581 }
582 }
583
584 /**
585 * Decodes one `records_fields` row the same way `Record_Tools::iterate_request_line()`
586 * decodes it when a saved record is re-read for display/preset use (JSON-decode
587 * `field_value` when `field_attrs.is_encoded` is set, otherwise use it verbatim - see
588 * that method for why: `Form_Record\Controller::get_prepared_fields()` only encodes a
589 * non-scalar value, e.g. the list/array shapes a multi-file Media field produces),
590 * then normalizes the result through the same `normalize_attachment_ids()` shapes as
591 * every other source this migration reads.
592 *
593 * @param object $row Raw row with `field_value` and `field_attrs` columns.
594 *
595 * @return int[]
596 */
597 private function extract_record_field_attachment_ids( $row ): array {
598 $attrs = \Jet_Form_Builder\Classes\Tools::decode_json( $row->field_attrs );
599 $value = empty( $attrs['is_encoded'] )
600 ? $row->field_value
601 : \Jet_Form_Builder\Classes\Tools::decode_json( $row->field_value );
602
603 return \JFB_Modules\Media_Cleanup\Module::normalize_attachment_ids( $value );
604 }
605
606 /**
607 * Mirrors `Media_Cleanup::normalize_attachment_ids()` so the same stored shapes
608 * (single numeric ID, comma-joined string, plain list, or `both`-shape
609 * `array( 'id' => .., 'url' => .. )` / list of those) resolve to the same attachment
610 * IDs the plugin's own cleanup code would see for this value.
611 *
612 * @param mixed $meta_value Raw `meta_value` column straight from `$wpdb->get_results()`.
613 * Unlike `get_post_meta()`, a direct `$wpdb` query does NOT run
614 * `maybe_unserialize()` on the row - a `both`/`ids` value stored
615 * as a serialized PHP array would otherwise reach
616 * `normalize_attachment_ids()` as its raw serialized string
617 * (e.g. `a:2:{i:0;s:1:"5";...}`), which is not numeric and has
618 * no comma, so every ID inside it would be silently dropped
619 * instead of backfilled.
620 *
621 * @return int[]
622 */
623 private function extract_attachment_ids( $meta_value ): array {
624 return \JFB_Modules\Media_Cleanup\Module::normalize_attachment_ids( maybe_unserialize( $meta_value ) );
625 }
626
627 /**
628 * Backfills `_jfb_uploaded_by_user` from `post_author` regardless of whether it is zero
629 * or a real user ID, and always pairs it with `_jfb_uploaded_by_user_heuristic = 1` so
630 * `Media_Cleanup::is_owned_by_current_actor()` can tell this guessed value apart from a
631 * live upload's ground-truth uploader ID. See this class's own docblock (search for
632 * "PHASE 2 IS A HEURISTIC") for why leaving `post_author = 0` unbackfilled was itself a
633 * security bug this method used to have, and why writing an explicit `0` here is what
634 * closes it: an unbackfilled attachment falls into `is_owned_by_current_actor()`'s
635 * unconditional-allow "no uploader meta at all" branch, which - combined with this
636 * migration also granting `_jfb_uploaded_by_form` to the same attachment - let any
637 * authenticated user claim or delete it via any post they control, the same primitive
638 * issues-tracker #20547 exists to close.
639 *
640 * @param int $attachment_id
641 *
642 * @return bool Whether this attachment was backfilled (false = not an attachment, or
643 * already carrying the marker).
644 */
645 private function maybe_backfill_attachment( int $attachment_id ): bool {
646 if ( $attachment_id <= 0 ) {
647 return false;
648 }
649
650 $attachment = get_post( $attachment_id );
651
652 if ( ! $attachment instanceof \WP_Post || 'attachment' !== $attachment->post_type ) {
653 return false;
654 }
655
656 if ( get_post_meta( $attachment_id, '_jfb_uploaded_by_form', true ) ) {
657 return false;
658 }
659
660 $uploader_id = absint( $attachment->post_author );
661
662 /**
663 * A non-zero `post_author` writes `_jfb_uploaded_by_user` as normal; a zero
664 * `post_author` ALSO now writes it, explicitly, as `0` - unlike an earlier revision
665 * of this migration, which left `_jfb_uploaded_by_user` entirely absent for
666 * `post_author = 0` to preserve `is_owned_by_current_actor()`'s lenient "no
667 * uploader meta at all" branch. That earlier choice was itself the bug: "no
668 * uploader meta" is `is_owned_by_current_actor()`'s UNCONDITIONAL-allow branch (it
669 * returns `true` before checking webhook, `$owning_post_id`, or anything else) -
670 * the one branch meant only for attachments the marker system predates entirely.
671 * Combined with this migration ALSO granting `_jfb_uploaded_by_form` to the same
672 * attachment, an anonymously-uploaded legacy attachment left without
673 * `_jfb_uploaded_by_user` became claimable/deletable by ANY authenticated user via
674 * ANY post they control - `Media_Field_Parser::is_allowed_submitted_attachment()`
675 * accepts it into an unrelated post's meta on the strength of the unconditional
676 * branch alone, and a subsequent `delete_attachments()` call deletes it the same
677 * way. That is not meaningfully different from the original #20547 primitive; it
678 * is now closed by writing an explicit `0` here, which routes the same attachment
679 * through the anonymous-uploader branch instead (`Tools::is_webhook() ||
680 * $owning_post_id > 0` for deletion; `Tools::is_webhook()` only for acceptance into
681 * meta - see both call sites' own docblocks).
682 *
683 * `_jfb_uploaded_by_user_heuristic` is written alongside `_jfb_uploaded_by_user`
684 * regardless of whether the value is zero or non-zero: it marks this specific value
685 * as migration-inferred from `post_author`, not observed from a live upload
686 * request. This distinction is read by `Media_Cleanup::is_owned_by_current_actor()`:
687 * a live upload's uploader ID (zero or not) is ground truth (the actual
688 * `get_current_user_id()` at upload time, recorded directly by
689 * `Uploaded_File::add_attachment()`), so a live-uploaded attachment is bound
690 * strictly to that one identity. A migration-inferred value is only ever a
691 * plausible guess - `post_author` can legitimately be reassigned after upload for
692 * reasons unrelated to who actually uploaded the file (editorial cleanup, bulk
693 * ownership-transfer tools, a different user's later edit of the attachment's own
694 * post record, or - for the zero case specifically - an attachment created via
695 * import/WP-CLI/any programmatic path with no current user, never a real anonymous
696 * form submission at all). Marking it lets a verified-identity editor of the post
697 * it is demonstrably still attached to (`$owning_post_id`, only ever supplied by
698 * `delete_attachments()`) clean it up despite the guess, without granting that same
699 * exception to a live, ground-truth-bound upload.
700 *
701 * Written BEFORE `_jfb_uploaded_by_form` (below), same reasoning as
702 * `Uploaded_File::add_attachment()`: the latter is the trust gate
703 * `is_owned_by_current_actor()` reads to decide an attachment is plugin-owned at
704 * all. If this migration were interrupted between the two writes (a failed request,
705 * a fatal error further down the batch), an attachment left with the trust gate set
706 * but no uploader identity would fall into that check's unconditional-allow branch -
707 * recreating this fix's own target vulnerability for exactly the attachments this
708 * migration is backfilling. Writing the identity first means an interruption
709 * instead leaves the attachment with neither marker, to be picked up again by a
710 * later resumed run of this same phase (see `run_backfill_phase()`'s docblock).
711 *
712 * The trust gate is published only once `metadata_exists()` confirms the identity
713 * write actually landed - same reasoning as `Uploaded_File::add_attachment()`:
714 * `update_post_meta()` can be silently short-circuited by a filter on
715 * `update_post_meta`/`add_post_meta` (WP core lets either return non-null to skip
716 * the write) even without any interruption. Without this check, the migration could
717 * still publish `_jfb_uploaded_by_form` on top of a swallowed identity write, and
718 * `get_post_meta( ..., '_jfb_uploaded_by_form', true )` above already prevents a
719 * retried run from correcting a half-migrated attachment on its own.
720 */
721 update_post_meta( $attachment_id, '_jfb_uploaded_by_user', $uploader_id );
722 update_post_meta( $attachment_id, '_jfb_uploaded_by_user_heuristic', 1 );
723
724 if ( ! metadata_exists( 'post', $attachment_id, '_jfb_uploaded_by_user' ) ) {
725 return false;
726 }
727
728 update_post_meta( $attachment_id, '_jfb_uploaded_by_form', 1 );
729
730 return true;
731 }
732
733 private function load_progress(): array {
734 $stored = get_option( self::PROGRESS_OPTION, array() );
735
736 if ( ! is_array( $stored ) ) {
737 $stored = array();
738 }
739
740 return array_merge(
741 array(
742 'phase' => self::PHASE_COLLECT_KEYS,
743 'last_form_id' => 0,
744 'meta_keys' => array(),
745 'field_names_by_form' => array(),
746 'last_meta_id' => 0,
747 'last_record_field_id' => 0,
748 'record_field_max_id' => 0,
749 'backfilled_count' => 0,
750 ),
751 $stored
752 );
753 }
754
755 private function persist_progress( array $progress ) {
756 update_option( self::PROGRESS_OPTION, $progress, false );
757 }
758
759 /**
760 * Writes the resume cursor FIRST, then commits: with `autocommit = 0`, an explicit
761 * `COMMIT` ends the current transaction and the next write opens a new implicit one.
762 * Committing before writing the cursor would leave the cursor itself in that fresh,
763 * uncommitted transaction - exactly what `Auto_Migrator::run()`'s catch-and-rollback of
764 * the exception below would then discard, defeating the whole point of committing
765 * early. Same reasoning as `Version_3_6_5_3::persist_progress_and_yield()`.
766 *
767 * @param array $progress
768 *
769 * @throws \Jet_Form_Builder\Migrations\Migration_Incomplete_Exception Always.
770 */
771 private function persist_progress_and_yield( array $progress ) {
772 $this->persist_progress( $progress );
773
774 \Jet_Form_Builder\Db_Queries\Execution_Builder::instance()->transaction_commit();
775
776 throw new \Jet_Form_Builder\Migrations\Migration_Incomplete_Exception( 'Media ownership backfill migration exceeded its time budget; will resume on the next request.' );
777 }
778
779 /**
780 * @param float $started_at `microtime( true )` value captured when this `up()` call
781 * started (covers both phases in this call, matching
782 * `Version_3_6_5_3`'s single-budget-per-call approach).
783 */
784 private function time_budget_exceeded( float $started_at ): bool {
785 $budget = (float) apply_filters(
786 'jet-form-builder/media-ownership-migration/time-budget',
787 self::TIME_BUDGET_SECONDS
788 );
789
790 return ( microtime( true ) - $started_at ) >= $budget;
791 }
792 }
793