Files
WP-LIT-MCP/src/Integrations/Divi/Compose/ShortformWriter.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

306 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\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
defined('ABSPATH') || exit;
/**
* Writes short-form fields (spec §5.4, ShortformMap) into Divi attributes: checks the value per
* kind, resolves placeholders (not for code) and sets it at "<path>.desktop.value[.<sub>]";
* null removes the field. Pure PHP.
*
* Every string goes through ValueCleaner::clean() – the single choke point under the kses
* bypass (D7), see its pipeline – with the curated kind of the field; apply() adds step 0 (S35)
* and set() the guard for removals (S7/S26). Own rules on top: colors are placeholders, color
* references or plain CSS colors, checked before and after cleaning (S13); an attachment's alt
* text carrying a reference is dropped (S33b). Nothing is passed through.
*/
final class ShortformWriter
{
/** A plain CSS color; no names, no functions but rgb/hsl (ruling S13). */
private const COLOR = '/^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|transparent)$/D';
private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a';
/**
* @param array<string, array<string, array<string, mixed>>>|null $modulesForTests Short form
* per type, a test seam (trusted wiring only, ledger T2); null = ShortformMap.
*/
public function __construct(
private Placeholders $placeholders,
private MediaResolver $media,
private ValueCleaner $cleaner,
private ?array $modulesForTests = null
) {
}
/**
* @param string $type Short module name ("button").
* @param array<string, mixed> $attrs Current attributes.
* @param array<string, mixed> $values Field => value.
* @return array{attrs: array<string, mixed>, errors: array<string, string>}
*/
public function apply(string $type, array $attrs, array $values, string $field): array
{
// Ruling S35: controls go before placeholder resolution and trim ("{\0{post:title}}").
$values = ControlChars::stripDeep($values);
$fields = $this->modulesForTests === null ? ShortformMap::fields($type) : ($this->modulesForTests[$type] ?? []);
$errors = [];
foreach ($values as $name => $value) {
$name = (string) $name;
$spec = $fields[$name] ?? null;
if ($spec === null) {
$errors[$field . '.' . $name] = $fields === []
? 'dieses Modul hat keine Kurzform; Einstellungen über attrs (Vollform) setzen'
: 'unbekanntes Feld; erlaubt: ' . implode(', ', array_keys($fields));
continue;
}
try {
$attrs = $this->set('divi/' . $type, $attrs, $spec, $value, $values);
} catch (InvalidArgumentException $e) {
$errors[$field . '.' . $name] = $e->getMessage();
}
}
return ['attrs' => $attrs, 'errors' => $errors];
}
/**
* Attribute paths the given short-form fields of $type write to (unknown fields left out).
*
* @param list<string> $names Field names.
* @return array<string, string> Field => attribute path.
*/
public function paths(string $type, array $names): array
{
$fields = $this->modulesForTests === null ? ShortformMap::fields($type) : ($this->modulesForTests[$type] ?? []);
$paths = [];
foreach ($names as $name) {
if (isset($fields[$name]['path'])) {
$paths[$name] = (string) $fields[$name]['path'];
}
}
return $paths;
}
/**
* The short-form color check (ruling S13; Task 3 reuses it for background_color): a
* placeholder or reference that is a color reference, or a plain CSS color – checked
* before and after cleaning (S28). Returns the value to store.
*
* @throws InvalidArgumentException
*/
public function color(string $module, string $path, mixed $value, ?string $subKey = null): string
{
if (! is_string($value)) {
throw new InvalidArgumentException(self::COLOR_ERROR);
}
$value = trim($this->placeholders->resolve($value));
if (VariableRef::contains($value)) {
$reference = $this->cleaner->clean($module, $path, $value, $subKey, ValueKind::Text);
if (! is_string($reference) || VariableRef::typeOf($reference) !== 'color') {
throw new InvalidArgumentException(self::COLOR_ERROR);
}
return $reference;
}
if (preg_match(self::COLOR, $value) !== 1) {
throw new InvalidArgumentException(self::COLOR_ERROR);
}
$clean = $this->cleaner->clean($module, $path, $value, $subKey, ValueKind::Text);
if (! is_string($clean) || preg_match(self::COLOR, $clean) !== 1) {
throw new InvalidArgumentException(self::COLOR_ERROR);
}
return $clean;
}
/**
* @param array<string, mixed> $attrs
* @param array<string, mixed> $spec
* @param array<string, mixed> $all All values of this call (media must not overwrite an explicit alt).
* @return array<string, mixed>
* @throws InvalidArgumentException
*/
private function set(string $module, array $attrs, array $spec, mixed $value, array $all): array
{
$path = (string) $spec['path'];
$sub = $spec['sub'] === null ? null : (string) $spec['sub'];
$base = $path . '.desktop.value';
$this->cleaner->guardPath($path);
if ($value === null) {
return AttrMap::remove($attrs, $sub === null ? $path : $base . '.' . $sub);
}
if ($spec['kind'] === 'media') {
$attrs = $this->media($module, $attrs, $path, $spec['keys'], $value, ! array_key_exists('alt', $all));
} else {
$clean = $this->value($module, $path, $sub, $spec, $value);
$attrs = AttrMap::set($attrs, $sub === null ? $base : $base . '.' . $sub, $clean);
}
foreach ($spec['with'] ?? [] as $withKey => $fixed) {
$attrs = AttrMap::set($attrs, $base . '.' . $withKey, $fixed);
}
return $attrs;
}
/**
* @param array<string, mixed> $attrs
* @param array{0: string, 1: ?string, 2: ?string} $keys URL key, ID key, alt key.
* @return array<string, mixed>
* @throws InvalidArgumentException
*/
private function media(string $module, array $attrs, string $path, array $keys, mixed $value, bool $withAlt): array
{
$id = self::id($value);
$image = $id > 0 ? $this->media->image($id) : null;
$notFound = 'kein Bild aus der Mediathek; Anhang-ID z. B. mit lit-media-search suchen';
if ($image === null) {
throw new InvalidArgumentException($notFound);
}
[$urlKey, $idKey, $altKey] = $keys;
$base = $path . '.desktop.value.';
$url = $this->cleaner->clean($module, $path, $image['url'], $urlKey, ValueKind::Url);
if ($url === '') {
throw new InvalidArgumentException($notFound);
}
$attrs = AttrMap::set($attrs, $base . $urlKey, $url);
if ($idKey !== null) {
$attrs = AttrMap::set($attrs, $base . $idKey, (string) $id);
}
$alt = $altKey !== null && $withAlt ? $this->alt($module, $path, $image['alt'], $altKey) : '';
if ($alt !== '') {
$attrs = AttrMap::set($attrs, $base . (string) $altKey, $alt);
}
return $attrs;
}
/**
* The attachment's alt text, cleaned as text – or '' (no alt) when it carries a reference,
* whole or rebuilt by cleaning (ruling S33b): library data never becomes dynamic content.
*/
private function alt(string $module, string $path, string $alt, string $altKey): string
{
// Controls are stripped at the choke point before its marker check (S32a) – so a
// NUL-split marker counts as a marker here too, and a canonical result is refused.
if ($alt === '' || VariableRef::contains(ControlChars::strip($alt))) {
return '';
}
try {
$clean = $this->cleaner->clean($module, $path, $alt, $altKey, ValueKind::Text);
} catch (InvalidArgumentException) {
return '';
}
return is_string($clean) && ! VariableRef::contains($clean) ? $clean : '';
}
/**
* @param array<string, mixed> $spec
* @throws InvalidArgumentException
*/
private function value(string $module, string $path, ?string $sub, array $spec, mixed $value): mixed
{
switch ($spec['kind']) {
case 'onoff':
if (is_bool($value)) {
return $value ? 'on' : 'off';
}
if ($value === 'on' || $value === 'off') {
return $value;
}
throw new InvalidArgumentException('true oder false erwartet');
case 'level':
if (is_string($value) && preg_match('/^h[1-6]$/D', $value) === 1) {
return $value;
}
throw new InvalidArgumentException('h1 bis h6 erwartet');
case 'number':
$number = is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))
? $value + 0
: null;
if ($number !== null && is_finite((float) $number)) {
return $number;
}
throw new InvalidArgumentException('Zahl erwartet');
case 'enum':
if (is_string($value) && in_array($value, $spec['values'], true)) {
return $value;
}
throw new InvalidArgumentException('erlaubt: ' . implode(', ', $spec['values']));
case 'ids':
return $this->ids($value);
case 'color':
return $this->color($module, $path, $value, $sub);
}
// The curated kind as sanitizer kind; any other kind is text.
$kind = ValueKind::tryFrom((string) $spec['kind']) ?? ValueKind::Text;
if (! is_string($value) && ! is_int($value) && ! is_float($value)) {
throw new InvalidArgumentException('Text erwartet');
}
$value = (string) $value;
if ($kind !== ValueKind::Code) {
$value = $this->placeholders->resolve($value);
}
$clean = $this->cleaner->clean($module, $path, $value, $sub, $kind);
if ($kind === ValueKind::Url && $clean === '' && trim($value) !== '') {
throw new InvalidArgumentException('ungültige URL');
}
return $clean;
}
/** @throws InvalidArgumentException */
private function ids(mixed $value): string
{
if (! is_array($value) || $value === [] || ! array_is_list($value)) {
throw new InvalidArgumentException('Liste von Anhang-IDs erwartet, z. B. [12, 33]');
}
$ids = [];
foreach ($value as $item) {
$id = self::id($item);
if ($id <= 0 || $this->media->image($id) === null) {
throw new InvalidArgumentException(sprintf(
'%s ist kein Bild aus der Mediathek',
is_scalar($item) ? (string) $item : gettype($item)
));
}
$ids[] = $id;
}
return implode(',', $ids);
}
/** Attachment ID from an int or a digit string; 0 otherwise. */
private static function id(mixed $value): int
{
if (is_int($value)) {
return $value;
}
return is_string($value) && ctype_digit($value) ? (int) $value : 0;
}
}