PluginProbe ʕ •ᴥ•ʔ
Secure Custom Fields / 6.9.5
Secure Custom Fields v6.9.5
6.9.5 6.9.4 6.9.3 6.9.2 6.9.1 6.9.0 6.8.9 6.8.7 6.8.8 6.8.6 6.8.4 6.8.5 trunk 6.4.0-beta1 6.4.0-beta2 6.4.1 6.4.1-beta3 6.4.1-beta4 6.4.1-beta5 6.4.1-beta6 6.4.1-beta7 6.4.2 6.5.0 6.5.1 6.5.2 6.5.3 6.5.4 6.5.5 6.5.6 6.5.7 6.6.0 6.7.0 6.7.1 6.8.0 6.8.1 6.8.2 6.8.3
secure-custom-fields / includes / Datastore / REST_Save.php
secure-custom-fields / includes / Datastore Last commit date
Check_Screen.php 2 months ago Localization.php 2 months ago REST_Save.php 3 days ago Revisions.php 2 months ago
REST_Save.php
374 lines
1 <?php
2 /**
3 * SCF datastore integration.
4 *
5 * @package wordpress/secure-custom-fields
6 */
7
8 namespace SCF\Datastore;
9
10 /**
11 * Handles SCF datastore saves during Gutenberg / REST post requests.
12 *
13 * Decodes the _acf transport meta included on REST post requests, writes
14 * individual field meta to the post and (when applicable) to the revision,
15 * then cleans up the transport blob. Also strips the transport blob from
16 * REST responses so it never leaks to clients.
17 */
18 class REST_Save {
19
20 /**
21 * Decoded SCF values from the current REST save.
22 * Set in save_post_rest(), consumed in save_revision_meta().
23 *
24 * @since ACF 6.8.1
25 * @var array|null
26 */
27 private $current_acf_values = null;
28
29 /**
30 * Post ID pending _acf cleanup.
31 * Set in save_post_rest(), consumed in cleanup_acf_transport_meta().
32 *
33 * @since ACF 6.8.1
34 * @var integer|null
35 */
36 private $pending_cleanup_post_id = null;
37
38 /**
39 * Stable bridge to the private datastore preflight.
40 *
41 * @since SCF 6.9.5
42 * @var \Closure
43 */
44 private $preflight_datastore_callback;
45
46 /**
47 * Constructor.
48 *
49 * Defers hook registration to rest_api_init so the
50 * acf/settings/enable_datastore filter is available to themes
51 * and plugins by the time the gate is evaluated.
52 *
53 * @since ACF 6.8.1
54 */
55 public function __construct() {
56 $this->preflight_datastore_callback = \Closure::fromCallable( array( $this, 'preflight_datastore_request' ) );
57 add_action( 'rest_api_init', array( $this, 'maybe_register_rest_save_hooks' ) );
58
59 // Skip the legacy ACF_Form_Post::save_post() during the metabox AJAX
60 // (meta-box-loader) that follows each Gutenberg REST save -- the REST
61 // path has already saved the values, so re-running the legacy save
62 // would clobber them.
63 add_filter( 'acf/form-post/skip_save', array( $this, 'skip_metabox_loader_save' ), 10, 3 );
64 }
65
66 /**
67 * Returns true when the current request is the meta-box-loader AJAX
68 * and the datastore is enabled.
69 *
70 * @since ACF 6.8.1
71 *
72 * @param boolean $skip Whether the save should be skipped.
73 * @param integer $post_id The post ID being saved.
74 * @param mixed $post The post being saved.
75 * @return boolean
76 */
77 public function skip_metabox_loader_save( $skip, $post_id, $post ) {
78 unset( $post_id, $post );
79
80 if ( $skip ) {
81 return $skip;
82 }
83
84 return acf_maybe_get_GET( 'meta-box-loader', false ) && acf_is_using_datastore();
85 }
86
87 /**
88 * Conditionally registers REST save hooks for all public post types.
89 *
90 * @since ACF 6.8.1
91 *
92 * @return void
93 */
94 public function maybe_register_rest_save_hooks() {
95 if ( ! acf_is_using_datastore() ) {
96 return;
97 }
98
99 add_filter( 'rest_dispatch_request', $this->preflight_datastore_callback, 10, 4 );
100 add_action( '_wp_put_post_revision', array( $this, 'save_revision_meta' ), 11, 2 );
101
102 foreach ( get_post_types( array( 'show_in_rest' => true ) ) as $post_type ) {
103 // Post-save: decode _acf and write individual meta keys to post + revision.
104 add_action( "rest_after_insert_{$post_type}", array( $this, 'save_post_rest' ), 10, 2 );
105
106 // Strip _acf from post REST responses. Revisions use
107 // rest_prepare_revision instead, so _acf passes through
108 // for the revision viewer.
109 add_filter( "rest_prepare_{$post_type}", array( $this, 'strip_acf_transport_meta' ) );
110 }
111
112 /**
113 * Autosave: WP_REST_Autosaves_Controller does not fire
114 * rest_after_insert_{post_type}, so this response filter is the only
115 * hook available in all autosave paths that carries the request object.
116 * No-ops on GET requests since the request body is empty.
117 */
118 add_filter( 'rest_prepare_autosave', array( $this, 'save_autosave_rest' ), 10, 3 );
119
120 // Fallback cleanup for the _acf transport meta. Runs at priority 20,
121 // after the revision system (priority 9) has finished deciding whether
122 // to create a revision. Catches orphaned _acf when no revision is
123 // created (e.g., post type doesn't support revisions).
124 add_action( 'wp_after_insert_post', array( $this, 'cleanup_acf_transport_meta' ), 20 );
125 }
126
127 /**
128 * Checks bidirectional datastore targets before dispatch.
129 *
130 * @since SCF 6.9.5
131 *
132 * @param mixed $result Result from an earlier dispatch filter.
133 * @param \WP_REST_Request $request Current REST request.
134 * @param string $route Matched REST route.
135 * @param array $handler Matched route handler.
136 * @return mixed|\WP_Error
137 */
138 private function preflight_datastore_request( $result, $request, $route, $handler ) {
139 $meta = $request->get_param( 'meta' );
140 $values = is_array( $meta ) && ! empty( $meta['_acf'] ) ? json_decode( $meta['_acf'], true ) : null;
141 if ( null !== $result || ! in_array( $request->get_method(), array( 'POST', 'PUT', 'PATCH' ), true ) || ! is_array( $values ) || ! isset( $handler['callback'][0] ) ) {
142 return $result;
143 }
144 $controller = $handler['callback'][0];
145 $is_autosave = $controller instanceof \WP_REST_Autosaves_Controller;
146 if ( ! $is_autosave && ! ( $controller instanceof \WP_REST_Posts_Controller ) ) {
147 return $result;
148 }
149
150 $post_id = isset( $request->get_url_params()['id'] ) ? absint( $request->get_param( 'id' ) ) : 0;
151 $origin_id = $post_id;
152 $current_values = $post_id ? null : array();
153 if ( $is_autosave ) {
154 $post = get_post( $post_id );
155 if ( ! $post ) {
156 return $result;
157 }
158 if ( ! function_exists( 'wp_check_post_lock' ) ) {
159 require_once ABSPATH . 'wp-admin/includes/post.php';
160 }
161 if ( in_array( $post->post_status, array( 'draft', 'auto-draft' ), true ) && get_current_user_id() === (int) $post->post_author && ! wp_check_post_lock( $post_id ) ) {
162 unset( $meta['_acf'] );
163 $request->set_param( 'meta', $meta );
164 return $result;
165 }
166 $autosave = wp_get_post_autosave( $post_id, get_current_user_id() );
167 $origin_id = $autosave ? $autosave->ID : 0;
168 $current_values = $autosave ? null : array();
169 $location_args = array( 'post_id' => $post_id );
170 } elseif ( $post_id ) {
171 $location_args = array( 'post_id' => $post_id );
172 } else {
173 $post_status = $request->get_param( 'status' );
174 $template = $request->get_param( 'template' );
175 $location_args = array(
176 'post_type' => $controller->get_item_schema()['title'],
177 'post_status' => $post_status ? $post_status : 'draft',
178 );
179 if ( $template ) {
180 $location_args['page_template'] = $template;
181 }
182 }
183
184 $active_fields = array();
185 foreach ( acf_get_field_groups( $location_args ) as $field_group ) {
186 foreach ( (array) acf_get_fields( $field_group ) as $field ) {
187 $prepared_field = apply_filters( 'acf/prepare_field', $field );
188 if ( $prepared_field ) {
189 $active_fields[ $prepared_field['key'] ] = true;
190 }
191 }
192 }
193 $filtered_values = $values;
194 foreach ( $values as $field_key => $value ) {
195 if ( ! acf_get_setting( 'enable_bidirection' ) ) {
196 continue;
197 }
198 $is_active = isset( $active_fields[ $field_key ] );
199 $field = acf_get_field( $field_key );
200 if ( ! $field ) {
201 continue;
202 }
203 foreach ( _scf_collect_bidirectional_destinations( $field, $value, $origin_id, $current_values, true ) as $destination ) {
204 if ( ! acf_current_user_can_edit_in_context( acf_decode_post_id( $destination ) ) ) {
205 if ( ! $is_active ) {
206 unset( $filtered_values[ $field_key ] );
207 break;
208 }
209 return new \WP_Error( 'acf_rest_cannot_update_bidirectional_target', __( 'Sorry, you are not allowed to update one or more bidirectional targets.', 'secure-custom-fields' ), array( 'status' => 403 ) );
210 }
211 }
212 }
213 if ( $filtered_values !== $values ) {
214 if ( $filtered_values ) {
215 $meta['_acf'] = wp_json_encode( $filtered_values );
216 } else {
217 unset( $meta['_acf'] );
218 }
219 $request->set_param( 'meta', $meta );
220 }
221 return $result;
222 }
223
224 /**
225 * Writes SCF field values to the revision after WordPress creates it.
226 *
227 * Called via _wp_put_post_revision, which fires AFTER rest_after_insert
228 * (where save_post_rest writes values to the post). At this point the
229 * decoded values are available in $this->current_acf_values.
230 *
231 * @since ACF 6.8.1
232 *
233 * @param integer $revision_id The revision ID.
234 * @param integer $post_id The parent post ID.
235 * @return void
236 */
237 public function save_revision_meta( $revision_id, $post_id ) {
238 if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) {
239 return;
240 }
241
242 $is_autosave = wp_is_post_autosave( $revision_id );
243
244 // Clean up the transport-only _acf blob from the post.
245 // wp_save_revisioned_meta_fields (priority 10) has already copied
246 // it to the revision for future comparison.
247 if ( ! $is_autosave ) {
248 delete_post_meta( $post_id, '_acf' );
249 }
250
251 if ( empty( $this->current_acf_values ) || $is_autosave ) {
252 return;
253 }
254
255 $values = $this->current_acf_values;
256
257 if ( ! acf_allow_unfiltered_html() ) {
258 $values = wp_kses_post_deep( $values );
259 }
260
261 acf_update_values( $values, $revision_id );
262
263 $this->current_acf_values = null;
264 }
265
266 /**
267 * Processes SCF field values from the REST request.
268 * Decodes the _acf blob and saves individual meta keys to the post.
269 *
270 * Revision meta is handled separately by save_revision_meta(), which
271 * fires later via _wp_put_post_revision after WordPress creates the
272 * revision inside wp_after_insert_post().
273 *
274 * @since ACF 6.8.1
275 *
276 * @param \WP_Post $post The post object.
277 * @param \WP_REST_Request $request The REST request.
278 * @return void
279 */
280 public function save_post_rest( $post, $request ) {
281 // Check if _acf data was included in the request.
282 $meta = $request->get_param( 'meta' );
283 if ( empty( $meta['_acf'] ) ) {
284 return;
285 }
286
287 // Retrieve and decode the JSON.
288 $acf_json = get_post_meta( $post->ID, '_acf', true );
289 $values = is_string( $acf_json ) ? json_decode( $acf_json, true ) : null;
290
291 if ( empty( $values ) || ! is_array( $values ) ) {
292 return;
293 }
294
295 // Save individual meta keys to the post.
296 // Fires acf/save_post action, runs all ACF processing hooks.
297 acf_save_post( $post->ID, $values );
298
299 // Store values for save_revision_meta(), which fires later
300 // via _wp_put_post_revision (after wp_after_insert_post creates
301 // the revision). Don't delete _acf yet -- the revision comparison
302 // needs it on the post when deciding whether to create a revision.
303 // cleanup_acf_transport_meta() handles deletion after the revision
304 // system finishes.
305 $this->current_acf_values = $values;
306 $this->pending_cleanup_post_id = $post->ID;
307 }
308
309 /**
310 * Handles SCF values during autosave REST requests.
311 *
312 * @since ACF 6.8.1
313 *
314 * @param \WP_REST_Response $response The response object.
315 * @param \WP_Post $post The post object.
316 * @param \WP_REST_Request $request The REST request.
317 * @return \WP_REST_Response
318 */
319 public function save_autosave_rest( $response, $post, $request ) {
320 if ( ! wp_is_post_autosave( $post ) ) {
321 return $response;
322 }
323 $this->save_post_rest( $post, $request );
324 return $response;
325 }
326
327 /**
328 * Cleans up the transport-only _acf meta after the revision system finishes.
329 *
330 * Hooked to wp_after_insert_post at priority 20, which runs after
331 * wp_save_post_revision_on_insert (priority 9). This catches orphaned
332 * _acf when no revision is created (e.g., post type doesn't support
333 * revisions). When a revision IS created, save_revision_meta() already
334 * deleted _acf, so this is a harmless no-op.
335 *
336 * @since ACF 6.8.1
337 *
338 * @param integer $post_id The post ID.
339 * @return void
340 */
341 public function cleanup_acf_transport_meta( $post_id ) {
342 if ( null === $this->pending_cleanup_post_id || (int) $this->pending_cleanup_post_id !== (int) $post_id ) {
343 return;
344 }
345
346 delete_post_meta( $post_id, '_acf' );
347 $this->current_acf_values = null;
348 $this->pending_cleanup_post_id = null;
349 }
350
351 /**
352 * Strips _acf from post REST responses.
353 *
354 * The _acf meta is transport-only and should not appear in post
355 * responses. Revision responses use rest_prepare_revision instead,
356 * so _acf passes through for the revision viewer.
357 *
358 * @since ACF 6.8.1
359 *
360 * @param \WP_REST_Response $response The response object.
361 * @return \WP_REST_Response
362 */
363 public function strip_acf_transport_meta( $response ) {
364 $data = $response->get_data();
365
366 if ( isset( $data['meta']['_acf'] ) ) {
367 $data['meta']['_acf'] = '';
368 $response->set_data( $data );
369 }
370
371 return $response;
372 }
373 }
374