widgets = $widgets; } /** `zadex/documents/save_body` (RestController::save_document): only acts for content-only users. */ public static function filter_save($body, $post_id, $request = null) { if (!is_array($body) || Access::mode() !== Access::CONTENT) { return $body; } return (new self(Plugin::instance()->widgets))->guard_body($body, (int) $post_id); } /** * @param array $body the REST body {data, css, pageSettings?, template?, status?} * @return array|WP_Error the body to save (CSS / page settings / template pinned to the saved ones) */ public function guard_body(array $body, int $post_id) { $sanitizer = new Sanitizer($this->widgets); $incoming = $sanitizer->sanitize_document(is_array($body['data'] ?? null) ? $body['data'] : []); $check = $this->check(Document::get_data($post_id), $incoming); if ($check instanceof WP_Error) { return $check; } if (isset($body['pageSettings']) && is_array($body['pageSettings'])) { $saved = Document::get_page_settings($post_id); $saved = isset($saved['settings']) && is_array($saved['settings']) ? $saved['settings'] : $saved; $in = isset($body['pageSettings']['settings']) && is_array($body['pageSettings']['settings']) ? $body['pageSettings']['settings'] : $body['pageSettings']; if (!self::same_settings($sanitizer->sanitize_settings_generic($saved), $sanitizer->sanitize_settings_generic($in), ['page_template', 'css'])) { return self::error('zadex_content_only_page', __('Your role can only edit content: page settings cannot be changed.', 'zadex')); } } if (array_key_exists('template', $body) && is_string($body['template']) && Templates::sanitize($body['template']) !== Templates::get($post_id)) { return self::error('zadex_content_only_page', __('Your role can only edit content: the page layout cannot be changed.', 'zadex')); } unset($body['pageSettings'], $body['template']); $body['css'] = Document::get_css($post_id); return $body; } /** * Compares two sanitized documents. Returns true when every difference is a content change. * * @return true|WP_Error */ public function check(array $saved, array $incoming) { $before = self::flatten(is_array($saved['elements'] ?? null) ? $saved['elements'] : []); $after = self::flatten(is_array($incoming['elements'] ?? null) ? $incoming['elements'] : []); if (array_map(static fn(array $e): array => [$e['type'], $e['parent'], $e['index']], $before) !== array_map(static fn(array $e): array => [$e['type'], $e['parent'], $e['index']], $after) || array_keys($before) !== array_keys($after)) { return self::error('zadex_content_only_structure', __('Your role can only edit the content of existing elements: adding, removing or moving elements is not allowed.', 'zadex')); } foreach ($before as $id => $old) { $new = $after[$id]; if (self::canon($old['props']) !== self::canon($new['props'])) { return self::error('zadex_content_only_props', __('Your role can only edit content: element options (name, visibility, lock, classes) cannot be changed.', 'zadex')); } $changed = self::changed_keys($old['settings'], $new['settings']); if (!$changed) { continue; } if (!empty($old['props']['designLocked'])) { return self::error('zadex_design_locked', __('This element is design-locked: your role cannot change it.', 'zadex'), ['element' => $id]); } $controls = $this->widgets->controls($old['type']); foreach ($changed as $key) { $base = preg_replace('/(::[a-z_]+)?(@[a-z_]+)?$/', '', $key) ?? $key; if (!self::is_content_control($controls[$base] ?? null)) { return self::error( 'zadex_content_only_style', /* translators: 1: setting key, 2: element id */ sprintf(__('Your role can only edit content: the design setting "%1$s" of element %2$s cannot be changed.', 'zadex'), $key, $id), ['element' => $id, 'setting' => $key] ); } } } return true; } /** A control whose value is content (text, media, links ...), not design. */ public static function is_content_control($control): bool { if (!is_array($control)) { return false; } if (($control['tab'] ?? 'content') !== 'content' || !empty($control['css'])) { return false; } $type = (string) ($control['type'] ?? ''); if ($type === 'structure' || ($type === 'code' && ($control['language'] ?? '') === 'css')) { return false; } return true; } /** * @param array $elements * @return array */ public static function flatten(array $elements, string $parent = ''): array { $out = []; foreach (array_values($elements) as $i => $el) { if (!is_array($el) || !isset($el['id'])) { continue; } $id = (string) $el['id']; $props = []; foreach (['label', 'hidden', 'locked', 'classes', 'designLocked'] as $prop) { if (!empty($el[$prop])) { $props[$prop] = $el[$prop]; } } $out[$id] = [ 'type' => (string) ($el['type'] ?? ''), 'parent' => $parent, 'index' => $i, 'props' => $props, 'settings' => is_array($el['settings'] ?? null) ? $el['settings'] : [], ]; if (!empty($el['children']) && is_array($el['children'])) { $out += self::flatten($el['children'], $id); } } return $out; } /** @return array keys whose value differs (missing and present-but-empty are different) */ public static function changed_keys(array $old, array $new): array { $keys = array_unique(array_merge(array_map('strval', array_keys($old)), array_map('strval', array_keys($new)))); $out = []; foreach ($keys as $key) { $a = array_key_exists($key, $old) ? self::canon($old[$key]) : null; $b = array_key_exists($key, $new) ? self::canon($new[$key]) : null; if ($a !== $b) { $out[] = $key; } } return $out; } /** @param array $ignore */ public static function same_settings(array $a, array $b, array $ignore = []): bool { foreach ($ignore as $key) { unset($a[$key], $b[$key]); } return self::changed_keys($a, $b) === []; } /** Order-insensitive encoding for associative arrays (JS may reorder object keys). */ public static function canon($value): string { $sort = static function ($v) use (&$sort) { if (!is_array($v)) { return $v; } $v = array_map($sort, $v); if (array_keys($v) !== range(0, count($v) - 1)) { ksort($v); } return $v; }; return (string) wp_json_encode($sort($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION); } private static function error(string $code, string $message, array $data = []): WP_Error { return new WP_Error($code, $message, ['status' => 403] + $data); } }