fix: Preset-Änderungen ohne unfiltered_html ablehnen, wenn Divi HTML/CSS anderer Presets entfernen würde
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
048cd9353d
commit
75714429be
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use WP_Error;
|
||||
|
||||
@@ -11,10 +12,41 @@ defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Who may change the design system (spec §10): presets need edit_theme_options and Divi's role right "Visual Builder
|
||||
* verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1").
|
||||
* verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1"). Without unfiltered_html a
|
||||
* write is refused while presets it saves along hold HTML/CSS that Divi would strip (final review I-2).
|
||||
*/
|
||||
final class DesignRights
|
||||
{
|
||||
public const OTHER_MARKUP = 'Andere Presets enthalten HTML/CSS, das beim Speichern ohne das Recht unfiltered_html '
|
||||
. 'verloren ginge; nichts wurde gespeichert.';
|
||||
|
||||
/** @return Closure(): bool Whether the current user may save unfiltered HTML. */
|
||||
public static function unfilteredHtml(): Closure
|
||||
{
|
||||
return static fn (): bool => current_user_can('unfiltered_html');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses the write when the user lacks unfiltered_html and $markupIds (PresetBook::markupOutside()) is not empty.
|
||||
*
|
||||
* @param Closure(): bool $unfiltered
|
||||
* @param list<string> $markupIds
|
||||
*/
|
||||
public static function markup(
|
||||
Closure $unfiltered,
|
||||
array $markupIds,
|
||||
string $message = self::OTHER_MARKUP
|
||||
): ?WP_Error {
|
||||
if ($markupIds === [] || $unfiltered()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Errors::forbidden(
|
||||
$message,
|
||||
'Einen Administrator (mit dem Recht unfiltered_html) die Änderung vornehmen lassen.'
|
||||
);
|
||||
}
|
||||
|
||||
public static function presets(DesignSystemWriter $writer): ?WP_Error
|
||||
{
|
||||
if (! current_user_can('edit_theme_options')) {
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
@@ -20,11 +21,17 @@ final class DiviDesignRestoreAbility implements Ability
|
||||
{
|
||||
private const ID = '/^(colors|variables|presets|theme_options)-\d{8}T\d{6}Z-[0-9a-f]{6}$/D';
|
||||
|
||||
private const MARKUP = 'Der Snapshot oder der aktuelle Bestand enthält Presets mit HTML/CSS, das beim Speichern '
|
||||
. 'ohne das Recht unfiltered_html verloren ginge; nichts wurde gespeichert.';
|
||||
|
||||
/** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */
|
||||
public function __construct(
|
||||
private DesignSystemWriter $writer,
|
||||
private DesignChange $change,
|
||||
private SnapshotStore $snapshots
|
||||
private SnapshotStore $snapshots,
|
||||
private ?Closure $unfiltered = null
|
||||
) {
|
||||
$this->unfiltered ??= DesignRights::unfilteredHtml();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
@@ -101,13 +108,23 @@ final class DiviDesignRestoreAbility implements Ability
|
||||
);
|
||||
}
|
||||
|
||||
$unfiltered = $this->unfiltered;
|
||||
|
||||
return $this->change->run(
|
||||
$this->name(),
|
||||
new PresetArea($this->writer),
|
||||
$input,
|
||||
static function (array $raw) use ($snapshot, $id): DesignPlan {
|
||||
static function (array $raw) use ($snapshot, $id, $unfiltered): DesignPlan|WP_Error {
|
||||
$before = PresetBook::fromDivi($raw);
|
||||
$after = PresetBook::fromDivi($snapshot['data']);
|
||||
$refusal = DesignRights::markup(
|
||||
$unfiltered,
|
||||
array_merge($before->markupOutside([]), $after->markupOutside([])),
|
||||
self::MARKUP
|
||||
);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
$diff = PresetDiff::between($before, $after);
|
||||
|
||||
return new DesignPlan(
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
@@ -26,11 +27,14 @@ final class DiviPresetsDeleteAbility implements Ability
|
||||
private const DETACH_HINT = 'Erst die Verweise entfernen: lit/divi-design-detach (ab Etappe 4b) oder im '
|
||||
. 'Divi-Builder; danach erneut löschen.';
|
||||
|
||||
/** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */
|
||||
public function __construct(
|
||||
private DesignSystemWriter $writer,
|
||||
private DesignChange $change,
|
||||
private GlobalUsages $usages
|
||||
private GlobalUsages $usages,
|
||||
private ?Closure $unfiltered = null
|
||||
) {
|
||||
$this->unfiltered ??= DesignRights::unfilteredHtml();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
@@ -162,6 +166,10 @@ final class DiviPresetsDeleteAbility implements Ability
|
||||
self::DETACH_HINT
|
||||
);
|
||||
}
|
||||
$refusal = DesignRights::markup($this->unfiltered, $book->markupOutside($deleted));
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$diff = PresetDiff::between($before, $book);
|
||||
|
||||
|
||||
@@ -30,17 +30,25 @@ final class DiviPresetsSetAbility implements Ability
|
||||
|
||||
private const NAME_RULE = 'Name: 1–100 Zeichen, ohne HTML';
|
||||
|
||||
private const MARKUP = 'Vorhandene Presets enthalten HTML/CSS, das beim Speichern ohne das Recht unfiltered_html '
|
||||
. 'verloren ginge; nichts wurde gespeichert.';
|
||||
|
||||
private const GROUP_DEFAULT_WARNING = 'Standard-Gruppen-Presets wirken zusätzlich auf alle Module mit dieser '
|
||||
. 'Gruppe ohne eigenes Gruppen-Preset; diese Seiten sind nicht aufgelistet.';
|
||||
|
||||
/** @param Closure(): PresetComposer $composer */
|
||||
/**
|
||||
* @param Closure(): PresetComposer $composer
|
||||
* @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights).
|
||||
*/
|
||||
public function __construct(
|
||||
private DesignSystemWriter $writer,
|
||||
private DesignChange $change,
|
||||
private GlobalUsages $usages,
|
||||
private DiviPageLoader $loader,
|
||||
private Closure $composer
|
||||
private Closure $composer,
|
||||
private ?Closure $unfiltered = null
|
||||
) {
|
||||
$this->unfiltered ??= DesignRights::unfilteredHtml();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
@@ -150,6 +158,7 @@ final class DiviPresetsSetAbility implements Ability
|
||||
$errors = [];
|
||||
$invalid = [];
|
||||
$targets = [];
|
||||
$created = [];
|
||||
$warnings = [];
|
||||
foreach ($items as $index => $item) {
|
||||
$field = sprintf('items[%d]', $index);
|
||||
@@ -169,6 +178,9 @@ final class DiviPresetsSetAbility implements Ability
|
||||
continue;
|
||||
}
|
||||
$id = (string) $result['item']['id'];
|
||||
if ($existing === null) {
|
||||
$created[] = $id;
|
||||
}
|
||||
$book = $book->with($scope, $result['item']);
|
||||
if (($item['make_default'] ?? null) === true) {
|
||||
$book = $book->withDefault($scope, $id);
|
||||
@@ -190,6 +202,11 @@ final class DiviPresetsSetAbility implements Ability
|
||||
if ($errors !== []) {
|
||||
return Errors::invalidInput('Ungültige Preset-Angaben; nichts wurde gespeichert.', $errors);
|
||||
}
|
||||
// Only new presets passed the choke point as a whole; a changed one keeps its stored settings (review I-2).
|
||||
$refusal = DesignRights::markup($this->unfiltered, $book->markupOutside($created), self::MARKUP);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$diff = PresetDiff::between($before, $book);
|
||||
|
||||
|
||||
@@ -200,6 +200,35 @@ final class PresetBook
|
||||
return $users;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of the presets outside $touchedIds whose settings (attrs, styleAttrs, renderAttrs) hold a string with "<"
|
||||
* or ">" – HTML or CSS that Divi's prepare_data() strips when a user without unfiltered_html saves the inventory
|
||||
* (final review I-2).
|
||||
*
|
||||
* @param list<string> $touchedIds
|
||||
* @return list<string>
|
||||
*/
|
||||
public function markupOutside(array $touchedIds): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($this->data as $records) {
|
||||
foreach ($records as $record) {
|
||||
foreach ($record['items'] as $id => $item) {
|
||||
$id = (string) $id;
|
||||
if (in_array($id, $touchedIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$settings = array_intersect_key($item, ['attrs' => 1, 'styleAttrs' => 1, 'renderAttrs' => 1]);
|
||||
if (self::hasMarkup($settings)) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/** @return array{module: list<array{default: string, items: list<array<string, mixed>>}>,
|
||||
* group: list<array{default: string, items: list<array<string, mixed>>}>} */
|
||||
public function toRest(): array
|
||||
@@ -225,6 +254,22 @@ final class PresetBook
|
||||
return $id;
|
||||
}
|
||||
|
||||
private static function hasMarkup(mixed $value): bool
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return strpbrk($value, '<>') !== false;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $inner) {
|
||||
if (self::hasMarkup($inner)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function plain(mixed $value): mixed
|
||||
{
|
||||
if (is_object($value)) {
|
||||
|
||||
@@ -11,6 +11,7 @@ use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignRights;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility;
|
||||
@@ -167,11 +168,22 @@ final class DiviIntegration implements Integration
|
||||
$this->designChange(),
|
||||
$this->globalUsages(),
|
||||
$loader,
|
||||
$this->presetComposer(...)
|
||||
$this->presetComposer(...),
|
||||
DesignRights::unfilteredHtml()
|
||||
),
|
||||
new DiviPresetsDeleteAbility(
|
||||
$this->designWriter,
|
||||
$this->designChange(),
|
||||
$this->globalUsages(),
|
||||
DesignRights::unfilteredHtml()
|
||||
),
|
||||
new DiviPresetsDeleteAbility($this->designWriter, $this->designChange(), $this->globalUsages()),
|
||||
new DiviDesignSnapshotsAbility(new SnapshotStore()),
|
||||
new DiviDesignRestoreAbility($this->designWriter, $this->designChange(), new SnapshotStore()),
|
||||
new DiviDesignRestoreAbility(
|
||||
$this->designWriter,
|
||||
$this->designChange(),
|
||||
new SnapshotStore(),
|
||||
DesignRights::unfilteredHtml()
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user