Kristian reported the infinity sign sitting above centre in the admin menu on the live shop. Security traced it to WordPress's own stylesheet rather than adjusting it by eye: #adminmenu div.wp-menu-image is 36x34 and its :before carries padding 7px 0, which is exactly 7 + 20 + 7 for a 20px dashicon at line-height 1. The 1.2.0 rule set line-height 1.4 and inherited that padding, so the pseudo-element was 42px tall inside a 34px parent. 1.2.1 reproduces the box, drops the padding and centres with flex — geometry rather than a nudge, because U+221E sits on the maths axis instead of filling its em box. It is the same half-done abstraction as the escaping fix: the filter made the character configurable and left the geometry tuned for one specific character. Nothing could fail, because the only value anyone ever passed was the one it was tuned for. Verified before copying, including the part that makes the check meaningful: Security predicted php -w WOULD differ this round, since the executable code changed — so equal stripped hashes would have meant the wrong file. They differ, and the diff is the version constant plus the CSS geometry, with wpmgr_css_escape untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
380 lines
16 KiB
PHP
380 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* The one admin menu the manager plugins share.
|
|
*
|
|
* ┌──────────────────────────────────────────────────────────────────────────┐
|
|
* │ THIS FILE IS BYTE-IDENTICAL IN EVERY MANAGER PLUGIN. │
|
|
* │ Do not edit it to add something for one plugin. Extend it through the │
|
|
* │ `wpmgr_dashboard_cards` filter, which is what it is for. │
|
|
* │ Contract version 1.1.0. See "Why it is frozen" below — and note that │
|
|
* │ the two changes it has had were both paid for in load-order confusion. │
|
|
* └──────────────────────────────────────────────────────────────────────────┘
|
|
*
|
|
* The problem it solves: a family of plugins each adding a top-level menu is
|
|
* a handful of entries in a sidebar that already has fifteen. So they share
|
|
* one parent and each adds a submenu under it. Six of them as of this
|
|
* writing, which is why the count is not stated anywhere it would have to be
|
|
* maintained.
|
|
*
|
|
* Three things about how it does that are load-bearing, and none is obvious.
|
|
*
|
|
* **1. Every declaration is guarded.** Each plugin `require_once`s its own
|
|
* copy of this file, in one request. `require_once` only deduplicates by
|
|
* resolved path, and these are different paths — so without the
|
|
* `function_exists()` guard, activating the second plugin is
|
|
* `Fatal error: Cannot redeclare wpmgr_ensure_menu()`. A white screen on a
|
|
* shop, caused by nothing except owning two of these plugins. It is the same
|
|
* shape as the update-hook collision: a fault that exists only when there is
|
|
* more than one, which means no repository's own tests can find it.
|
|
*
|
|
* **2. Whichever copy loads first wins, and that order is arbitrary** — it is
|
|
* plugin activation order, effectively alphabetical by directory. PHP cannot
|
|
* un-declare a function, so there is no honest way for a newer copy to replace
|
|
* an older one that has already been loaded. The only stable answer is that
|
|
* the file never changes. Hence: **frozen**. The parent slug, the capability,
|
|
* the position and the filter name are a contract between every repository in
|
|
* the family, and anything new arrives through the filter.
|
|
*
|
|
* 🔴 It has moved twice, and both times the cost was the same: a half-adopted
|
|
* change means the menu is whatever the alphabetically-first plugin's copy
|
|
* says, and every repository's own hash check goes green on its own side
|
|
* while the family disagrees. Nolimit SEO measured exactly that today — two
|
|
* plugins on 1.1.0 and three on 1.0.0, so a shop got the infinity glyph or
|
|
* the old cog depending on activation order. If this file has to change,
|
|
* every plugin takes it in the same hour or the change is not made.
|
|
*
|
|
* **3. The dashboard is built from a filter, not owned by a plugin.** If the
|
|
* plugin that happened to register the menu is deactivated, the menu is
|
|
* re-registered by the next one on the following request and the page still
|
|
* renders — because the page's content comes from whoever is installed, not
|
|
* from whoever created it. Registering a callback that lives in one plugin
|
|
* would mean deactivating that plugin takes the other four's screens with it.
|
|
*
|
|
* Each plugin calls `wpmgr_ensure_menu()` at the top of its own `admin_menu`
|
|
* callback, before its own `add_submenu_page( 'wpmgr', … )`. Not on some other
|
|
* hook: `add_submenu_page` against a parent that does not exist yet silently
|
|
* fails, and whose `admin_menu` runs first is, again, arbitrary.
|
|
*
|
|
* The package below is the family, not any one plugin: this file is
|
|
* byte-identical in five repositories, and a tag naming whichever of them it
|
|
* was first written in reads as a mistake in the other four — which invites
|
|
* somebody to "fix" it, in one repository, which is the exact edit the freeze
|
|
* exists to prevent.
|
|
*
|
|
* @package WordPress_Manager_Family
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
if ( ! defined( 'WPMGR_MENU_VERSION' ) ) {
|
|
define( 'WPMGR_MENU_VERSION', '1.2.1' );
|
|
}
|
|
|
|
if ( ! defined( 'WPMGR_MENU_SLUG' ) ) {
|
|
define( 'WPMGR_MENU_SLUG', 'wpmgr' );
|
|
}
|
|
|
|
if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
|
|
|
|
/**
|
|
* Register the shared parent menu, unless somebody already has.
|
|
*
|
|
* Call this first thing in your own `admin_menu` callback.
|
|
*/
|
|
function wpmgr_ensure_menu() {
|
|
global $admin_page_hooks;
|
|
|
|
if ( isset( $admin_page_hooks[ WPMGR_MENU_SLUG ] ) ) {
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* The label on the shared parent menu.
|
|
*
|
|
* Left untranslated here on purpose: this file is byte-identical in
|
|
* five plugins, so it cannot carry any one of their text domains, and
|
|
* a string with the wrong domain is a string that is silently never
|
|
* translated. Any plugin may translate it in its own domain through
|
|
* this filter.
|
|
*
|
|
* @param string $title The menu title.
|
|
*/
|
|
$title = apply_filters( 'wpmgr_menu_title', 'Manager' );
|
|
|
|
/**
|
|
* The glyph on the shared parent menu.
|
|
*
|
|
* An infinity sign, because the family is called Nolimit. A cog said
|
|
* nothing, and there is no infinity dashicon.
|
|
*
|
|
* Drawn as TEXT rather than as an SVG passed to add_menu_page(), and
|
|
* the reason is colour. WordPress renders a data-URI SVG as a CSS
|
|
* background-image, and a background-image cannot inherit a colour —
|
|
* so it stays one fixed grey while every other icon lightens on hover
|
|
* and turns white on the current item. It looks subtly broken under
|
|
* every admin colour scheme, including the default. A `::before`
|
|
* glyph is text: it takes the colour it is given and follows all of
|
|
* those states for free, with no image request.
|
|
*
|
|
* Filterable so that changing it later needs no edit to this file.
|
|
* This file is byte-identical across the family and hash-frozen in
|
|
* each plugin's CI, so an edit here is a coordinated release for all
|
|
* of them; a filter is one line in one plugin.
|
|
*
|
|
* @param string $glyph A single character, or '' for the old cog.
|
|
*/
|
|
$glyph = apply_filters( 'wpmgr_menu_glyph', '∞' );
|
|
|
|
add_menu_page(
|
|
$title,
|
|
$title,
|
|
'manage_options',
|
|
WPMGR_MENU_SLUG,
|
|
'wpmgr_render_dashboard',
|
|
// 'none' makes WordPress emit the icon wrapper with no dashicon
|
|
// class on it, which is the hook the style below needs. A dashicon
|
|
// name here would win instead, because its class carries its own
|
|
// ::before content.
|
|
'' === $glyph ? 'dashicons-admin-generic' : 'none',
|
|
// Just below WooCommerce (55.x) and above Appearance (60). The
|
|
// fractional position is not decoration: an integer collides with
|
|
// whatever core or another plugin put there, and the loser is
|
|
// simply dropped.
|
|
58.9
|
|
);
|
|
|
|
if ( '' !== $glyph ) {
|
|
add_action( 'admin_head', 'wpmgr_menu_glyph_style' );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The one style rule the glyph needs.
|
|
*
|
|
* Printed in the head on every admin page, because the menu is on every
|
|
* admin page — a stylesheet enqueued only on this family's own screens
|
|
* would leave a blank square everywhere else.
|
|
*
|
|
* `font-family` has to be overridden: WordPress sets the icon wrapper to
|
|
* the Dashicons font, and Dashicons has no infinity glyph, so inheriting
|
|
* it renders nothing at all.
|
|
*
|
|
* 🔴 The box is WordPress's own, measured rather than guessed, and the
|
|
* glyph is centred in it geometrically rather than nudged.
|
|
*
|
|
* wp-admin/css/admin-menu.css sets `#adminmenu div.wp-menu-image` to
|
|
* 36x34, and `div.wp-menu-image:before` to `padding: 7px 0` — which is
|
|
* exactly 7 + 20 + 7 for a 20px dashicon on `line-height: 1`. The first
|
|
* version of this rule set `line-height: 1.4` and inherited that padding,
|
|
* making the line box 28px and the whole pseudo-element 42px inside a 34px
|
|
* parent. Kristian reported the result: the glyph sitting above centre.
|
|
*
|
|
* So: reproduce the 36x34 box, drop the padding that only existed to
|
|
* position a 20px line inside it, and centre with flex. That matters more
|
|
* for an arbitrary glyph than for a dashicon — U+221E sits on the maths
|
|
* axis rather than filling its em box, so any rule that positions by
|
|
* line-height and padding is positioning the box and hoping about the
|
|
* glyph. `align-items: center` centres what is actually drawn.
|
|
*/
|
|
function wpmgr_menu_glyph_style() {
|
|
$glyph = apply_filters( 'wpmgr_menu_glyph', '∞' );
|
|
|
|
if ( '' === $glyph ) {
|
|
return;
|
|
}
|
|
|
|
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- the slug is reduced to [a-z0-9_-] and the glyph to CSS numeric escapes; both are escaped for the context they are printed in, which is CSS rather than HTML.
|
|
printf(
|
|
'<style>#adminmenu .toplevel_page_%1$s .wp-menu-image::before{content:"%2$s";font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:20px;font-weight:400;display:flex;align-items:center;justify-content:center;width:36px;height:34px;padding:0;line-height:1}</style>',
|
|
preg_replace( '/[^a-z0-9_-]/', '', strtolower( WPMGR_MENU_SLUG ) ),
|
|
wpmgr_css_escape( $glyph )
|
|
);
|
|
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
|
|
}
|
|
|
|
/**
|
|
* A string that is safe inside a CSS string literal, whatever it was.
|
|
*
|
|
* 🔴 esc_attr() is the wrong tool here and it protected by accident.
|
|
* Nolimit Discounts measured it: a glyph of `x"} body{display:none}` comes
|
|
* out as `x"} …`, the CSS string never closes, and nothing breaks
|
|
* out — but only because the entity happens to contain no quote. The CSS
|
|
* parser does not decode HTML entities, so it was never escaping in this
|
|
* context; it was a substitution that coincidentally removed the one
|
|
* character that mattered. Two things follow from that: a glyph containing
|
|
* a quote renders as the literal text `"` in the menu, and a single
|
|
* backslash — which esc_attr() leaves alone, and which IS an escape to the
|
|
* CSS parser — swallows the closing quote and the rest of the rule with
|
|
* it. Measured, not reasoned: the string then runs on to the quote in
|
|
* "Segoe UI" and the declaration block is corrupt.
|
|
*
|
|
* So do not filter the input; change its alphabet. Every character becomes
|
|
* a CSS numeric escape, so the emitted value contains nothing but
|
|
* backslashes, hex digits and spaces and cannot leave the string for any
|
|
* input at all. No whitelist to keep honest, no character class to get
|
|
* wrong, and an emoji still works.
|
|
*
|
|
* The decoder is written out rather than using mb_ord() because WordPress
|
|
* supports installs without mbstring, and ord() on a multi-byte character
|
|
* silently yields the first byte — which would render our own ∞ as
|
|
* mojibake on exactly those installs and on no machine any of us test on.
|
|
*
|
|
* @param string $text Text from a filter, i.e. arbitrary.
|
|
* @return string CSS escapes, safe between double quotes.
|
|
*/
|
|
function wpmgr_css_escape( $text ) {
|
|
$out = '';
|
|
$len = strlen( $text );
|
|
|
|
for ( $i = 0; $i < $len; $i++ ) {
|
|
$byte = ord( $text[ $i ] );
|
|
|
|
if ( $byte < 0x80 ) {
|
|
$code = $byte;
|
|
$tail = 0;
|
|
} elseif ( $byte >= 0xF0 ) {
|
|
$code = $byte & 0x07;
|
|
$tail = 3;
|
|
} elseif ( $byte >= 0xE0 ) {
|
|
$code = $byte & 0x0F;
|
|
$tail = 2;
|
|
} elseif ( $byte >= 0xC0 ) {
|
|
$code = $byte & 0x1F;
|
|
$tail = 1;
|
|
} else {
|
|
continue; // A stray continuation byte is not a character.
|
|
}
|
|
|
|
while ( $tail > 0 && $i + 1 < $len && 0x80 === ( ord( $text[ $i + 1 ] ) & 0xC0 ) ) {
|
|
++$i;
|
|
$code = ( $code << 6 ) | ( ord( $text[ $i ] ) & 0x3F );
|
|
--$tail;
|
|
}
|
|
|
|
if ( $tail > 0 ) {
|
|
continue; // Truncated sequence.
|
|
}
|
|
|
|
// The trailing space terminates the escape, so a following hex
|
|
// digit cannot be read as part of it.
|
|
$out .= sprintf( '\\%X ', $code );
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* The shared landing page.
|
|
*
|
|
* Nothing here knows which plugins exist. Every card comes from the
|
|
* filter, so the page is correct whichever of them registered the menu and
|
|
* whichever of them have since been deactivated.
|
|
*/
|
|
function wpmgr_render_dashboard() {
|
|
if ( ! current_user_can( 'manage_options' ) ) {
|
|
wp_die( esc_html( 'You do not have permission to view this page.' ) );
|
|
}
|
|
|
|
/**
|
|
* Cards on the shared dashboard, one per installed manager plugin.
|
|
*
|
|
* Each entry:
|
|
* title string Plugin name, already translated by its owner.
|
|
* status string 'ok' | 'warn' | 'fail' — what the check said.
|
|
* headline string One short line: the number that matters.
|
|
* lines array label => value, a handful at most.
|
|
* url string Where "Open" goes.
|
|
* colour string The plugin's own colour, as #rrggbb.
|
|
*
|
|
* Implementations must not measure anything here. This filter runs on
|
|
* an admin page load; a card that walks the disk or makes an HTTP
|
|
* request turns somebody else's dashboard into a slow page, and they
|
|
* will not know why.
|
|
*
|
|
* @param array $cards The cards so far.
|
|
*/
|
|
$cards = apply_filters( 'wpmgr_dashboard_cards', array() );
|
|
|
|
echo '<div class="wrap wpmgr-dashboard">';
|
|
echo '<h1>' . esc_html( apply_filters( 'wpmgr_menu_title', 'Manager' ) ) . '</h1>';
|
|
|
|
if ( empty( $cards ) ) {
|
|
echo '<p>Nothing to report.</p></div>';
|
|
|
|
return;
|
|
}
|
|
|
|
echo '<style>'
|
|
. '.wpmgr-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-top:20px}'
|
|
. '.wpmgr-card{background:#fff;border:1px solid #dcdcde;border-radius:8px;padding:0;overflow:hidden}'
|
|
. '.wpmgr-card header{padding:14px 18px;color:#fff;display:flex;justify-content:space-between;align-items:center}'
|
|
. '.wpmgr-card header h2{margin:0;font-size:14px;color:inherit}'
|
|
. '.wpmgr-card .wpmgr-body{padding:16px 18px}'
|
|
. '.wpmgr-headline{font-size:22px;font-weight:600;margin:0 0 10px}'
|
|
. '.wpmgr-card dl{display:grid;grid-template-columns:1fr auto;gap:4px 12px;margin:0;font-size:13px}'
|
|
. '.wpmgr-card dt{color:#646970;margin:0}.wpmgr-card dd{margin:0;text-align:right}'
|
|
. '.wpmgr-state{font-size:11px;text-transform:uppercase;letter-spacing:.04em;'
|
|
. 'background:rgba(255,255,255,.22);padding:2px 8px;border-radius:10px}'
|
|
. '.wpmgr-card footer{padding:12px 18px;border-top:1px solid #f0f0f1}'
|
|
. '</style>';
|
|
|
|
echo '<div class="wpmgr-cards">';
|
|
|
|
$states = array(
|
|
'ok' => 'OK',
|
|
'warn' => 'Attention',
|
|
'fail' => 'Action needed',
|
|
);
|
|
|
|
foreach ( $cards as $card ) {
|
|
if ( empty( $card['title'] ) ) {
|
|
continue;
|
|
}
|
|
|
|
$colour = isset( $card['colour'] ) && preg_match( '/^#[0-9a-f]{6}$/i', $card['colour'] )
|
|
? $card['colour']
|
|
: '#50575e';
|
|
|
|
$status = isset( $card['status'] ) && isset( $states[ $card['status'] ] ) ? $card['status'] : 'ok';
|
|
|
|
echo '<section class="wpmgr-card">';
|
|
printf(
|
|
'<header style="background:%s"><h2>%s</h2><span class="wpmgr-state">%s</span></header>',
|
|
esc_attr( $colour ),
|
|
esc_html( $card['title'] ),
|
|
esc_html( $states[ $status ] )
|
|
);
|
|
|
|
echo '<div class="wpmgr-body">';
|
|
|
|
if ( ! empty( $card['headline'] ) ) {
|
|
echo '<p class="wpmgr-headline">' . esc_html( $card['headline'] ) . '</p>';
|
|
}
|
|
|
|
if ( ! empty( $card['lines'] ) && is_array( $card['lines'] ) ) {
|
|
echo '<dl>';
|
|
foreach ( $card['lines'] as $label => $value ) {
|
|
echo '<dt>' . esc_html( $label ) . '</dt><dd>' . esc_html( $value ) . '</dd>';
|
|
}
|
|
echo '</dl>';
|
|
}
|
|
|
|
echo '</div>';
|
|
|
|
if ( ! empty( $card['url'] ) ) {
|
|
printf(
|
|
'<footer><a class="button" href="%s">%s</a></footer>',
|
|
esc_url( $card['url'] ),
|
|
esc_html( isset( $card['action'] ) ? $card['action'] : 'Open' )
|
|
);
|
|
}
|
|
|
|
echo '</section>';
|
|
}
|
|
|
|
echo '</div></div>';
|
|
}
|
|
}
|