Files
WP-LIT-MCP/src/Integrations/Divi/Compose/DynamicContentRules.php
T
janniclas.loosenandClaude Opus 5.5 b24eb6c3e9 fix(security): no direct HTTP access to plugin PHP files (audit L2, cheap part)
Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 19:32:12 +02:00

302 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
defined('ABSPATH') || exit;
/**
* The allow list of content references ($variable({"type":"content",…})$) that VariableRef
* delegates to: which names exist and how each of their settings is cleaned. Pure PHP.
*
* Allowed names: a non-color global variable or a theme font (no settings, DESIGN_SETTINGS,
* S27); custom_meta_<key> with META_KEY (no leading "_", ruling S12; settings from the map entry
* of that exact name, else META_SETTINGS); or a key of the dynamic content map.
*
* Dynamic content map (ruling S22), constructor argument $map:
* array<string, array<string, string>> option name => [setting key => field type]
* e.g. ['post_title' => ['before' => 'text', 'after' => 'text'], 'post_date' => [… 'date_format' => 'select']]
* Field types: "text" → ValueSanitizer::text(); "richtext" or "html" → html(); "url" → url()
* (refused when it empties a value); "select_post" → a numeric id of a post the user may read;
* "select", "yes_no_button", "number" → a scalar without markup ("<", ">"), strings cleaned with
* text(); any other type is refused (the map comes from a filter – untrusted, ruling S37).
* Default (null): DEFAULT_DYNAMIC_CONTENT. Task 5 builds the map from Divi's live registry
* DynamicContentOptions::get_options($post_id, 'edit') – option name => its "fields" (key =>
* field "type"). Always, whatever the map says: "enable_html" is rejected (VariableRef), loop_*
* names only when the map names them, custom_meta_<key> names and the values of settings whose
* key ends in "meta_key" (meta_key, select_meta_key, loop_meta_key …) obey META_KEY after Divi's
* own prefix stripping (rulings S29, S30), and no string – before or after cleaning (ruling S28)
* – may contain a reference or ")$". A map of another shape fails in the constructor with
* InvalidArgumentException.
*/
final class DynamicContentRules
{
/** custom_meta_<key>: no leading "_" – Divi renders protected meta publicly (ruling S12). */
public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/D';
/** @var array<string, array<string, string>> Placeholders::POST values and their settings. */
public const DEFAULT_DYNAMIC_CONTENT = [
'post_title' => self::BEFORE_AFTER,
'post_excerpt' => self::BEFORE_AFTER,
'post_date' => self::BEFORE_AFTER + self::DATE,
'post_modified_date' => self::BEFORE_AFTER + self::DATE,
'post_author' => self::BEFORE_AFTER,
'post_link_url' => [],
'post_featured_image' => [],
'post_categories' => self::BEFORE_AFTER,
'post_tags' => self::BEFORE_AFTER,
];
/** Settings of custom_meta_<key> without a map entry of its own. */
public const META_SETTINGS = self::BEFORE_AFTER;
/**
* Settings of content references to global variables and theme fonts: none (ruling S27).
* Divi ignores them: theme fonts resolve to var(<name>) (DynamicData.php:300), and the gvid
* option "doesn't have any settings" and renders without them
* (DynamicContentGlobalVariableOptions.php:66-69, 155-161).
*/
public const DESIGN_SETTINGS = [];
private const BEFORE_AFTER = ['before' => 'richtext', 'after' => 'richtext'];
private const DATE = ['date_format' => 'select', 'custom_date_format' => 'text'];
private const META_PREFIX = 'custom_meta_';
/**
* Prefixes Divi strips from a meta key option with str_replace – every occurrence, not just
* the leading one (DynamicContentOptionPostMetaKey.php:232,249,275,286;
* DynamicContentOptionLoopPostMetaKey.php:366-373). Ruling S30.
*/
private const META_KEY_PREFIXES = [
self::META_PREFIX,
'loop_post_meta_key_',
'loop_user_meta_key_',
'loop_term_meta_key_',
];
/** @var array<string, array<string, string>> */
private array $map;
/**
* @param array<array-key, mixed>|null $map See the class docblock.
* @throws InvalidArgumentException When the map does not have that shape.
*/
public function __construct(private DesignSystem $design, ?array $map = null)
{
$this->map = $map === null ? self::DEFAULT_DYNAMIC_CONTENT : self::checkMap($map);
}
/**
* @return array<string, string> Setting key => field type allowed for the content name.
* @throws InvalidArgumentException When the name is not allowed.
*/
public function fieldsFor(string $name): array
{
// Ruling S29: the meta key rule holds for map entries too.
if (str_starts_with($name, self::META_PREFIX)) {
if (! self::isMetaKey($name)) {
throw VariableRef::unknownName($name, 'content');
}
return $this->map[$name] ?? self::META_SETTINGS;
}
if (isset($this->map[$name])) {
return $this->map[$name];
}
if (str_starts_with($name, 'loop_')) {
throw new InvalidArgumentException(sprintf(
'Schleifen-Inhalt "%s" ist hier nicht verfügbar (nur innerhalb von Divi-Schleifen).',
$name
));
}
if (in_array($name, $this->design->contentVariableIds(), true)) {
return self::DESIGN_SETTINGS;
}
throw VariableRef::unknownName($name, 'content');
}
/**
* Cleans one content setting by its field type (rulings S21/S22). The steps, in this order:
* 1. control characters off the RAW value (S32/S32a);
* 2. the raw value must not contain a reference marker or ")$" (S2);
* 3. the field type's check and sanitizer (textSetting / selectPostSetting / scalarSetting);
* 4. the final string must not contain a marker, ")$" or control characters (S28) – only rejection;
* 5. settings named …meta_key must name an unprotected meta key (S29/S30).
*
* @throws InvalidArgumentException
*/
public static function cleanSetting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer): mixed
{
// Ruling S32/S32a: control characters go on the RAW value, before every check and the
// sanitizer ("<" + VT stays a literal "<" for strip_tags; a later strip would build a tag).
if (is_string($value)) {
$value = ControlChars::strip($value);
}
if (is_string($value) && (VariableRef::contains($value) || str_contains($value, VariableRef::END))) {
throw VariableRef::notAlone();
}
$clean = match ($fieldType) {
'text', 'richtext', 'html', 'url' => self::textSetting($key, $value, $fieldType, $sanitizer),
'select_post' => self::selectPostSetting($key, $value, $sanitizer),
'select', 'yes_no_button', 'number' => self::scalarSetting($key, $value, $sanitizer),
// Ruling S37: map types come from a filter (untrusted) – unknown types are refused.
default => throw new InvalidArgumentException(sprintf(
'Einstellung "%s" im Divi-Verweis: Feldtyp "%s" wird nicht unterstützt.',
$key,
$fieldType
)),
};
// Ruling S28: sanitizing may rebuild a marker ("$variable<x>(" → "$variable("). After the
// sanitizer the only action is rejection (S32a) – controls went on the raw value above.
if (
is_string($clean)
&& (
VariableRef::contains($clean)
|| str_contains($clean, VariableRef::END)
|| ControlChars::strip($clean) !== $clean
)
) {
throw VariableRef::notAlone();
}
// Ruling S29: meta key settings (post_meta_key's meta_key/select_meta_key, loop_meta_key …)
// name a meta key – never a protected one. Matched by the key SUFFIX "meta_key" (S29/S30):
// re-check Divi's dynamic content options for new meta key settings on every Divi version bump.
if (str_ends_with($key, 'meta_key') && $clean !== '' && ! self::isMetaKey((string) $clean)) {
throw new InvalidArgumentException(sprintf(
'Einstellung "%s" im Divi-Verweis: ungültiger Meta-Schlüssel (Buchstaben, Ziffern, "_" und "-", '
. 'nicht mit "_" am Anfang).',
$key
));
}
return $clean;
}
/**
* Checks the meta key Divi derives from $key (ruling S30): a leading known prefix is removed the
* way Divi does it – str_replace of every occurrence – and a prefix still present afterwards is
* rejected (defensive). "telefon", "custom_meta_telefon", "loop_post_meta_key_telefon" yes;
* "_edit_lock", "custom_meta__edit_lock", "custom_meta_custom_meta__edit_lock",
* "loop_post_meta_key__edit_lock", "a b" no.
*/
public static function isMetaKey(string $key): bool
{
foreach (self::META_KEY_PREFIXES as $prefix) {
if (str_starts_with($key, $prefix)) {
$key = str_replace($prefix, '', $key);
break;
}
}
foreach (self::META_KEY_PREFIXES as $prefix) {
if (str_contains($key, $prefix)) {
return false;
}
}
return preg_match(self::META_KEY, $key) === 1;
}
/**
* text, richtext/html and url settings: a string, cleaned by the matching sanitizer; a URL
* the sanitizer empties is refused.
*
* @throws InvalidArgumentException
*/
private static function textSetting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer): string
{
if (! is_string($value)) {
throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis: nur Text.', $key));
}
$clean = match ($fieldType) {
'text' => $sanitizer->text($value),
'url' => $sanitizer->url($value),
default => $sanitizer->html($value),
};
if ($fieldType === 'url' && $clean === '' && trim($value) !== '') {
throw new InvalidArgumentException(
sprintf('Einstellung "%s" im Divi-Verweis: ungültige URL.', $key)
);
}
return $clean;
}
/**
* Ruling S37: a numeric post id the user may read – no slugs (Divi resolves those itself).
* Returned as sent (int or digit string).
*
* @throws InvalidArgumentException
*/
private static function selectPostSetting(string $key, mixed $value, ValueSanitizer $sanitizer): mixed
{
$digits = is_string($value) && preg_match('/^[1-9][0-9]*$/D', $value) === 1;
$id = is_int($value) ? $value : ($digits ? (int) $value : 0);
if ($id <= 0 || ! $sanitizer->canReadPost($id)) {
throw new InvalidArgumentException(sprintf(
'Einstellung "%s" im Divi-Verweis: Beitrags-ID eines lesbaren Eintrags erwartet.',
$key
));
}
return $value;
}
/**
* select, yes_no_button and number settings: a scalar without markup; strings cleaned as text.
*
* @throws InvalidArgumentException
*/
private static function scalarSetting(string $key, mixed $value, ValueSanitizer $sanitizer): mixed
{
if (! is_scalar($value) || (is_string($value) && preg_match('/[<>]/', $value) === 1)) {
throw new InvalidArgumentException(sprintf(
'Einstellung "%s" im Divi-Verweis muss ein einfacher Wert ohne Markup sein.',
$key
));
}
return is_string($value) ? $sanitizer->text($value) : $value;
}
/**
* @param array<array-key, mixed> $map
* @return array<string, array<string, string>>
* @throws InvalidArgumentException
*/
private static function checkMap(array $map): array
{
foreach ($map as $name => $fields) {
if (! is_string($name) || ! is_array($fields)) {
throw new InvalidArgumentException('Dynamic content map: erwartet Name => [Einstellung => Feldtyp].');
}
foreach ($fields as $key => $type) {
if (! is_string($key) || ! is_string($type)) {
throw new InvalidArgumentException(sprintf(
'Dynamic content map "%s": erwartet Einstellung => Feldtyp (Text).',
$name
));
}
}
}
/** @var array<string, array<string, string>> $map */
return $map;
}
}