fix: link targets never reveal library entries by type (ruling Q14)
WpLinkTargets answers the library, internal "_" types and every type without a Divi post_link_url option like a missing ID; replace-text strips library IDs only from global subtrees. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,8 +69,9 @@ final class DiviTextSearch
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Every hit of $page, computed without changing it. Without $globalIds (ruling P13: no library
|
* Every hit of $page, computed without changing it. Without $globalIds (ruling P13: no library
|
||||||
* access) the library IDs a global instance stores are no text to find: an opaque node is
|
* access) the library IDs a global instance stores are no text to find: a global node is
|
||||||
* matched without globalModule/globalParent, as if they were not stored.
|
* matched without globalModule/globalParent, as if they were not stored; every other opaque
|
||||||
|
* node (e.g. undecodable attributes) is matched as stored, the same with or without access.
|
||||||
*
|
*
|
||||||
* @return array{count: int, samples: list<array<string, string>>,
|
* @return array{count: int, samples: list<array<string, string>>,
|
||||||
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
|
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
|
||||||
@@ -89,7 +90,9 @@ final class DiviTextSearch
|
|||||||
foreach ($page->index->entries() as $entry) {
|
foreach ($page->index->entries() as $entry) {
|
||||||
$block = $entry['block'];
|
$block = $entry['block'];
|
||||||
if ($block->isDivi() && $block->isOpaque()) {
|
if ($block->isDivi() && $block->isOpaque()) {
|
||||||
$markup = $globalIds ? Serializer::block($block) : self::withoutLibraryIds($block);
|
$markup = $globalIds || ! $block->isGlobal()
|
||||||
|
? Serializer::block($block)
|
||||||
|
: self::withoutLibraryIds($block);
|
||||||
if ($replacer->mentions($markup)) {
|
if ($replacer->mentions($markup)) {
|
||||||
$skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)];
|
$skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)];
|
||||||
}
|
}
|
||||||
@@ -120,8 +123,9 @@ final class DiviTextSearch
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ruling P13: $block's markup with every globalModule/globalParent left out, at every depth –
|
* Ruling P13: the markup of the global node $block (decodable attributes, as isGlobal() needs)
|
||||||
* only for matching, never saved.
|
* with every globalModule/globalParent left out, at every depth – only for matching, never
|
||||||
|
* saved. Openers are rebuilt from the decoded attributes, so Divi's raw spelling is not kept.
|
||||||
*/
|
*/
|
||||||
private static function withoutLibraryIds(Block $block): string
|
private static function withoutLibraryIds(Block $block): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,32 +4,62 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
|
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
|
||||||
use WP_Post;
|
use WP_Post;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {{link:<ID>}} and post_link_url_* targets from WordPress (ruling P10): the entry exists, lives
|
* {{link:<ID>}} and post_link_url_* targets from WordPress (rulings P10, Q14): the entry exists,
|
||||||
* (no trash, auto-draft or revision), is no attachment (whatever its status) and the current user
|
* lives (no trash, auto-draft or revision), is no attachment (whatever its status), its type has a
|
||||||
* may read it (read_post, like select_post settings, S37). Missing and unreadable entries give the
|
* Divi link option post_link_url_<type> (never the library et_pb_layout or an internal "_" type) and
|
||||||
* same null (no existence oracle).
|
* the current user may read it (read_post, like select_post settings, S37). Missing, unreadable and
|
||||||
|
* unlinkable entries give the same null (no existence or type oracle).
|
||||||
*/
|
*/
|
||||||
final class WpLinkTargets implements LinkTargets
|
final class WpLinkTargets implements LinkTargets
|
||||||
{
|
{
|
||||||
private const DEAD = ['trash', 'auto-draft', 'inherit'];
|
private const DEAD = ['trash', 'auto-draft', 'inherit'];
|
||||||
|
|
||||||
|
/** Never link targets, whatever Divi lists: attachments and the Divi Library (ruling Q14). */
|
||||||
|
private const NEVER = ['attachment', 'et_pb_layout'];
|
||||||
|
|
||||||
|
/** @var Closure(string): bool */
|
||||||
|
private Closure $linkable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param (Closure(string): bool)|null $linkable Whether Divi offers post_link_url_<type> (tests);
|
||||||
|
* null = Divi's et_builder_get_public_post_types(), the list its link option uses.
|
||||||
|
*/
|
||||||
|
public function __construct(?Closure $linkable = null)
|
||||||
|
{
|
||||||
|
$this->linkable = $linkable ?? self::diviLinkable(...);
|
||||||
|
}
|
||||||
|
|
||||||
public function postTypeOf(int $id): ?string
|
public function postTypeOf(int $id): ?string
|
||||||
{
|
{
|
||||||
$post = get_post($id);
|
$post = get_post($id);
|
||||||
if (
|
if (! $post instanceof WP_Post || in_array((string) $post->post_status, self::DEAD, true)) {
|
||||||
! $post instanceof WP_Post
|
|
||||||
|| in_array((string) $post->post_status, self::DEAD, true)
|
|
||||||
|| $post->post_type === 'attachment'
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return current_user_can('read_post', $id) ? (string) $post->post_type : null;
|
$type = (string) $post->post_type;
|
||||||
|
if (in_array($type, self::NEVER, true) || str_starts_with($type, '_') || ! ($this->linkable)($type)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current_user_can('read_post', $id) ? $type : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fail closed: without Divi's list no type has a link option. */
|
||||||
|
private static function diviLinkable(string $type): bool
|
||||||
|
{
|
||||||
|
if (! function_exists('et_builder_get_public_post_types')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = et_builder_get_public_post_types();
|
||||||
|
|
||||||
|
return is_array($types) && array_key_exists($type, $types);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
@@ -19,6 +20,11 @@ defined('ABSPATH') || exit;
|
|||||||
*/
|
*/
|
||||||
final class WpValueSanitizer implements ValueSanitizer
|
final class WpValueSanitizer implements ValueSanitizer
|
||||||
{
|
{
|
||||||
|
/** @param LinkTargets $links The {{link:}} targets post_link_url_* follow too (ruling P10). */
|
||||||
|
public function __construct(private LinkTargets $links = new WpLinkTargets())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public function text(string $value): string
|
public function text(string $value): string
|
||||||
{
|
{
|
||||||
return sanitize_text_field($value);
|
return sanitize_text_field($value);
|
||||||
@@ -51,6 +57,6 @@ final class WpValueSanitizer implements ValueSanitizer
|
|||||||
|
|
||||||
public function linkTargetType(int $id): ?string
|
public function linkTargetType(int $id): ?string
|
||||||
{
|
{
|
||||||
return (new WpLinkTargets())->postTypeOf($id);
|
return $this->links->postTypeOf($id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ defined('ABSPATH') || exit;
|
|||||||
*/
|
*/
|
||||||
final class WordPressIntegration implements Integration
|
final class WordPressIntegration implements Integration
|
||||||
{
|
{
|
||||||
/** @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration). */
|
|
||||||
/**
|
/**
|
||||||
|
* @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration).
|
||||||
* @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether lit-page-render-summary may name
|
* @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether lit-page-render-summary may name
|
||||||
* global module IDs (the Divi integration's library access); null = always.
|
* global module IDs (the Divi integration's library access); null = always.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -464,6 +464,22 @@ final class DiviReplaceTextAbilityTest extends TestCase
|
|||||||
self::assertSame([], $byId['skipped_nodes']);
|
self::assertSame([], $byId['skipped_nodes']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling P13/Q14: only global subtrees lose their IDs – an undecodable node is matched as stored, with or without access. */
|
||||||
|
public function testUndecodableNodesAreMatchedAlikeWithAndWithoutLibraryAccess(): void
|
||||||
|
{
|
||||||
|
$content = str_replace(
|
||||||
|
'<!-- /wp:divi/column -->',
|
||||||
|
'<!-- wp:divi/text {"note": Müller} --><!-- /wp:divi/text --><!-- /wp:divi/column -->',
|
||||||
|
self::page([self::text('<p>Meier</p>')])
|
||||||
|
);
|
||||||
|
|
||||||
|
$hidden = $this->ability($content)->execute(['search' => 'Müller', 'replace' => 'x']);
|
||||||
|
$shown = $this->ability($content, false, [7], true)->execute(['search' => 'Müller', 'replace' => 'x']);
|
||||||
|
|
||||||
|
self::assertSame(['s0.r0.c0.m1'], array_column($hidden['skipped_nodes'], 'id'));
|
||||||
|
self::assertSame($shown['skipped_nodes'], $hidden['skipped_nodes']);
|
||||||
|
}
|
||||||
|
|
||||||
/** No effective change (search == replacement, cleaned replacement == match): nothing saved. */
|
/** No effective change (search == replacement, cleaned replacement == match): nothing saved. */
|
||||||
public function testNoEffectiveChangeSavesNothing(): void
|
public function testNoEffectiveChangeSavesNothing(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\WpLinkTargets;
|
use LIT\MCP\Integrations\Divi\Pages\WpLinkTargets;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\WpValueSanitizer;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
use LIT\MCP\Tests\Unit\TestCase;
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
use WP_Post;
|
use WP_Post;
|
||||||
|
|
||||||
@@ -28,7 +33,7 @@ final class WpLinkTargetsTest extends TestCase
|
|||||||
static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 842
|
static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 842
|
||||||
);
|
);
|
||||||
|
|
||||||
$links = new WpLinkTargets();
|
$links = new WpLinkTargets(self::linkable());
|
||||||
|
|
||||||
self::assertSame('page', $links->postTypeOf(839));
|
self::assertSame('page', $links->postTypeOf(839));
|
||||||
self::assertNull($links->postTypeOf(841), 'trashed');
|
self::assertNull($links->postTypeOf(841), 'trashed');
|
||||||
@@ -40,4 +45,77 @@ final class WpLinkTargetsTest extends TestCase
|
|||||||
self::assertNull($links->postTypeOf(846), 'trashed attachment');
|
self::assertNull($links->postTypeOf(846), 'trashed attachment');
|
||||||
self::assertNull($links->postTypeOf(847), 'attachment with an own status (ruling P10: no attachment)');
|
self::assertNull($links->postTypeOf(847), 'attachment with an own status (ruling P10: no attachment)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Post types Divi offers post_link_url_<type> for in these tests (et_builder_get_public_post_types). */
|
||||||
|
private static function linkable(): \Closure
|
||||||
|
{
|
||||||
|
return static fn (string $type): bool => in_array($type, ['page', 'post', 'attachment', 'lit_person'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling Q14: a type without a Divi link option (the library, internal "_" types, any other) is no
|
||||||
|
* link target – answered like a missing ID, so {{link:}} tells nobody which IDs are library entries.
|
||||||
|
*/
|
||||||
|
public function testTypesWithoutADiviLinkOptionAreNotFound(): void
|
||||||
|
{
|
||||||
|
$posts = [
|
||||||
|
1058 => new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout', 'post_status' => 'publish']),
|
||||||
|
1070 => new WP_Post(['ID' => 1070, 'post_type' => '_internal', 'post_status' => 'publish']),
|
||||||
|
1071 => new WP_Post(['ID' => 1071, 'post_type' => 'wp_template', 'post_status' => 'publish']),
|
||||||
|
1072 => new WP_Post(['ID' => 1072, 'post_type' => 'lit_person', 'post_status' => 'publish']),
|
||||||
|
];
|
||||||
|
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $posts[$id] ?? null);
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
$everything = new WpLinkTargets(static fn (string $type): bool => true);
|
||||||
|
|
||||||
|
$links = new WpLinkTargets(self::linkable());
|
||||||
|
self::assertNull($links->postTypeOf(1058), 'library entry');
|
||||||
|
self::assertNull($links->postTypeOf(1070), 'internal type');
|
||||||
|
self::assertNull($links->postTypeOf(1071), 'type without link option');
|
||||||
|
self::assertSame('lit_person', $links->postTypeOf(1072));
|
||||||
|
self::assertNull($everything->postTypeOf(1058), 'the library never, whatever Divi lists');
|
||||||
|
self::assertNull($everything->postTypeOf(1070), 'internal types never, whatever Divi lists');
|
||||||
|
self::assertNull((new WpLinkTargets())->postTypeOf(1072), 'without Divi no type has a link option');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling Q14: {{link:}} and the full form answer a library ID exactly like a missing ID – with and
|
||||||
|
* without the library right (read_post on the entry), so no guessed ID reveals a library entry.
|
||||||
|
*/
|
||||||
|
public function testALibraryIdIsAnsweredLikeAMissingId(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $id === 1058
|
||||||
|
? new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout', 'post_status' => 'publish'])
|
||||||
|
: null);
|
||||||
|
$links = new WpLinkTargets(self::linkable());
|
||||||
|
$placeholders = new Placeholders(DiviFixtures::designSystem(), $links);
|
||||||
|
$ref = new VariableRef(DiviFixtures::designSystem(), ['post_link_url_page' => ['post_id' => 'select_post']]);
|
||||||
|
$sanitizer = new WpValueSanitizer($links);
|
||||||
|
$answer = static function (callable $call, string $id): string {
|
||||||
|
try {
|
||||||
|
$call($id);
|
||||||
|
|
||||||
|
return 'accepted';
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
return str_replace($id, '<ID>', $e->getMessage());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$short = static fn (string $id): string => $placeholders->resolve('{{link:' . $id . '}}');
|
||||||
|
$full = static fn (string $id): string => $ref->canonical(
|
||||||
|
'$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"' . $id . '"}}})$',
|
||||||
|
$sanitizer,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (['with the library right' => true, 'without it' => false] as $case => $right) {
|
||||||
|
Functions\when('current_user_can')->justReturn($right);
|
||||||
|
foreach (['{{link:}}' => $short, 'post_link_url_page' => $full] as $form => $call) {
|
||||||
|
$library = $answer($call, '1058');
|
||||||
|
self::assertNotSame('accepted', $library, $case . ' ' . $form);
|
||||||
|
self::assertSame($answer($call, '1066'), $library, $case . ' ' . $form);
|
||||||
|
self::assertStringNotContainsString('et_pb_layout', $library, $case . ' ' . $form);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\WpLinkTargets;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\WpValueSanitizer;
|
use LIT\MCP\Integrations\Divi\Pages\WpValueSanitizer;
|
||||||
use LIT\MCP\Tests\Unit\TestCase;
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
use WP_Post;
|
use WP_Post;
|
||||||
@@ -72,7 +73,7 @@ final class WpValueSanitizerTest extends TestCase
|
|||||||
default => null,
|
default => null,
|
||||||
});
|
});
|
||||||
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 16);
|
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 16);
|
||||||
$sanitizer = new WpValueSanitizer();
|
$sanitizer = new WpValueSanitizer(new WpLinkTargets(static fn (string $type): bool => $type !== 'wp_block'));
|
||||||
|
|
||||||
self::assertSame('page', $sanitizer->linkTargetType(12));
|
self::assertSame('page', $sanitizer->linkTargetType(12));
|
||||||
self::assertSame('post', $sanitizer->linkTargetType(20));
|
self::assertSame('post', $sanitizer->linkTargetType(20));
|
||||||
|
|||||||
Reference in New Issue
Block a user