| Server IP : 195.130.67.5 / Your IP : 216.73.217.154 Web Server : Microsoft-IIS/10.0 System : Windows NT WEBSERVER1 10.0 build 17763 (Windows Server 2016) i586 User : IUSR ( 0) PHP Version : 7.4.19 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : OFF | Perl : OFF | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : /inetpub/wwwroot/cdc/wp-content/plugins/siteorigin-panels/inc/ |
Upload File : |
<?php
/**
* SiteOrigin Panels — Abilities API exposure.
*
* Public API — premium-addon-facing. Registers two WordPress Abilities API
* abilities so the AI ecosystem can discover and use Page Builder layouts:
*
* - siteorigin-panels/layout-get (readonly) — reads a post's canonical
* panels_data across classic + block storage.
* - siteorigin-panels/layout-update (write) — persists a classic/meta-stored
* layout, OR a specific block-stored Layout
* Block selected by block_index, through the
* same sanitizer the corresponding save uses.
*
* Core ships ZERO AI vendor logic: no API keys, model calls, or prompts. An
* ability here is capability registration against existing sanitized seams —
* exposure, like the read-only REST route in inc/ai-exposure.php. The premium
* addon will later IMPLEMENT the AI behaviour that CALLS these abilities.
*
* @since {NEXT_VERSION}
* @api
*/
class SiteOrigin_Panels_Abilities {
/**
* @var SiteOrigin_Panels_Abilities
*/
private static $single;
/**
* Get the singleton instance.
*
* @return SiteOrigin_Panels_Abilities
*/
public static function single() {
if ( empty( self::$single ) ) {
self::$single = new self();
}
return self::$single;
}
public function __construct() {
// Categories must be registered on the categories-init hook, BEFORE the
// abilities-init hook (an ability references its category at registration).
add_action( 'wp_abilities_api_categories_init', array( $this, 'register_ability_category' ) );
// Abilities must be registered on the documented init hook; registering
// outside it triggers _doing_it_wrong() and the registration fails.
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
}
/**
* Register the Page Builder ability category.
*
* Groups the paired layout abilities for client-side discoverability (matching
* the AIOSEO precedent). Guarded for environments without the Abilities API,
* same as register_abilities(), so core never fatals on WP < 6.9.
*
* @since {NEXT_VERSION}
* @api
*/
public function register_ability_category() {
if ( ! function_exists( 'wp_register_ability_category' ) ) {
return;
}
wp_register_ability_category(
'siteorigin-panels',
array(
'label' => __( 'Page Builder by SiteOrigin', 'siteorigin-panels' ),
'description' => __( 'Read and update SiteOrigin Page Builder layouts.', 'siteorigin-panels' ),
)
);
}
/**
* Register the Page Builder abilities.
*
* Guarded for environments without the Abilities API (WP < 6.9, or the API
* plugin absent): we bail early rather than fatal. The plugin supports a wide
* range of WordPress versions, so core must stay safe where the API is missing.
*
* @since {NEXT_VERSION}
* @api
*/
public function register_abilities() {
if ( ! function_exists( 'wp_register_ability' ) ) {
return;
}
wp_register_ability(
'siteorigin-panels/layout-get',
array(
'label' => __( 'Get Page Builder layout', 'siteorigin-panels' ),
'description' => __( "Reads a post's canonical Page Builder layout data. Returns layouts from both classic (meta-stored) and Layout Block storage; the 'source' field reports which storage path(s) supplied data. Each layouts entry is labelled with its storage ('meta' or 'block') and block_index (null for the classic layout; the 0-based ordinal among Layout Blocks otherwise). Pass that block_index to layout-update to write a specific Layout Block.", 'siteorigin-panels' ),
'category' => 'siteorigin-panels',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'post_id' => array(
'type' => 'integer',
'description' => __( 'Post ID of the layout to read.', 'siteorigin-panels' ),
'minimum' => 1,
),
),
'required' => array( 'post_id' ),
'additionalProperties' => false,
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'post_id' => array( 'type' => 'integer' ),
'source' => array(
'type' => 'string',
'enum' => array( 'meta', 'block', 'mixed', 'none' ),
),
'layouts' => array(
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'storage' => array(
'type' => 'string',
'enum' => array( 'meta', 'block' ),
),
'block_index' => array(
'type' => array( 'integer', 'null' ),
),
'panels_data' => array( 'type' => 'object' ),
),
),
),
),
),
'permission_callback' => array( $this, 'layout_get_permission' ),
'execute_callback' => array( $this, 'layout_get' ),
'meta' => array(
'readonly' => true,
'show_in_rest' => true,
),
)
);
wp_register_ability(
'siteorigin-panels/layout-update',
array(
'label' => __( 'Update Page Builder layout', 'siteorigin-panels' ),
'description' => __( "Writes a post's Page Builder layout — either the classic (meta-stored) layout, or a specific block-stored Layout Block selected by block_index (the 0-based index from layout-get). The incoming layout is re-sanitized through Page Builder's widget sanitizer before being persisted, so input is never trusted raw. When a post has multiple Layout Blocks, block_index is required; if it is missing or out of range the call declines as 'block-ambiguous' rather than guessing.", 'siteorigin-panels' ),
'category' => 'siteorigin-panels',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'post_id' => array(
'type' => 'integer',
'description' => __( 'Post ID of the layout to update.', 'siteorigin-panels' ),
'minimum' => 1,
),
'panels_data' => array(
'type' => 'object',
'description' => __( 'Canonical panels_data to persist (widgets, grids, grid_cells).', 'siteorigin-panels' ),
),
'block_index' => array(
'type' => 'integer',
'description' => __( 'For block-stored posts, the 0-based index (from layout-get) of the Layout Block to write. Optional for a single-block post (defaults to 0); required when the post has multiple Layout Blocks. Ignored for classic/meta posts.', 'siteorigin-panels' ),
'minimum' => 0,
),
),
'required' => array( 'post_id', 'panels_data' ),
'additionalProperties' => false,
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'post_id' => array( 'type' => 'integer' ),
'updated' => array( 'type' => 'boolean' ),
'source' => array(
'type' => 'string',
'enum' => array( 'meta', 'block', 'block-ambiguous', 'unsupported' ),
),
'block_index' => array( 'type' => 'integer' ),
'message' => array( 'type' => 'string' ),
),
),
'permission_callback' => array( $this, 'layout_update_permission' ),
'execute_callback' => array( $this, 'layout_update' ),
'meta' => array(
'show_in_rest' => true,
),
)
);
}
/**
* Permission check for siteorigin-panels/layout-update.
*
* Authorization, not just authentication: the caller must be able to edit the
* target post to update its layout (mirrors the read seam's check).
*
* @since {NEXT_VERSION}
* @api
*
* @param array $input Ability input — expects post_id.
*
* @return bool|WP_Error
*/
public function layout_update_permission( $input ) {
$post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'siteorigin_panels_cannot_update_layout',
__( 'Sorry, you are not allowed to update this layout.', 'siteorigin-panels' )
);
}
return true;
}
/**
* Execute siteorigin-panels/layout-update.
*
* Persists either a classic (meta-stored) layout or a specific block-stored
* Layout Block. Routing mirrors exactly what layout-get advertises:
* - `block_index` omitted (null) AND the post has a meta layout → write meta.
* This honors the `{ storage:'meta', block_index:null }` entry layout-get
* surfaces, so a mixed (meta + block) post can still update its meta layout.
* - otherwise, if the post has Layout Block(s) → write the targeted block
* (block_index resolved against the SAME shared walk; ambiguity is declined,
* never guessed).
* - otherwise (no blocks) → meta path.
* The incoming layout MUST traverse process_raw_widgets() before persist — the
* same §3 guarantee every other write path enforces; input is never trusted raw.
*
* @since {NEXT_VERSION}
* @api
*
* @param array $input Ability input — expects post_id, panels_data; optional block_index.
*
* @return array|WP_Error
*/
public function layout_update( $input ) {
$post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
$block_index = isset( $input['block_index'] ) && is_numeric( $input['block_index'] ) ? (int) $input['block_index'] : null;
// Defense in depth: the ability framework already gates this via
// layout_update_permission(), but re-check here so a direct in-process
// caller (e.g. a premium addon calling this method) cannot bypass the
// capability. Same code/message as the permission callback.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'siteorigin_panels_cannot_update_layout',
__( 'Sorry, you are not allowed to update this layout.', 'siteorigin-panels' )
);
}
// Reject a present-but-wrong-type panels_data rather than silently
// collapsing it to array() — which on the meta path would delete the
// existing classic layout and report success. Absent panels_data still
// defaults to array() (a legitimate "clear the layout" request).
if ( isset( $input['panels_data'] ) && ! is_array( $input['panels_data'] ) ) {
return array(
'post_id' => $post_id,
'updated' => false,
'source' => 'unsupported',
'message' => __( 'The panels_data must be provided as an object.', 'siteorigin-panels' ),
);
}
$panels_data = isset( $input['panels_data'] ) ? $input['panels_data'] : array();
$post = get_post( $post_id );
if ( empty( $post ) ) {
return array(
'post_id' => $post_id,
'updated' => false,
'source' => 'unsupported',
'message' => __( 'Layout not found.', 'siteorigin-panels' ),
);
}
$old_panels_data = get_post_meta( $post_id, 'panels_data', true );
$has_meta_layout = ! empty( $old_panels_data );
// No block_index given AND a meta layout exists → write meta. This covers
// the pure-classic post and honors the meta entry layout-get advertises on
// a mixed post (so its classic layout stays writable).
if ( $block_index === null && $has_meta_layout ) {
return $this->update_meta_layout( $post_id, $panels_data, $old_panels_data );
}
// Block-stored: write the targeted Layout Block (never guess on ambiguity).
$block_count = $this->count_layout_blocks( $post );
if ( $block_count > 0 ) {
return $this->update_block_layout( $post, $block_count, $block_index, $panels_data );
}
// No TARGETABLE top-level block, but the post visibly contains a Layout
// Block the walk could not target (nested inside a container block, or with
// empty panelsData). Decline: writing a classic meta layout here would
// create a NEW competing layout while the untargetable block keeps
// rendering — duplicate content. (A mixed post with a real meta layout was
// already handled by the block_index === null && has_meta branch above; a
// post with a qualifying top-level block took the block path above.)
if (
function_exists( 'has_block' ) &&
has_block( SiteOrigin_Panels_AI_Exposure::single()->layout_block_name(), $post )
) {
return array(
'post_id' => $post_id,
'updated' => false,
'source' => 'unsupported',
'message' => __( 'This post contains Layout Block(s) that cannot be targeted (nested inside another block, or without layout data). Writing a classic layout here could create duplicate content.', 'siteorigin-panels' ),
);
}
// No blocks present → meta path (creates/updates the classic layout).
return $this->update_meta_layout( $post_id, $panels_data, $old_panels_data );
}
/**
* Write the classic (meta-stored) layout.
*
* Mirrors the persist semantics of the classic save (admin.php save_post):
* re-sanitizes via process_raw_widgets(), runs the sidebars-emulator when
* enabled, applies the public siteorigin_panels_data_pre_save filter, and —
* like save_post — DELETES the meta when the sanitized layout has no widgets
* and no grids (rather than persisting an empty layout). §3: input is never
* persisted raw; the value is double-slashed because update_post_meta()
* wp_unslash()es its input.
*
* @param int $post_id The post to write to.
* @param array $panels_data Incoming canonical panels_data.
* @param mixed $old_panels_data Existing meta value (any scalar/array from get_post_meta).
*
* @return array The layout-update result array.
*/
/**
* Coerce every entry of $panels_data['widgets'] to an array so no object- or
* scalar-shaped widget can slip past process_raw_widgets()'s
* `if ( ! is_array( $widget ) ) continue;` skip (inc/admin.php) and
* kses_deep()'s non-array passthrough and reach persistence unsanitized.
*
* Casting a stdClass widget ENTRY to an assoc array is sufficient: once the
* entry is an array, process_raw_widgets() runs the widget's update() over it
* and kses_deep() recurses its string leaves. kses_deep() recurses only
* is_array values, so a stdClass nested INSIDE a widget (e.g. an object-valued
* setting) would still be skipped by the floor — that deeper case is out of
* scope here: it is not the reported finding, widget schema values are
* scalars/arrays not objects, and casting arbitrarily deep risks corrupting
* legitimate structures.
*
* Reachable only from the AI direct-caller path; classic/import/live-editor
* saves receive $_POST/editor JSON already decoded to arrays.
*
* @param array $panels_data Incoming canonical panels_data.
* @return array Panels_data with every widgets entry normalized to an array.
*/
protected function normalize_widget_entries( $panels_data ) {
if ( empty( $panels_data['widgets'] ) || ! is_array( $panels_data['widgets'] ) ) {
return $panels_data;
}
$normalized = array();
foreach ( $panels_data['widgets'] as $widget ) {
if ( is_object( $widget ) ) {
$normalized[] = (array) $widget;
} elseif ( is_array( $widget ) ) {
$normalized[] = $widget;
}
// Scalars / null are dropped: not a valid widget shape, never persisted.
}
$panels_data['widgets'] = $normalized;
return $panels_data;
}
protected function update_meta_layout( $post_id, $panels_data, $old_panels_data ) {
$admin = SiteOrigin_Panels_Admin::single();
// Strip any inbound signature so a client-supplied or copied
// 'sanitize_signature' can never be persisted into meta and round-trip
// back out via layout-get looking like a real signature. Nothing signs or
// verifies meta, so this is hygiene — mirrors sanitize_panels_data()'s
// strip on the block path.
unset( $panels_data['sanitize_signature'] );
// Fetch the post up-front so it can be passed to the pre-save filter and
// reused for the copy-content refresh.
$post = get_post( $post_id );
// get_post_meta() can return a non-array scalar (e.g. '') — normalize so the
// ['widgets'] read below is explicit and future-proof.
$old_panels_data = is_array( $old_panels_data ) ? $old_panels_data : array();
// Coerce object/scalar widget entries to arrays so none slips past the
// sanitizer (see normalize_widget_entries()).
$panels_data = $this->normalize_widget_entries( $panels_data );
$panels_data['widgets'] = $admin->process_raw_widgets(
! empty( $panels_data['widgets'] ) ? $panels_data['widgets'] : array(),
! empty( $old_panels_data['widgets'] ) ? $old_panels_data['widgets'] : false,
false
);
// Sidebars-emulator parity (admin.php save_post): generate sidebar widget IDs
// when the setting is on, between sanitize passes.
if ( siteorigin_panels_setting( 'sidebars-emulator' ) ) {
$panels_data['widgets'] = SiteOrigin_Panels_Sidebars_Emulator::single()->generate_sidebar_widget_ids( $panels_data['widgets'], $post_id );
}
$panels_data = SiteOrigin_Panels_Styles_Admin::single()->sanitize_all( $panels_data );
// Apply the same public pre-save filter save_post applies, so third-party
// pre-save transforms run on ability writes too.
$panels_data = apply_filters( 'siteorigin_panels_data_pre_save', $panels_data, $post, $post_id );
// Unconditional kses floor for the AI meta write (Audit #1 fix 1b): the
// whole write is AI-originated, and AI output is prompt-injectable no
// matter whose credential carries the request — the author's
// unfiltered_html capability must not exempt it. Applied AFTER sanitize
// and the pre-save filter (widget update() output is what persists;
// filter-injected content is floored too), directly at this call site:
// nothing signs meta, so no chokepoint flag is involved. Classic render
// trusts stored meta, which makes this write-time floor the only floor
// this surface gets.
if ( ! empty( $panels_data['widgets'] ) ) {
$panels_data['widgets'] = SiteOrigin_Panels_Admin::kses_deep( $panels_data['widgets'] );
}
// Empty-layout parity (admin.php save_post): a layout with no widgets and no
// grids means "clear the layout" — delete the meta rather than storing an
// empty layout, and skip the copy-content refresh.
if ( empty( $panels_data['widgets'] ) && empty( $panels_data['grids'] ) ) {
delete_post_meta( $post_id, 'panels_data' );
return array(
'post_id' => $post_id,
'updated' => true,
'source' => 'meta',
'message' => __( 'Layout cleared.', 'siteorigin-panels' ),
);
}
// update_post_meta() wp_unslash()es its input, so backslashes (e.g. a
// namespaced widget class 'SiteOrigin\Widget\Foo', or content like C:\path)
// must be double-slashed first — same as save_post() and the home-page save.
update_post_meta( $post_id, 'panels_data', map_deep( $panels_data, array( 'SiteOrigin_Panels_Admin', 'double_slash_string' ) ) );
// Refresh the copy-content post_content mirror through the SAME code a human
// editor save uses, so on copy-content sites this programmatic write is not
// stale until the next save. No-op when the copy-content setting is off.
// Guarded: copy_content_to_post() may call wp_update_post(), which re-fires
// save_post(); with_save_guard() sets/restores $in_save_post so that nested
// save early-returns exactly as it does for the editor's own copy write.
// NOT called for block writes (write_block_layout) — block layouts render
// dynamically and have no stale post_content mirror.
if ( ! empty( $post ) ) {
$admin->with_save_guard(
function () use ( $admin, $post, $post_id, $panels_data ) {
$admin->copy_content_to_post( $post, $post_id, $panels_data );
}
);
}
return array(
'post_id' => $post_id,
'updated' => true,
'source' => 'meta',
);
}
/**
* Resolve which Layout Block to write, then write it.
*
* Selection rules (never guess on ambiguity):
* - exactly ONE block: block_index defaults to 0; a non-zero index is declined.
* - MORE THAN ONE block: block_index is REQUIRED and must be in range; a missing
* or out-of-range index is declined as 'block-ambiguous' with the valid range.
*
* The resolved write routes through the compat save chokepoint — see
* write_block_layout() for the sanitize/forced-floor contract and the
* `siteorigin_panels_ai_block_layout_pre_save` layered-transform note.
*
* @param WP_Post $post The post being written.
* @param int $block_count Number of qualifying Layout Blocks in the post.
* @param int|null $block_index Caller-supplied index, or null when omitted.
* @param array $panels_data Canonical panels_data to persist.
*
* @return array The layout-update result array.
*/
protected function update_block_layout( $post, $block_count, $block_index, $panels_data ) {
$max_index = $block_count - 1;
if ( $block_count === 1 ) {
if ( $block_index !== null && $block_index !== 0 ) {
return array(
'post_id' => (int) $post->ID,
'updated' => false,
'source' => 'block',
'message' => __( 'This post has a single Layout Block; block_index must be 0 or omitted.', 'siteorigin-panels' ),
);
}
$block_index = 0;
} else {
// More than one block — an explicit, in-range index is mandatory.
if ( $block_index === null || $block_index < 0 || $block_index > $max_index ) {
return array(
'post_id' => (int) $post->ID,
'updated' => false,
'source' => 'block-ambiguous',
'message' => sprintf(
/* translators: %s: valid block_index range, e.g. "0-2". */
__( 'This post has multiple Layout Blocks; a valid block_index is required. Valid indices: %s.', 'siteorigin-panels' ),
'0-' . $max_index
),
);
}
}
$written = $this->write_block_layout( $post, $block_index, $panels_data );
if ( is_wp_error( $written ) ) {
// A genuine save failure: index was valid but the post update did not
// persist. Report a non-silent block failure (not ambiguity).
if ( $written->get_error_code() === 'block_write_failed' ) {
return array(
'post_id' => (int) $post->ID,
'updated' => false,
'source' => 'block',
'message' => __( 'The layout could not be saved.', 'siteorigin-panels' ),
);
}
// 'block_index_not_found' is purely defensive: after the get/write walk
// unification an in-range index always resolves, so this is effectively
// unreachable here. Treat any residual miss as ambiguous with the range.
return array(
'post_id' => (int) $post->ID,
'updated' => false,
'source' => 'block-ambiguous',
'message' => sprintf(
/* translators: %s: valid block_index range, e.g. "0-2". */
__( 'Requested block_index could not be resolved. Valid indices: %s.', 'siteorigin-panels' ),
'0-' . $max_index
),
);
}
return array(
'post_id' => (int) $post->ID,
'updated' => true,
'source' => 'block',
'block_index' => $written,
);
}
/**
* The ordered qualifying Layout Block layouts for a post.
*
* Delegates to the SINGLE shared walk on SiteOrigin_Panels_AI_Exposure so the
* count, the index layout-get emits, and the write target are all derived
* identically (including the post-`siteorigin_panels_data`-filter emptiness
* test). This is what guarantees a chosen block_index can never resolve to a
* different block between get and update.
*
* @param WP_Post $post The post to inspect.
*
* @return array Ordered list of { block_index, block_key, panels_data }.
*/
protected function qualifying_block_layouts( $post ) {
return SiteOrigin_Panels_AI_Exposure::single()->get_qualifying_block_layouts( $post );
}
/**
* Count the qualifying Layout Blocks in a post.
*
* @param WP_Post $post The post to inspect.
*
* @return int
*/
protected function count_layout_blocks( $post ) {
return count( $this->qualifying_block_layouts( $post ) );
}
/**
* Whether a post stores its layout in a Layout Block.
*
* @param WP_Post $post The post to inspect.
*
* @return bool
*/
protected function post_has_layout_block( $post ) {
return $this->count_layout_blocks( $post ) > 0;
}
/**
* Write a sanitized layout into a specific qualifying Layout Block.
*
* Targets the block whose 0-based ordinal among QUALIFYING Layout Blocks equals
* $block_index — resolved from the SAME shared walk read_layouts() labels with,
* so get and update never disagree about which block an index means. The walk
* returns each block's original parse_blocks() key, so we mutate exactly that
* block.
*
* §3: the incoming layout is routed through the compat save CHOKEPOINT
* (SiteOrigin_Panels_Compat_Layout_Block::sanitize_block_untrusted()) — the
* SAME path every Layout Block save uses: the
* `siteorigin_panels_ai_block_layout_pre_save` filter, strict sanitize
* (process_raw_widgets + sanitize_all), and the kses floor FORCED regardless
* of the credential's `unfiltered_html` capability (AI output is
* origin-untrusted, whatever authenticates the request). AI input is never
* persisted raw, and this is a SINGLE sanitize pass by design: the
* wp_insert_post_data safety net recognizes the block as already sanitized
* this request via the request-local memo in sanitize_block() and skips the
* second pass, so widget update() never runs twice per write.
*
* NOTE for premium-addon authors (layered transforms): because the write
* goes through the chokepoint, any consumer hooked to
* `siteorigin_panels_ai_block_layout_pre_save` transforms THIS write too.
*
* Only the target block is replaced — every other block is left
* byte-identical — then post_content is re-serialized and saved.
*
* @param WP_Post $post The post to write to.
* @param int $block_index 0-based qualifying-block ordinal to target.
* @param array $panels_data Canonical panels_data to persist into that block.
*
* @return int|WP_Error Matched block index on success; WP_Error 'block_index_not_found'
* when no qualifying block has that index,
* 'layout_block_unsupported' when the compat chokepoint class
* is unavailable, or 'block_write_failed' when the post update
* did not persist.
*/
protected function write_block_layout( $post, $block_index, $panels_data ) {
$qualifying = $this->qualifying_block_layouts( $post );
$target_key = null;
foreach ( $qualifying as $entry ) {
if ( $entry['block_index'] === $block_index ) {
$target_key = $entry['block_key'];
break;
}
}
if ( $target_key === null ) {
return new WP_Error( 'block_index_not_found', __( 'Requested block index not found.', 'siteorigin-panels' ) );
}
// The chokepoint class is required to sanitize the write and apply the
// forced kses floor. It is loaded behind function_exists(
// 'register_block_type' ), so mirror ai-exposure.php's class_exists guard
// and decline rather than fatal.
if ( ! class_exists( 'SiteOrigin_Panels_Compat_Layout_Block' ) ) {
return new WP_Error( 'layout_block_unsupported', __( 'Layout Block saving is not available on this site.', 'siteorigin-panels' ) );
}
// §3 — route the raw incoming layout through the save chokepoint with the
// kses floor forced (see docblock). Replace ONLY the target block (the
// walk's block_key indexes this same parse_blocks() array); all other
// blocks untouched.
// Coerce object/scalar widget entries to arrays so none slips past the
// sanitizer inside the chokepoint (see normalize_widget_entries()).
$panels_data = $this->normalize_widget_entries( $panels_data );
$blocks = parse_blocks( $post->post_content );
$blocks[ $target_key ]['attrs']['panelsData'] = $panels_data;
$blocks[ $target_key ] = SiteOrigin_Panels_Compat_Layout_Block::single()->sanitize_block_untrusted( $blocks[ $target_key ] );
// wp_update_post()/wp_insert_post() run wp_unslash() on their input, so the
// content MUST be slashed first — otherwise the backslash in every JSON
// escape that serialize_blocks() produces (e.g. < for '<') is stripped,
// corrupting all markup in the stored block attributes. Core's own REST
// controller slashes for the same reason.
$result = wp_update_post(
array(
'ID' => $post->ID,
'post_content' => wp_slash( serialize_blocks( $blocks ) ),
),
true
);
// No silent success: wp_update_post returns 0 or a WP_Error on failure.
if ( empty( $result ) || is_wp_error( $result ) ) {
return new WP_Error( 'block_write_failed', __( 'The layout could not be saved.', 'siteorigin-panels' ) );
}
return $block_index;
}
/**
* Permission check for siteorigin-panels/layout-get.
*
* Authorization, not just authentication: the caller must be able to edit the
* target post to read its layout (mirrors the read REST route).
*
* @since {NEXT_VERSION}
* @api
*
* @param array $input Ability input — expects post_id.
*
* @return bool|WP_Error
*/
public function layout_get_permission( $input ) {
$post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'siteorigin_panels_cannot_read_layout',
__( 'Sorry, you are not allowed to read this layout.', 'siteorigin-panels' )
);
}
return true;
}
/**
* Execute siteorigin-panels/layout-get.
*
* Returns the SAME { post_id, source, layouts } shape as the read REST route
* by delegating to the shared reader, so REST and ability consumers never drift.
*
* @since {NEXT_VERSION}
* @api
*
* @param array $input Ability input — expects post_id.
*
* @return array|WP_Error
*/
public function layout_get( $input ) {
$post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
// Defense in depth: the framework gates this via layout_get_permission(),
// but re-check so a direct in-process caller cannot bypass the capability.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'siteorigin_panels_cannot_read_layout',
__( 'Sorry, you are not allowed to read this layout.', 'siteorigin-panels' )
);
}
return SiteOrigin_Panels_AI_Exposure::single()->read_layouts( $post_id );
}
}