fix: refuse private/unpublished menu links, write only changed menu items
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -17,7 +17,8 @@ use WP_Term;
|
||||
* its id alone; type/object/object_id and – for post/taxonomy items – url are
|
||||
* informational and ignored. Existing items keep all their data (classes, target,
|
||||
* description, custom label, …); title only becomes a custom label when it differs
|
||||
* from the currently shown title, url only changes for custom items.
|
||||
* from the currently shown title, url only changes for custom items. Only items whose
|
||||
* parent, position, title or url actually change are rewritten (and counted as updated).
|
||||
*
|
||||
* apply() is all-or-nothing as far as WordPress allows: on a failure it deletes the
|
||||
* items it created and rewrites the touched items with their previous data. Deleted
|
||||
@@ -27,6 +28,8 @@ use WP_Term;
|
||||
class MenuWriter
|
||||
{
|
||||
private const NON_LINKABLE = ['revision', 'nav_menu_item', 'attachment'];
|
||||
/** Statuses WordPress still shows in menus although visitors get a dead link until publication. */
|
||||
private const UNPUBLISHED = ['draft', 'pending', 'future'];
|
||||
private const URL_SCHEMES = ['http', 'https', 'mailto', 'tel'];
|
||||
private const URL_RULE = '/pfad, #anker, http(s), mailto, tel';
|
||||
|
||||
@@ -39,20 +42,25 @@ class MenuWriter
|
||||
/** @var list<int> Items deleted by the running apply(). */
|
||||
private array $deleted = [];
|
||||
|
||||
/** Set by plan(): links to draft/pending/future posts are allowed. */
|
||||
private bool $allowUnpublished = false;
|
||||
|
||||
/**
|
||||
* @param list<mixed> $tree
|
||||
* @param array<int, object> $current Current items by ID.
|
||||
* @param array<int, object> $current Current items by ID.
|
||||
* @param bool $allowUnpublished Allow links to draft/pending/future posts (with a warning).
|
||||
* @return array{
|
||||
* errors: array<string, string>,
|
||||
* removals: list<array{id: int, title: string}>,
|
||||
* warnings: list<string>
|
||||
* }
|
||||
*/
|
||||
public function plan(array $tree, array $current): array
|
||||
public function plan(array $tree, array $current, bool $allowUnpublished = false): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
$seen = [];
|
||||
$this->allowUnpublished = $allowUnpublished;
|
||||
$this->validateNodes($tree, 'items', $current, $seen, $errors, $warnings);
|
||||
|
||||
$removals = [];
|
||||
@@ -195,19 +203,13 @@ class MenuWriter
|
||||
) {
|
||||
return 'object_id muss ein vorhandener, für dich lesbarer Eintrag sein.';
|
||||
}
|
||||
if ($post->post_status !== 'publish') {
|
||||
$warnings[] = sprintf(
|
||||
'"%s" ist nicht veröffentlicht und erscheint erst nach dem Veröffentlichen im Menü.',
|
||||
$post->post_title
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return $this->checkStatus($post, $warnings);
|
||||
case 'taxonomy':
|
||||
$term = is_int($objectId) ? get_term($objectId) : null;
|
||||
return $term instanceof WP_Term
|
||||
&& (is_taxonomy_viewable($term->taxonomy) || current_user_can('edit_term', $objectId))
|
||||
// Terms of non-public taxonomies would show internal structure in a public menu.
|
||||
return $term instanceof WP_Term && is_taxonomy_viewable($term->taxonomy)
|
||||
? null
|
||||
: 'object_id muss ein vorhandener, für dich lesbarer Begriff sein.';
|
||||
: 'object_id muss ein vorhandener Begriff einer öffentlichen Taxonomie sein.';
|
||||
case 'custom':
|
||||
if (! $this->validUrl($node['url'] ?? null)) {
|
||||
return 'Individueller Link braucht eine gültige url (' . self::URL_RULE . ').';
|
||||
@@ -220,6 +222,45 @@ class MenuWriter
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WordPress shows menu items of draft/pending/private/future posts on the live site right
|
||||
* away (only trashed or missing objects are hidden), so private posts are always refused
|
||||
* and unpublished ones only with allow_unpublished.
|
||||
*
|
||||
* @param list<string> $warnings
|
||||
*/
|
||||
private function checkStatus(WP_Post $post, array &$warnings): ?string
|
||||
{
|
||||
$status = (string) $post->post_status;
|
||||
if ($status === 'publish') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($status === 'private') {
|
||||
return 'Private Inhalte dürfen nicht in öffentlichen Menüs erscheinen (Besucher sähen Titel und Link).';
|
||||
}
|
||||
|
||||
if (! in_array($status, self::UNPUBLISHED, true)) {
|
||||
return sprintf('Einträge mit Status %s können nicht verlinkt werden.', $status);
|
||||
}
|
||||
|
||||
if (! $this->allowUnpublished) {
|
||||
return sprintf(
|
||||
'"%s" ist nicht veröffentlicht (%s), würde aber sofort im Menü erscheinen – als toter Link. '
|
||||
. 'Erst veröffentlichen oder allow_unpublished: true setzen.',
|
||||
$post->post_title,
|
||||
$status
|
||||
);
|
||||
}
|
||||
|
||||
$warnings[] = sprintf(
|
||||
'Eintrag "%s" ist sofort im Menü sichtbar; Besucher erhalten bis zur Veröffentlichung einen toten Link.',
|
||||
$post->post_title
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $node */
|
||||
private function urlChanges(array $node, object $item): bool
|
||||
{
|
||||
@@ -234,12 +275,17 @@ class MenuWriter
|
||||
|
||||
private function validUrl(mixed $url): bool
|
||||
{
|
||||
if (! is_string($url) || $url === '' || preg_match('/\s/', $url) === 1) {
|
||||
if (! is_string($url) || $url === '' || preg_match('/[\s\x00-\x1f\x7f]/', $url) === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
|
||||
return ! str_starts_with($url, '//');
|
||||
if (str_starts_with($url, '/')) {
|
||||
// "//host" and "/\host" are protocol-relative links to another host in browsers.
|
||||
return ! in_array(substr($url, 1, 1), ['/', '\\'], true);
|
||||
}
|
||||
|
||||
if (str_starts_with($url, '#')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$scheme = wp_parse_url($url, PHP_URL_SCHEME);
|
||||
@@ -263,22 +309,35 @@ class MenuWriter
|
||||
foreach ($nodes as $node) {
|
||||
$position++;
|
||||
$existing = self::isExisting($node) ? $current[(int) $node['id']] : null;
|
||||
$args = $existing !== null ? $this->argsFromItem($existing) : $this->argsFromNode($node);
|
||||
if ($existing === null ? isset($node['title']) : $this->titleChanges($node, $existing)) {
|
||||
$args['menu-item-title'] = (string) $node['title'];
|
||||
}
|
||||
if ($existing === null ? $node['type'] === 'custom' : $this->customUrlChanges($node, $existing)) {
|
||||
$args['menu-item-url'] = esc_url_raw((string) $node['url']);
|
||||
}
|
||||
$args['menu-item-parent-id'] = $parentId;
|
||||
$args['menu-item-position'] = $position;
|
||||
$args['menu-item-status'] = 'publish';
|
||||
|
||||
$itemId = $this->save($menuId, $existing !== null ? (int) $existing->ID : 0, $args);
|
||||
if ($existing !== null) {
|
||||
$seen[(int) $existing->ID] = true;
|
||||
$this->touched[] = (int) $existing->ID;
|
||||
$itemId = (int) $existing->ID;
|
||||
$seen[$itemId] = true;
|
||||
$titleChanges = $this->titleChanges($node, $existing);
|
||||
$urlChanges = $this->customUrlChanges($node, $existing);
|
||||
$moved = $parentId !== (int) $existing->menu_item_parent
|
||||
|| $position !== $this->storedPosition($current, $itemId);
|
||||
if ($titleChanges || $urlChanges || $moved) {
|
||||
$args = $this->withNode(
|
||||
$this->argsFromItem($existing),
|
||||
$node,
|
||||
$titleChanges,
|
||||
$urlChanges,
|
||||
$parentId,
|
||||
$position
|
||||
);
|
||||
$this->save($menuId, $itemId, $args);
|
||||
$this->touched[] = $itemId;
|
||||
}
|
||||
} else {
|
||||
$args = $this->withNode(
|
||||
$this->argsFromNode($node),
|
||||
$node,
|
||||
isset($node['title']),
|
||||
$node['type'] === 'custom',
|
||||
$parentId,
|
||||
$position
|
||||
);
|
||||
$itemId = $this->save($menuId, 0, $args);
|
||||
$this->created[] = $itemId;
|
||||
}
|
||||
|
||||
@@ -286,6 +345,45 @@ class MenuWriter
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $args
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function withNode(
|
||||
array $args,
|
||||
array $node,
|
||||
bool $setTitle,
|
||||
bool $setUrl,
|
||||
int $parentId,
|
||||
int $position
|
||||
): array {
|
||||
if ($setTitle) {
|
||||
$args['menu-item-title'] = (string) $node['title'];
|
||||
}
|
||||
if ($setUrl) {
|
||||
$args['menu-item-url'] = esc_url_raw((string) $node['url']);
|
||||
}
|
||||
$args['menu-item-parent-id'] = $parentId;
|
||||
$args['menu-item-position'] = $position;
|
||||
$args['menu-item-status'] = 'publish';
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* The item's current position: its menu_order, or – when that is missing – its index in
|
||||
* $current (which is in menu order).
|
||||
*
|
||||
* @param array<int, object> $current
|
||||
*/
|
||||
private function storedPosition(array $current, int $itemId): int
|
||||
{
|
||||
$order = (int) ($current[$itemId]->menu_order ?? 0);
|
||||
|
||||
return $order > 0 ? $order : (int) array_search($itemId, array_keys($current), true) + 1;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $node */
|
||||
private function customUrlChanges(array $node, object $item): bool
|
||||
{
|
||||
@@ -338,14 +436,11 @@ class MenuWriter
|
||||
}
|
||||
}
|
||||
|
||||
$order = array_flip(array_keys($current));
|
||||
foreach ($this->touched as $itemId) {
|
||||
$item = $current[$itemId];
|
||||
$args = $this->argsFromItem($item);
|
||||
$args['menu-item-parent-id'] = (int) $item->menu_item_parent;
|
||||
$args['menu-item-position'] = isset($item->menu_order) && (int) $item->menu_order > 0
|
||||
? (int) $item->menu_order
|
||||
: $order[$itemId] + 1;
|
||||
$args['menu-item-position'] = $this->storedPosition($current, $itemId);
|
||||
$args['menu-item-status'] = 'publish';
|
||||
try {
|
||||
$this->save($menuId, $itemId, $args);
|
||||
|
||||
Reference in New Issue
Block a user