PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
← All changes | includes/Campaign/MetaBox.php +117 -32 2.3.3 → 2.4.1 View file →
@@ -193,12 +193,12 @@
193 193
194 194 /**
195 195 * Refuse changes to Pro-element settings from a site without Pro.
196 196 *
197 - * The builder renders a Pro element's controls disabled, but a disabled input
198 - * is a courtesy, not a control — nothing stops a crafted `POST` to the REST
199 - * route or the classic editor from carrying arbitrary `donors_wall` settings.
200 - * This is the actual enforcement; the greyed-out inputs are the UI for it.
197 + * The builder shows a locked Pro element's upgrade banner instead of its
198 + * controls, but that is a courtesy, not a control — nothing stops a crafted
199 + * `POST` to the REST route or the classic editor (or an AI turn) from carrying
200 + * arbitrary `donors_wall` settings. This is the actual enforcement.
201 201 *
202 202 * **Existing values are preserved, never stripped.** The tempting fix is to
203 203 * delete Pro elements (or blank their settings) on save, but a user whose
204 204 * licence lapsed for a week would come back to find their configuration
@@ -206,15 +206,40 @@
206 206 *
207 207 * - element exists already → its stored settings are restored verbatim, so
208 208 * the campaign is byte-identical on the Pro elements and reactivating Pro
209 209 * brings everything back exactly as it was;
210 - * - element is new → its settings are reset to the schema defaults, so a
210 + * - element is new → its settings are reset to the registered defaults, so a
211 211 * free user can't hand-craft values for an element they can't configure.
212 + * A free install registers only a lock stub
213 + * ({@see \Better_Payment\Lite\Campaign\Elements\ProElementStubs}), whose
214 + * defaults are empty, so a new Pro element saves with `[]` settings.
212 215 *
216 + * **"Exists already" is decided in two passes, and the second one is the one
217 + * that protects the user.** An element id is not a stable identity: an AI
218 + * whole-page edit (`set_layout`), an undo to a snapshot from before a save, or
219 + * any client that re-mints ids hands back the SAME FAQ under a NEW id. Matching
220 + * by id alone made that FAQ "new", saved it as `[]`, and destroyed the
221 + * configuration this guard exists to keep — permanently, since reactivating Pro
222 + * has nothing left to restore. So:
223 + *
224 + * 1. an incoming Pro element whose id AND type match a stored one takes that
225 + * element's settings;
226 + * 2. every other incoming Pro element takes the settings of the next stored
227 + * element of the SAME type that step 1 did not claim, in document order;
228 + * 3. only when no unclaimed stored element of that type remains is it new.
229 + *
230 + * Settings never cross types, and each stored element is claimed at most once,
231 + * so an extra Pro element beyond what was stored still gets the defaults. The
232 + * values restored always come from storage, never from the request.
233 + *
213 234 * Structure (order, position, insertion, deletion) is deliberately NOT
214 - * guarded — moving or removing a leftover Pro element is legitimate and the
215 - * builder offers a Remove button for exactly that.
235 + * guarded — moving or removing a leftover Pro element is legitimate, and the
236 + * canvas element toolbar's delete action is there for exactly that.
216 237 *
238 + * Structure (order, position, insertion, deletion) is deliberately NOT
239 + * guarded — moving or removing a leftover Pro element is legitimate, and the
240 + * canvas element toolbar's delete action is there for exactly that.
241 + *
217 242 * No-ops entirely when Pro is active. Entitlement is read live from
218 243 * `better_payment/pro_enabled` on every save, so a previously-saved campaign
219 244 * grants nothing.
220 245 *
@@ -251,12 +276,15 @@
251 276 if ( empty( $locked ) ) {
252 277 return $layout_json;
253 278 }
254 279
255 - $stored = json_decode( (string) get_post_meta( $post_id, '_bpc_fields_layout', true ), true );
256 - $known = self::index_element_settings( is_array( $stored ) ? $stored : [] );
280 + $stored = json_decode( (string) get_post_meta( $post_id, '_bpc_fields_layout', true ), true );
281 + $pool = self::collect_locked_elements( is_array( $stored ) ? $stored : [], $locked );
282 + $claims = self::collect_locked_elements( $incoming, $locked );
283 + $resolved = self::resolve_locked_settings( $claims, $pool, $locked );
257 284
258 - $guarded = self::restore_locked_settings( $incoming, $locked, $known );
285 + $ordinal = 0;
286 + $guarded = self::restore_locked_settings( $incoming, $locked, $resolved, $ordinal );
259 287
260 288 $encoded = wp_json_encode( $guarded );
261 289
262 290 return is_string( $encoded ) ? $encoded : $layout_json;
@@ -262,27 +290,34 @@
262 290 return is_string( $encoded ) ? $encoded : $layout_json;
263 291 }
264 292
265 293 /**
266 - * Flatten a layout into `element id => settings` for every element it holds.
294 + * Every locked (Pro) element in a layout, in document order.
267 295 *
268 296 * Walks recursively rather than assuming `columns[].elements[]` so the legacy
269 - * flat-array layout format is covered too.
297 + * flat-array layout format is covered too. It stops at a locked element, and
298 + * {@see self::restore_locked_settings()} walks identically, so the Nth entry
299 + * here is the Nth element that walk rewrites.
270 300 *
271 - * @param array $node
272 - * @param array $acc
273 - * @return array<string, array>
301 + * @param array $node
302 + * @param array<string, array> $locked Type => default settings.
303 + * @param array $acc
304 + * @return array<int, array{id: string, type: string, settings: array}>
274 305 */
275 - private static function index_element_settings( array $node, array $acc = [] ): array {
276 - if ( isset( $node['id'], $node['type'] ) && is_scalar( $node['id'] ) ) {
277 - $acc[ (string) $node['id'] ] = isset( $node['settings'] ) && is_array( $node['settings'] )
278 - ? $node['settings']
279 - : [];
306 + private static function collect_locked_elements( array $node, array $locked, array $acc = [] ): array {
307 + if ( isset( $node['type'] ) && is_string( $node['type'] ) && isset( $locked[ $node['type'] ] ) ) {
308 + $acc[] = [
309 + 'id' => isset( $node['id'] ) && is_scalar( $node['id'] ) ? (string) $node['id'] : '',
310 + 'type' => $node['type'],
311 + 'settings' => isset( $node['settings'] ) && is_array( $node['settings'] ) ? $node['settings'] : [],
312 + ];
313 +
314 + return $acc;
280 315 }
281 316
282 317 foreach ( $node as $child ) {
283 318 if ( is_array( $child ) ) {
284 - $acc = self::index_element_settings( $child, $acc );
319 + $acc = self::collect_locked_elements( $child, $locked, $acc );
285 320 }
286 321 }
287 322
288 323 return $acc;
@@ -288,30 +323,80 @@
288 323 return $acc;
289 324 }
290 325
291 326 /**
292 - * Replace the settings of every locked element with the stored ones (or the
293 - * schema defaults when the element is new). Mirrors the walk above.
327 + * Decide the settings each incoming locked element is saved with.
294 328 *
329 + * Pass 1 claims stored elements by id AND type. Pass 2 gives each still
330 + * unresolved incoming element the next unclaimed stored element of the same
331 + * type, in document order — the same element under a re-minted id (an AI
332 + * `set_layout`, an undo, any client that regenerates ids). Whatever is left
333 + * over is genuinely new and gets the registered defaults. A stored element is
334 + * claimed at most once and never by another type.
335 + *
336 + * @param array<int, array{id: string, type: string, settings: array}> $incoming Locked elements being saved.
337 + * @param array<int, array{id: string, type: string, settings: array}> $stored Locked elements already stored.
338 + * @param array<string, array> $locked Type => default settings.
339 + * @return array<int, array> Incoming ordinal => settings to save.
340 + */
341 + private static function resolve_locked_settings( array $incoming, array $stored, array $locked ): array {
342 + $resolved = [];
343 + $claimed = [];
344 +
345 + foreach ( $incoming as $i => $element ) {
346 + if ( '' === $element['id'] ) {
347 + continue;
348 + }
349 + foreach ( $stored as $j => $candidate ) {
350 + if ( ! isset( $claimed[ $j ] ) && $candidate['id'] === $element['id'] && $candidate['type'] === $element['type'] ) {
351 + $resolved[ $i ] = $candidate['settings'];
352 + $claimed[ $j ] = true;
353 + break;
354 + }
355 + }
356 + }
357 +
358 + foreach ( $incoming as $i => $element ) {
359 + if ( array_key_exists( $i, $resolved ) ) {
360 + continue;
361 + }
362 +
363 + $resolved[ $i ] = $locked[ $element['type'] ];
364 +
365 + foreach ( $stored as $j => $candidate ) {
366 + if ( ! isset( $claimed[ $j ] ) && $candidate['type'] === $element['type'] ) {
367 + $resolved[ $i ] = $candidate['settings'];
368 + $claimed[ $j ] = true;
369 + break;
370 + }
371 + }
372 + }
373 +
374 + return $resolved;
375 + }
376 +
377 + /**
378 + * Replace the settings of every locked element with the ones resolved for it.
379 + * Walks exactly like {@see self::collect_locked_elements()}; `$ordinal` counts
380 + * the locked elements seen so far.
381 + *
295 382 * @param array $node
296 - * @param array<string, array> $locked Type => default settings.
297 - * @param array<string, array> $known Element id => stored settings.
383 + * @param array<string, array> $locked Type => default settings.
384 + * @param array<int, array> $resolved Ordinal => settings.
385 + * @param int $ordinal Running locked-element counter.
298 386 * @return array
299 387 */
300 - private static function restore_locked_settings( array $node, array $locked, array $known ): array {
388 + private static function restore_locked_settings( array $node, array $locked, array $resolved, int &$ordinal ): array {
301 389 if ( isset( $node['type'] ) && is_string( $node['type'] ) && isset( $locked[ $node['type'] ] ) ) {
302 - $id = isset( $node['id'] ) && is_scalar( $node['id'] ) ? (string) $node['id'] : '';
390 + $node['settings'] = $resolved[ $ordinal ] ?? $locked[ $node['type'] ];
391 + ++$ordinal;
303 392
304 - $node['settings'] = ( '' !== $id && isset( $known[ $id ] ) )
305 - ? $known[ $id ]
306 - : $locked[ $node['type'] ];
307 -
308 393 return $node;
309 394 }
310 395
311 396 foreach ( $node as $key => $child ) {
312 397 if ( is_array( $child ) ) {
313 - $node[ $key ] = self::restore_locked_settings( $child, $locked, $known );
398 + $node[ $key ] = self::restore_locked_settings( $child, $locked, $resolved, $ordinal );
314 399 }
315 400 }
316 401
317 402 return $node;