create-block-theme/includes/create-theme/theme-settings-save.php
Birgit Pauli-Haack 87fe02c7bb
Some checks failed
Run checks / Compute previous WordPress version (push) Successful in 2s
Run checks / PHP 7.4 (push) Failing after 1s
Run checks / E2E Tests (push) Failing after 5s
Run checks / PHP 8.0 (push) Failing after 1s
Run checks / PHP 7.4 (WP previous major version) (push) Failing after 2s
Run checks / PHP 8.0 (WP previous major version) (push) Failing after 1s
Run checks / PHP 8.1 (push) Failing after 0s
Run checks / PHP 8.1 (WP previous major version) (push) Failing after 1s
Run checks / PHP 8.2 (push) Failing after 1s
Run checks / PHP 8.2 (WP previous major version) (push) Failing after 1s
Run checks / PHP 8.3 (push) Failing after 0s
Run checks / PHP 8.3 (WP previous major version) (push) Failing after 1s
Run checks / Lint (push) Failing after 19s
Add /theme-settings REST endpoint + CBT_Theme_Settings_Save service (#843)
Add /theme-settings REST endpoint and CBT_Theme_Settings_Save service

Server-side foundation for the Edit Theme Settings modal. New
POST /create-block-theme/v1/theme-settings route persists a partial-
theme.json payload from the modal into the active theme's theme.json.

Behavior:
- Validates payload shape (allowlisted top-level keys, JSON object/list
  enforcement, required entry keys, slug format).
- Context-aware sanitization: text fields (name/title/label/description)
  go through `sanitize_text_field`; slug fields through `sanitize_key`;
  CSS-bearing values (shadow/color/gradient/fontFamily) through
  `wp_kses_no_nul`l so multi-segment CSS round-trips intact.
- Deep-merges into the existing theme.json using JSON Merge Patch
  semantics (RFC 7396): null deletes a key, empty object is a no-op,
  empty list clears an existing list, lists replace wholesale.
- Translates `removedShadowDefaults` into settings.shadow.defaultPresets:
  false plus the kept core defaults re-registered as customs. Preserves
  user-defined presets and is idempotent.
- Atomic write with a per-request temp file + rename. wp_json_encode
  failure short-circuits before touching theme.json.
- Concurrent saves are serialized via flock() on a temp-dir lockfile
  scoped by theme slug, so disjoint edits compose correctly.

Returns the merged theme.json so the client can refresh without a
second fetch. Capability gate: edit_theme_options.

Part of #836. Closes #837.
2026-05-21 13:31:14 +02:00

532 lines
19 KiB
PHP

<?php
/**
* Theme Settings Save
*
* Persists Edit Theme Settings modal payloads to the active theme's theme.json.
* Accepts a partial-`theme.json` payload (only the keys the user edited),
* deep-merges it into the existing file using JSON Merge Patch semantics
* (RFC 7396), and writes the result. Reifies the `removedShadowDefaults`
* operational key into the standard `settings.shadow.defaultPresets` +
* `settings.shadow.presets` shape.
*
* Callers must enforce capability checks (`edit_theme_options`) before
* invoking `run()`. The service performs payload-shape validation and
* sanitization but does not authenticate.
*
* Concurrent saves on a single host are serialized via `flock()` on a
* sibling lockfile (`theme.json.lock`), so disjoint edits from two clients
* compose correctly. The remaining limitation is conflicting same-key
* edits, which are still last-write-wins (the second save sees the first
* save's state after taking the lock, merges its payload on top, and the
* conflicting field gets the second client's value). Distributed hosts
* where `flock()` is not honored (some NFS configurations) degrade to
* best-effort. ETag / If-Match handling is a candidate follow-up if the
* remaining conflict case becomes a problem in practice.
*
* @package Create_Block_Theme
* @see https://datatracker.ietf.org/doc/html/rfc7396 JSON Merge Patch
*/
class CBT_Theme_Settings_Save {
const ALLOWED_TOP_LEVEL_KEYS = array(
'settings',
'customTemplates',
'templateParts',
'removedShadowDefaults',
);
/**
* Keys whose string values are user-facing labels and benefit from
* `sanitize_text_field()` (HTML stripping, whitespace normalization).
*
* This is an allowlist. Adding a new label-class field to theme.json
* upstream means adding it here — otherwise it falls through to
* `wp_kses_no_null` and HTML will not be stripped. Trade-off accepted:
* the inverse rule ("strip HTML by default, allowlist CSS-value fields")
* silently corrupts CSS for new fields, which is worse than missing a
* label sanitization update.
*/
const TEXT_FIELD_KEYS = array( 'name', 'title', 'label', 'description' );
/**
* Keys whose string values are slug-formatted and pass through `sanitize_key()`.
* Used for both leaf scalars and entries of slug-list keys (e.g., `postTypes`).
*/
const SLUG_FIELD_KEYS = array( 'slug', 'area' );
/**
* Keys whose values are lists of slug strings (the entries — not the key — are slugs).
*/
const SLUG_LIST_KEYS = array( 'postTypes' );
/**
* Persist a partial theme.json payload to the active theme's theme.json.
*
* @param array $payload Partial-theme.json payload from the modal.
* @return array|WP_Error Merged theme.json on success, WP_Error on validation
* or write failure.
*/
public static function run( array $payload ) {
$validated = self::validate( $payload );
if ( is_wp_error( $validated ) ) {
return $validated;
}
$sanitized = self::sanitize( $validated );
// Serialize concurrent saves on a single host: hold an exclusive lock
// on a process-temp lockfile (scoped by theme slug) around the
// read-merge-write sequence. This prevents two requests from reading
// stale state, merging in parallel, and one overwriting the other's
// disjoint changes. On distributed hosts where flock isn't honored
// (some NFS configs) this degrades to best-effort, hence the
// documented limitation.
//
// Stored in `get_temp_dir()` rather than the theme directory so the
// file isn't included in theme exports.
$lock_path = get_temp_dir() . 'cbt-theme-settings-' . md5( get_stylesheet() ) . '.lock';
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
$lock_handle = @fopen( $lock_path, 'c' );
if ( false === $lock_handle ) {
return new WP_Error(
'cbt_lock_failed',
__( 'Could not acquire theme.json save lock. Check filesystem permissions on the active theme directory.', 'create-block-theme' ),
array( 'status' => 503 )
);
}
if ( ! flock( $lock_handle, LOCK_EX ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
fclose( $lock_handle );
return new WP_Error(
'cbt_lock_failed',
__( 'Could not acquire theme.json save lock.', 'create-block-theme' ),
array( 'status' => 503 )
);
}
try {
$current = CBT_Theme_JSON_Resolver::get_theme_file_contents();
if ( ! is_array( $current ) ) {
$current = array();
}
$merged = self::merge( $current, $sanitized );
// `isset()` (not `array_key_exists()`): the operational reify only
// runs when a real array of slugs was supplied. A `null` value at
// this key is treated as absent — equivalent to omitting the key
// from the payload entirely. This prevents `removedShadowDefaults:
// null` from reaching `reify_shadow_removals()` and violating its
// `array $removed_slugs` type.
if ( isset( $sanitized['removedShadowDefaults'] ) ) {
$merged = self::reify_shadow_removals( $merged, $sanitized['removedShadowDefaults'] );
}
$wrote = CBT_Theme_JSON_Resolver::write_theme_file_contents( $merged );
} finally {
flock( $lock_handle, LOCK_UN );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
fclose( $lock_handle );
}
if ( true !== $wrote ) {
return new WP_Error(
'cbt_write_failed',
__( 'Failed to write theme.json. Check filesystem permissions on the active theme directory.', 'create-block-theme' ),
array( 'status' => 500 )
);
}
return $merged;
}
/**
* Validate payload shape. Rejects unknown top-level keys and shape
* mismatches. Returns the payload unchanged on success. The operational
* `removedShadowDefaults` key is handled later — skipped by `merge()` and
* applied separately by `reify_shadow_removals()`.
*
* @param array $payload Raw payload from the request.
* @return array|WP_Error
*/
public static function validate( array $payload ) {
foreach ( $payload as $key => $value ) {
if ( ! in_array( $key, self::ALLOWED_TOP_LEVEL_KEYS, true ) ) {
return new WP_Error(
'cbt_invalid_payload',
sprintf(
/* translators: %s: unknown payload key */
__( 'Unknown top-level key: %s', 'create-block-theme' ),
$key
),
array( 'status' => 400 )
);
}
}
if ( isset( $payload['settings'] ) ) {
if ( ! is_array( $payload['settings'] ) ) {
return new WP_Error(
'cbt_invalid_payload',
__( '"settings" must be an object.', 'create-block-theme' ),
array( 'status' => 400 )
);
}
// Reject JSON lists in an object position. Empty array is
// ambiguous in PHP (both `{}` and `[]` decode to `[]`) and is
// allowed — `merge()` handles it as a no-op.
if ( ! empty( $payload['settings'] ) && self::is_list( $payload['settings'] ) ) {
return new WP_Error(
'cbt_invalid_payload',
__( '"settings" must be an object, not a list.', 'create-block-theme' ),
array( 'status' => 400 )
);
}
}
foreach ( array( 'customTemplates', 'templateParts' ) as $list_key ) {
if ( ! isset( $payload[ $list_key ] ) ) {
continue;
}
if ( ! is_array( $payload[ $list_key ] ) ) {
return new WP_Error(
'cbt_invalid_payload',
sprintf(
/* translators: %s: payload key */
__( '"%s" must be an array.', 'create-block-theme' ),
$list_key
),
array( 'status' => 400 )
);
}
// Require a JSON list, not a JSON object. Empty array is ambiguous
// in PHP and is allowed.
if ( ! empty( $payload[ $list_key ] ) && ! self::is_list( $payload[ $list_key ] ) ) {
return new WP_Error(
'cbt_invalid_payload',
sprintf(
/* translators: %s: payload key */
__( '"%s" must be a list, not an object.', 'create-block-theme' ),
$list_key
),
array( 'status' => 400 )
);
}
foreach ( $payload[ $list_key ] as $entry ) {
if ( ! is_array( $entry ) ) {
return new WP_Error(
'cbt_invalid_payload',
sprintf(
/* translators: %s: payload key */
__( 'Entries of "%s" must be objects.', 'create-block-theme' ),
$list_key
),
array( 'status' => 400 )
);
}
}
}
if ( isset( $payload['removedShadowDefaults'] ) ) {
if ( ! is_array( $payload['removedShadowDefaults'] ) ) {
return new WP_Error(
'cbt_invalid_payload',
__( '"removedShadowDefaults" must be an array of slugs.', 'create-block-theme' ),
array( 'status' => 400 )
);
}
if ( ! empty( $payload['removedShadowDefaults'] ) && ! self::is_list( $payload['removedShadowDefaults'] ) ) {
return new WP_Error(
'cbt_invalid_payload',
__( '"removedShadowDefaults" must be a list, not an object.', 'create-block-theme' ),
array( 'status' => 400 )
);
}
foreach ( $payload['removedShadowDefaults'] as $slug ) {
if ( ! is_string( $slug ) ) {
return new WP_Error(
'cbt_invalid_payload',
__( 'Entries of "removedShadowDefaults" must be strings.', 'create-block-theme' ),
array( 'status' => 400 )
);
}
if ( sanitize_key( $slug ) !== $slug || '' === $slug ) {
return new WP_Error(
'cbt_invalid_payload',
sprintf(
/* translators: %s: invalid slug */
__( 'Invalid shadow slug "%s". Slugs must be lowercase alphanumeric with dashes or underscores.', 'create-block-theme' ),
$slug
),
array( 'status' => 400 )
);
}
}
}
// Validate required keys + slug format on customTemplates and templateParts
// entries. `name` is required on both (used as the file-system slug,
// e.g. templates/<name>.html). `area` is required on templateParts;
// `title` is required on customTemplates.
$entry_required_keys = array(
'customTemplates' => array( 'name', 'title' ),
'templateParts' => array( 'name', 'area' ),
);
foreach ( $entry_required_keys as $list_key => $required_keys ) {
if ( ! isset( $payload[ $list_key ] ) ) {
continue;
}
foreach ( $payload[ $list_key ] as $entry ) {
foreach ( $required_keys as $required_key ) {
if ( ! isset( $entry[ $required_key ] ) || ! is_string( $entry[ $required_key ] ) || '' === $entry[ $required_key ] ) {
return new WP_Error(
'cbt_invalid_payload',
sprintf(
/* translators: 1: list key, 2: required key */
__( 'Entries of "%1$s" must have a non-empty string "%2$s" field.', 'create-block-theme' ),
$list_key,
$required_key
),
array( 'status' => 400 )
);
}
}
// `name` must be a valid slug (used as filename).
if ( sanitize_key( $entry['name'] ) !== $entry['name'] ) {
return new WP_Error(
'cbt_invalid_payload',
sprintf(
/* translators: 1: list key, 2: invalid name */
__( 'Invalid "%1$s" entry name "%2$s". Names must be lowercase alphanumeric with dashes or underscores.', 'create-block-theme' ),
$list_key,
$entry['name']
),
array( 'status' => 400 )
);
}
}
}
return $payload;
}
/**
* Recursively sanitize the payload, applying a context-aware sanitizer per
* leaf based on the parent key.
*
* - `name`/`title`/`label`/`description` → `sanitize_text_field()` (HTML-stripped labels).
* - `slug`/`area` → `sanitize_key()` (already validated to be slug-safe; this is belt-and-braces).
* - Entries inside `postTypes` lists → `sanitize_key()`.
* - Everything else (CSS values like `shadow`, `color`, `gradient`, `fontFamily`)
* → `wp_kses_no_null()` (strip NULL bytes only; preserve whitespace, parens, commas).
*
* Booleans and numbers pass through unchanged.
*
* @param mixed $value Value to sanitize.
* @param string $parent_key The key under which `$value` lives (empty for the root).
* For list entries, the list's key name.
* @return mixed
*/
public static function sanitize( $value, $parent_key = '' ) {
if ( is_array( $value ) ) {
$out = array();
foreach ( $value as $k => $v ) {
// Associative-key children inherit their own key as context.
// List-entry children inherit the list's key as context.
$child_context = is_string( $k ) ? $k : $parent_key;
$out[ $k ] = self::sanitize( $v, $child_context );
}
return $out;
}
if ( is_string( $value ) ) {
if ( in_array( $parent_key, self::TEXT_FIELD_KEYS, true ) ) {
return sanitize_text_field( $value );
}
if (
in_array( $parent_key, self::SLUG_FIELD_KEYS, true ) ||
in_array( $parent_key, self::SLUG_LIST_KEYS, true )
) {
return sanitize_key( $value );
}
return wp_kses_no_null( $value );
}
return $value;
}
/**
* Deep-merge $payload into $current using JSON Merge Patch (RFC 7396)
* semantics, with one PHP-imposed accommodation.
*
* Rules:
*
* - **`null` deletes the key.** Sending `{"settings":{"color":{"custom":null}}}`
* removes `settings.color.custom` from the result. Deleting a missing
* key is a no-op.
* - **Empty object (`{}`) is a no-op.** Per RFC 7396, an empty object means
* "no change at this key." PHP cannot distinguish a JSON `{}` from a JSON
* `[]` after `json_decode(..., true)` (both become empty PHP arrays), so
* we use a heuristic: if the existing value at this key is a list, an
* empty payload value clears that list; otherwise it is treated as a
* no-op. This means clients that intend to clear a list MUST already
* know the field is list-typed (which is how the modal is built).
* - **Associative-object values are merged recursively.** Leaves replace.
* - **List values replace wholesale** — RFC 7396 does not support
* per-element list patching. To remove one palette entry, send the full
* new palette. This means a caller editing only one entry must round-trip
* the entire list, so a non-modal caller (e.g., a hypothetical WP-CLI
* command per #828) needs to read-modify-write to avoid clobbering other
* entries. Intentional under the modal-owns-canonical-list design.
* - **Missing parent keys are created** when assigning into them.
*
* The operational key `removedShadowDefaults` is skipped here — it's
* handled separately by `reify_shadow_removals()`.
*
* @param array $current
* @param array $payload
* @return array
*/
public static function merge( array $current, array $payload ) {
$result = $current;
foreach ( $payload as $key => $value ) {
if ( 'removedShadowDefaults' === $key ) {
continue;
}
// RFC 7396: null deletes the key.
if ( null === $value ) {
unset( $result[ $key ] );
continue;
}
// Empty array: RFC 7396 says `{}` is a no-op. PHP can't tell `{}`
// from `[]`, so we infer intent from the existing value:
// - existing value is a list → caller intends to clear it.
// - otherwise (existing is assoc, missing, or scalar) → no-op.
if ( is_array( $value ) && empty( $value ) ) {
if ( isset( $result[ $key ] ) && is_array( $result[ $key ] ) && self::is_list( $result[ $key ] ) ) {
$result[ $key ] = array();
}
continue;
}
// Deep merge associative-object → associative-object.
if (
is_array( $value ) &&
! self::is_list( $value ) &&
isset( $result[ $key ] ) &&
is_array( $result[ $key ] ) &&
! self::is_list( $result[ $key ] )
) {
$result[ $key ] = self::merge( $result[ $key ], $value );
} elseif ( is_array( $value ) && self::is_list( $value ) ) {
// Normalize lists to a contiguous index. Defends against any
// sparse-keyed array slipping past validation.
$result[ $key ] = array_values( $value );
} else {
$result[ $key ] = $value;
}
}
return $result;
}
/**
* Translate `removedShadowDefaults: [slug, ...]` into the theme.json shape:
* `settings.shadow.defaultPresets: false` plus the kept core shadow
* defaults re-registered under `settings.shadow.presets`. User-defined
* presets (slugs that are not core defaults) are preserved.
*
* Idempotent: running the same removal twice produces the same output.
*
* @param array $merged Merged theme.json (post `merge()`).
* @param string[] $removed_slugs Slugs of core defaults to remove.
* @return array
*/
public static function reify_shadow_removals( array $merged, array $removed_slugs ) {
$core_presets = self::get_core_shadow_presets();
if ( empty( $core_presets ) ) {
return $merged;
}
$core_slugs = array_column( $core_presets, 'slug' );
$kept = array_values(
array_filter(
$core_presets,
static function ( $preset ) use ( $removed_slugs ) {
return ! in_array( $preset['slug'], $removed_slugs, true );
}
)
);
$existing = isset( $merged['settings']['shadow']['presets'] ) && is_array( $merged['settings']['shadow']['presets'] )
? $merged['settings']['shadow']['presets']
: array();
// Strip any existing presets whose slug matches a core default slug —
// we re-register the kept defaults below, so this prevents duplication.
$user_customs = array_values(
array_filter(
$existing,
static function ( $preset ) use ( $core_slugs ) {
return isset( $preset['slug'] ) && ! in_array( $preset['slug'], $core_slugs, true );
}
)
);
if ( ! isset( $merged['settings'] ) || ! is_array( $merged['settings'] ) ) {
$merged['settings'] = array();
}
if ( ! isset( $merged['settings']['shadow'] ) || ! is_array( $merged['settings']['shadow'] ) ) {
$merged['settings']['shadow'] = array();
}
$merged['settings']['shadow']['defaultPresets'] = false;
$merged['settings']['shadow']['presets'] = array_merge( $user_customs, $kept );
return $merged;
}
/**
* Fetch the core shadow defaults via `wp_get_global_settings`. Returns an
* empty array if core does not expose shadow defaults (older WP versions
* or unusual environments) — in which case shadow reification is a no-op
* and the caller's `defaultPresets` flag round-trips literally.
*
* `wp_get_global_settings` returns presets keyed by origin
* (`['default' => [...]]`); we extract the `default` slot.
*
* @return array<int, array{slug: string, name?: string, shadow: string}>
*/
public static function get_core_shadow_presets() {
if ( ! function_exists( 'wp_get_global_settings' ) ) {
return array();
}
$presets = wp_get_global_settings( array( 'shadow', 'presets' ) );
if ( is_array( $presets ) && isset( $presets['default'] ) && is_array( $presets['default'] ) ) {
return $presets['default'];
}
return array();
}
/**
* Detect whether an array is a list (sequential integer keys starting at 0).
* Mirrors PHP 8.1+ `array_is_list()`.
*
* @param array $arr
* @return bool
*/
private static function is_list( array $arr ) {
if ( function_exists( 'array_is_list' ) ) {
return array_is_list( $arr );
}
if ( array() === $arr ) {
return true;
}
$expected = 0;
foreach ( $arr as $key => $_v ) {
if ( $key !== $expected++ ) {
return false;
}
}
return true;
}
}