Advance full admin takeover from v1.1 to v1.12

Batches everything built since the 1.0 WP.org submission reset: Login
Redirect, the Tools/Export/Import screen takeovers, Modules rebuilt as
an addon marketplace, Menu Creator's reorder fix, Activity Log
redesigned into a filterable table with Admin/User tabs, dark-theme
fixes for Site Health/Personal Data/Updates, and an opt-in Disable
Gutenberg toggle. Continues the "nothing stays native" takeover goal
and closes gaps found via live testing and the WordPress Plugin Check
tool. See CHANGELOG.md for the full per-version breakdown (1.1-1.12).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-07-26 13:37:35 -05:00
co-authored by Claude Sonnet 5
parent faf9f35992
commit 24be114f9a
39 changed files with 3462 additions and 1653 deletions
+59 -2
View File
@@ -51,6 +51,11 @@ html.aurora-dark.aurora-ready #wpbody-content .postbox,
html.aurora-dark.aurora-ready #wpbody-content .card,
html.aurora-dark.aurora-ready #wpbody-content .welcome-panel,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table,
/* .widefat: WP's general "wide flat table" utility class, used beyond just
WP_List_Table screens — e.g. update-core.php's plugin/theme update
tables (class="widefat updates-table", no .wp-list-table and no
.striped either), which were completely uncovered without this. */
html.aurora-dark.aurora-ready #wpbody-content .widefat,
html.aurora-dark.aurora-ready #wpbody-content .stuffbox {
background: var(--aurora-card-bg);
border-color: var(--aurora-card-border);
@@ -79,17 +84,41 @@ html.aurora-dark.aurora-ready #wpbody-content .postbox .handle-order-lower {
/* Tables ---------------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table td {
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table td,
html.aurora-dark.aurora-ready #wpbody-content .widefat th,
html.aurora-dark.aurora-ready #wpbody-content .widefat td {
border-color: var(--aurora-border);
/* Core sets an explicit white background directly on <td> (not just the
row), which otherwise wins over .wp-list-table/.widefat's own dark
background above — transparent lets the row's actual background
(plain dark, or the striped-odd shade below) show through instead of a
white cell sitting on an otherwise-dark row. Seen first on Export/
Erase Personal Data's request-queue table, but this is a generic
WP_List_Table/.widefat gap, not specific to any one screen. */
background: transparent;
}
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table thead th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tfoot th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table thead th a,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tfoot th a {
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tfoot th a,
html.aurora-dark.aurora-ready #wpbody-content .widefat thead th,
html.aurora-dark.aurora-ready #wpbody-content .widefat tfoot th,
html.aurora-dark.aurora-ready #wpbody-content .widefat thead th a,
html.aurora-dark.aurora-ready #wpbody-content .widefat tfoot th a {
color: var(--aurora-text);
}
/* core's list-tables.css sets `.plugins tr { background: #fff; }` for the
native Plugins screen's list table — .plugins is a plain class name, not
scoped to that screen, so it also matches update-core.php's plugin-update
table (its <tbody> reuses the same class). Aurora's own Plugins screen is
a full custom rebuild that never hits this native markup, which is why
this collision hadn't surfaced before update-core.php. */
html.aurora-dark.aurora-ready #wpbody-content .plugins tr {
background: transparent;
}
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table.striped > tbody > :nth-child(odd),
html.aurora-dark.aurora-ready #wpbody-content .striped > tbody > :nth-child(odd) {
background: var(--aurora-bg-subtle);
@@ -492,3 +521,31 @@ html.aurora-dark.aurora-ready #wpbody-content .font-library__font-card {
border-color: var(--aurora-card-border);
color: var(--aurora-text);
}
/* Site Health (site-health.php) is left running WordPress core's own test
* logic rather than reimplemented — its Status tab async checks hit core's
* own /wp-site-health/v1/tests/* REST routes, and third-party plugins can
* add their own checks via the site_status_tests filter, so reproducing it
* risks both drifting from core and silently dropping other plugins'
* checks. Only the framing/theming is Aurora's, same "frame native content"
* treatment as the block editor above.
*
* Three of its own elements paint a hardcoded white/off-white background
* that the generic #wpbody-content light-text cascade doesn't reach,
* leaving light text unreadable against it — the same "boot-layout" pattern
* fixed above, just a different screen. */
html.aurora-dark.aurora-ready #wpbody-content .health-check-header,
html.aurora-dark.aurora-ready #wpbody-content .health-check-accordion-panel {
background: var(--aurora-card-bg);
}
html.aurora-dark.aurora-ready #wpbody-content .health-check-accordion-trigger {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
/* The Security/Performance category pill inside each trigger row keeps its
* own dark text color (readable against the trigger's original near-white
* background) — needs to flip to light along with the trigger's background
* above, or it becomes unreadable in the other direction. */
html.aurora-dark.aurora-ready #wpbody-content .health-check-accordion-trigger .badge {
color: var(--aurora-text);
}
+99 -18
View File
@@ -10,7 +10,7 @@ defined("ABSPATH") || exit();
class ActivityLog
{
const TABLE = "aurora_admin_activity_log";
const DB_VERSION = "1";
const DB_VERSION = "2";
const DB_OPTION = "aurora_admin_activity_log_db_version";
public function __construct()
@@ -19,9 +19,12 @@ class ActivityLog
add_action("save_post", [self::class, "on_save_post"], 10, 3);
add_action("before_delete_post", [self::class, "on_delete_post"]);
add_action("add_attachment", [self::class, "on_add_attachment"]);
add_action("delete_attachment", [self::class, "on_delete_attachment"]);
add_action("user_register", [self::class, "on_user_register"]);
add_action("profile_update", [self::class, "on_profile_update"]);
add_action("wp_login", [self::class, "on_login"], 10, 2);
add_action("wp_logout", [self::class, "on_logout"]);
add_action("activated_plugin", [self::class, "on_plugin_activated"]);
add_action("deactivated_plugin", [self::class, "on_plugin_deactivated"]);
add_action("switch_theme", [self::class, "on_theme_switch"]);
@@ -52,6 +55,7 @@ class ActivityLog
object_label VARCHAR(191) NOT NULL DEFAULT '',
user_id BIGINT UNSIGNED NULL,
user_name VARCHAR(191) NOT NULL DEFAULT '',
ip_address VARCHAR(45) NULL,
details LONGTEXT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
@@ -66,10 +70,23 @@ class ActivityLog
update_option(self::DB_OPTION, self::DB_VERSION);
}
private static function record($action_type, $object_type, $object_id, $object_label, $details = [])
/**
* @param array|null $actor Overrides the "current user" attribution with
* an explicit ["id" => int, "name" => string] — needed for wp_logout,
* where wp_get_current_user() has already been reset to the logged-out
* (id 0) user by the time this hook fires, which would otherwise record
* every logout as done by nobody.
*/
private static function record($action_type, $object_type, $object_id, $object_label, $details = [], $actor = null)
{
global $wpdb;
$user = wp_get_current_user();
if ($actor === null) {
$current = wp_get_current_user();
$actor = [
"id" => $current && $current->exists() ? $current->ID : null,
"name" => $current && $current->exists() ? $current->display_name : "",
];
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- $wpdb->insert() is WordPress's own escaping-safe API for writing to a custom table (not raw SQL); there's no core post/postmeta abstraction for Aurora's own activity-log table, so any access to it is necessarily "direct" by this sniff's definition.
$wpdb->insert(self::table_name(), [
@@ -77,69 +94,133 @@ class ActivityLog
"object_type" => $object_type,
"object_id" => $object_id,
"object_label" => substr((string) $object_label, 0, 191),
"user_id" => $user && $user->exists() ? $user->ID : null,
"user_name" => $user && $user->exists() ? $user->display_name : "",
"user_id" => $actor["id"],
"user_name" => $actor["name"],
"ip_address" => self::client_ip(),
"details" => $details ? wp_json_encode($details) : null,
"created_at" => current_time("mysql"),
]);
}
/**
* REMOTE_ADDR only — the actual TCP peer, not spoofable by a request
* header. Behind a reverse proxy/load balancer this will read as the
* proxy's own address rather than the original visitor; deliberately not
* trusting X-Forwarded-For or similar instead, since blindly trusting a
* client-supplied header for anything (even just display, as here) is the
* standard IP-spoofing footgun, and this plugin has no reliable way to
* know whether a given host's proxy is trusted.
*/
private static function client_ip()
{
$ip = isset($_SERVER["REMOTE_ADDR"]) ? sanitize_text_field(wp_unslash($_SERVER["REMOTE_ADDR"])) : "";
return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : null;
}
public static function on_save_post($post_id, $post, $update)
{
// save_post fires on autosaves and revisions too — neither represents a
// real user-visible change, so both are skipped here.
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
// save_post fires on autosaves, revisions, and attachments too — none
// represent a real user-visible content change (attachments get their
// own add_attachment/delete_attachment hooks below), so all three are
// skipped here.
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id) || $post->post_type === "attachment") {
return;
}
if (!in_array($post->post_status, ["publish", "draft", "pending", "future", "private"], true)) {
return;
}
self::record($update ? "post_updated" : "post_created", "post", $post_id, $post->post_title ?: "(no title)");
// object_type is the post's real type (post/page/a custom post type),
// not a hardcoded "post" — the Activity Log's Type column needs this to
// read correctly for anything that isn't the built-in Posts type.
self::record($update ? "updated" : "created", $post->post_type, $post_id, $post->post_title ?: "(no title)");
}
public static function on_delete_post($post_id)
{
$post = get_post($post_id);
if (!$post || $post->post_type === "revision") {
if (!$post || $post->post_type === "revision" || $post->post_type === "attachment") {
return;
}
self::record("post_deleted", "post", $post_id, $post->post_title ?: "(no title)");
self::record("deleted", $post->post_type, $post_id, $post->post_title ?: "(no title)");
}
public static function on_add_attachment($post_id)
{
$post = get_post($post_id);
self::record("created", "attachment", $post_id, $post ? $post->post_title : "(no title)");
}
public static function on_delete_attachment($post_id)
{
$post = get_post($post_id);
self::record("deleted", "attachment", $post_id, $post ? $post->post_title : "(no title)");
}
public static function on_user_register($user_id)
{
$user = get_userdata($user_id);
self::record("user_registered", "user", $user_id, $user ? $user->user_login : "");
// Self-registration means the visitor isn't authenticated yet at this
// point, so wp_get_current_user() would show "not logged in" rather
// than the account that was just created. Attributing explicitly to the
// new account instead — including when an admin creates the account —
// is a deliberate choice for this specific event: unlike profile edits
// (where the acting admin, shown via the default wp_get_current_user()
// attribution, is the more useful "who did this"), a registration event
// is overwhelmingly self-service, so "who this account belongs to" is
// the more useful read here, and treating both origins the same way
// keeps this one event type's Author column consistent regardless of
// which flow created the account.
self::record("registered", "user", $user_id, $user ? $user->user_login : "", [], [
"id" => $user_id,
"name" => $user ? $user->display_name : "",
]);
}
public static function on_profile_update($user_id)
{
$user = get_userdata($user_id);
self::record("user_updated", "user", $user_id, $user ? $user->user_login : "");
self::record("profile_updated", "user", $user_id, $user ? $user->user_login : "");
}
public static function on_login($user_login, $user)
{
self::record("login", "user", $user->ID, $user_login);
// Not wp_get_current_user(): wp_login fires from inside wp_signon(),
// before wp-login.php's own form handler calls wp_set_current_user() —
// the same ordering issue on_logout() has, just in the other direction.
// $user is passed in for exactly this reason; use it directly instead.
self::record("login", "user", $user->ID, $user_login, [], [
"id" => $user->ID,
"name" => $user->display_name,
]);
}
public static function on_logout($user_id)
{
$user = $user_id ? get_userdata($user_id) : false;
$login = $user ? $user->user_login : "";
self::record("logout", "user", $user_id ?: null, $login, [], [
"id" => $user_id ?: null,
"name" => $user ? $user->display_name : "",
]);
}
public static function on_plugin_activated($plugin)
{
self::record("plugin_activated", "plugin", null, $plugin);
self::record("activated", "plugin", null, $plugin);
}
public static function on_plugin_deactivated($plugin)
{
self::record("plugin_deactivated", "plugin", null, $plugin);
self::record("deactivated", "plugin", null, $plugin);
}
public static function on_theme_switch($new_name)
{
self::record("theme_switched", "theme", null, $new_name);
self::record("switched", "theme", null, $new_name);
}
public static function on_settings_updated()
{
self::record("settings_updated", "settings", null, "Aurora Admin settings");
self::record("updated", "settings", null, "Aurora Admin settings");
}
}
+49 -2
View File
@@ -23,6 +23,11 @@ class ActivityLogData
return current_user_can("manage_options");
},
"args" => [
// "admin" = site/content activity (everything except user-account
// events), "user" = account activity (login/logout/registration/
// profile updates) — the Activity Log page's two tabs. Omit for
// unfiltered (both).
"scope" => ["type" => "string", "required" => false, "enum" => ["admin", "user"]],
"action_type" => ["type" => "string", "required" => false],
"object_type" => ["type" => "string", "required" => false],
"search" => ["type" => "string", "required" => false],
@@ -40,6 +45,13 @@ class ActivityLogData
$where = ["1=1"];
$params = [];
$scope = $request->get_param("scope");
if ($scope === "user") {
$where[] = "object_type = 'user'";
} elseif ($scope === "admin") {
$where[] = "object_type != 'user'";
}
if ($request->get_param("action_type")) {
$where[] = "action_type = %s";
$params[] = sanitize_text_field($request->get_param("action_type"));
@@ -73,15 +85,50 @@ class ActivityLogData
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reading Aurora's own custom activity-log table; always passed through $wpdb->prepare() with $params plus the trailing $per_page/$offset bound via %d/%d.
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, $offset])));
$items = array_map(function ($row) {
// Live user info (avatar/role/email) rather than freezing it into each
// log row at record time — reflects the account's CURRENT role/email
// (correct if either changes later) and avoids duplicating PII into
// every single event row when user_id is already a foreign key back to
// it. Batch-fetched once for every distinct user_id on this page,
// rather than one get_userdata() call per row.
$user_ids = array_values(array_unique(array_filter(array_map(function ($row) {
return $row->user_id ? (int) $row->user_id : 0;
}, $rows))));
$users_by_id = [];
if ($user_ids) {
// $roles[0] is a role SLUG (e.g. "shop_manager") — its translated
// display name has to come from wp_roles()'s own registered name for
// that slug, not a guess like ucfirst($slug); that only happens to
// work for the single-word built-in roles.
$all_roles = wp_roles()->roles;
foreach (get_users(["include" => $user_ids, "fields" => "all_with_meta"]) as $u) {
$slug = $u->roles ? $u->roles[0] : "";
$role_name = $slug && isset($all_roles[$slug]) ? translate_user_role($all_roles[$slug]["name"]) : "";
$users_by_id[$u->ID] = [
"avatar" => get_avatar_url($u->ID, ["size" => 64]),
"role" => $role_name,
"email" => $u->user_email,
"editUrl" => esc_url_raw(admin_url("user-edit.php?user_id=" . $u->ID)),
];
}
}
$items = array_map(function ($row) use ($users_by_id) {
$uid = $row->user_id ? (int) $row->user_id : null;
$u = $uid && isset($users_by_id[$uid]) ? $users_by_id[$uid] : null;
return [
"id" => (int) $row->id,
"actionType" => $row->action_type,
"objectType" => $row->object_type,
"objectId" => $row->object_id ? (int) $row->object_id : null,
"objectLabel" => $row->object_label,
"userId" => $row->user_id ? (int) $row->user_id : null,
"userId" => $uid,
"userName" => $row->user_name,
"userAvatar" => $u ? $u["avatar"] : null,
"userRole" => $u ? $u["role"] : "",
"userEmail" => $u ? $u["email"] : "",
"userEditUrl" => $u ? $u["editUrl"] : "",
"ipAddress" => $row->ip_address,
"details" => $row->details ? json_decode($row->details, true) : null,
"createdAt" => $row->created_at,
];
+10
View File
@@ -31,6 +31,9 @@ use AuroraAdmin\Pages\NativeSettingsPages;
use AuroraAdmin\Rest\PermalinksData;
use AuroraAdmin\Rest\DiscussionData;
use AuroraAdmin\Pages\AppearancePages;
use AuroraAdmin\Pages\ToolsPage;
use AuroraAdmin\Pages\ExportPage;
use AuroraAdmin\Pages\ImportPage;
use AuroraAdmin\Rest\NavMenusData;
use AuroraAdmin\Rest\ThemesData;
use AuroraAdmin\Rest\WidgetsData;
@@ -43,6 +46,8 @@ use AuroraAdmin\Roles\RolesData;
use AuroraAdmin\ActivityLog\ActivityLog;
use AuroraAdmin\ActivityLog\ActivityLogData;
use AuroraAdmin\Login\LoginStyle;
use AuroraAdmin\Login\LoginRedirect;
use AuroraAdmin\Editor\DisableGutenberg;
use AuroraAdmin\Media\SvgUploads;
use AuroraAdmin\Media\MediaFolders;
use AuroraAdmin\ImageFormats\ImageFormats;
@@ -84,6 +89,9 @@ class AuroraAdmin
new PermalinksData();
new DiscussionData();
new AppearancePages();
new ToolsPage();
new ExportPage();
new ImportPage();
new NavMenusData();
new ThemesData();
new WidgetsData();
@@ -96,6 +104,8 @@ class AuroraAdmin
new ActivityLog();
new ActivityLogData();
new LoginStyle();
new LoginRedirect();
new DisableGutenberg();
new SvgUploads();
new MediaFolders();
new ImageFormats();
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace AuroraAdmin\Editor;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Settings > Screens > Editor: "Disable Gutenberg" — opt-in, off by default.
* Falls back to WordPress core's own classic (meta-box/TinyMCE) post editor
* via the same core-supported filter the official Classic Editor plugin
* uses, rather than framing or rebuilding the block editor itself — the
* block editor stays one of the two deliberate "still native" screens in
* the admin takeover (alongside the Theme/Plugin File Editor), so this is
* an escape hatch around it, not a takeover of it.
*/
class DisableGutenberg
{
public function __construct()
{
add_filter("use_block_editor_for_post_type", [self::class, "maybe_disable"], 100);
}
public static function maybe_disable($use_block_editor)
{
if ((bool) Settings::get("disable_gutenberg", false)) {
return false;
}
return $use_block_editor;
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace AuroraAdmin\Login;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Settings > Login > "Login Redirect": optionally sends logged-out wp-admin
* visitors to a custom URL instead of the login screen, and/or sends
* specific roles to a custom URL after logging in instead of the dashboard.
* Both are opt-in and empty by default (normal WordPress behavior).
*
* Deliberately does NOT touch wp-login.php itself — it is never hidden,
* blocked, or moved, so this can only change where someone lands
* before/after that point, never whether they can reach the login form.
* Changing/hiding the login URL itself is a separate, higher-risk feature
* not implemented here.
*/
class LoginRedirect
{
public function __construct()
{
add_filter("login_redirect", [self::class, "after_login"], 10, 3);
add_filter("allowed_redirect_hosts", [self::class, "allow_configured_hosts"]);
// wp_loaded, not admin_init: WordPress's own auth_redirect() (called
// from wp-admin/admin.php, before admin_init fires) already redirects
// an unauthenticated visitor to wp-login.php and exits — admin_init
// would never run for the case this needs to catch. wp_loaded fires
// from inside wp-settings.php, which wp-admin/admin.php requires and
// waits on BEFORE calling auth_redirect(), so it reliably runs first.
add_action("wp_loaded", [self::class, "guard_admin"]);
}
private static function enabled()
{
return (bool) Settings::get("login_redirect_enabled", false);
}
/** Destination after a successful login, for the configured roles. */
public static function after_login($redirect_to, $requested_redirect_to, $user)
{
if (!self::enabled() || !($user instanceof \WP_User) || !$user->exists()) {
return $redirect_to;
}
$roles = (array) Settings::get("redirect_roles", []);
if (!$roles || !array_intersect((array) $user->roles, $roles)) {
return $redirect_to;
}
$target = trim((string) Settings::get("redirect_after_login_url", ""));
return $target !== "" ? esc_url_raw($target) : $redirect_to;
}
/**
* Sends logged-out visitors to a custom URL instead of the default
* wp-login.php screen when they try to open wp-admin. Scoped tightly to
* real browser page loads under wp-admin — admin-ajax.php and
* admin-post.php (both used by logged-out requests via the *_nopriv_*
* hooks other plugins rely on for public form handling), async uploads,
* REST, and multisite's network admin are all left untouched, so nothing
* outside the login screen itself is affected.
*/
public static function guard_admin()
{
if (!self::enabled() || is_user_logged_in()) {
return;
}
if (!is_admin() || is_network_admin() || wp_doing_ajax()) {
return;
}
if (defined("REST_REQUEST") && REST_REQUEST) {
return;
}
$excluded_scripts = ["admin-post.php", "admin-ajax.php", "async-upload.php"];
if (in_array($GLOBALS["pagenow"] ?? "", $excluded_scripts, true)) {
return;
}
$target = trim((string) Settings::get("redirect_unauthenticated_url", ""));
// Empty, or would send a logged-out visitor straight back into
// wp-admin — the latter is almost certainly a misconfiguration and
// would otherwise create a redirect loop.
if ($target === "" || stripos($target, "wp-admin") !== false) {
return;
}
wp_safe_redirect(esc_url_raw($target));
exit();
}
/**
* Lets wp_safe_redirect() honor an external target the admin configured
* for either redirect above, instead of silently falling back to the site
* home URL — both settings come from manage_options-gated Settings, never
* from request input, so there's no open-redirect exposure in trusting
* their host.
*/
public static function allow_configured_hosts($hosts)
{
foreach (["redirect_unauthenticated_url", "redirect_after_login_url"] as $key) {
$host = wp_parse_url(trim((string) Settings::get($key, "")), PHP_URL_HOST);
if ($host) {
$hosts[] = $host;
}
}
return $hosts;
}
}
+106 -34
View File
@@ -14,14 +14,38 @@ class MenuCreator
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_menus_db_version";
/** Cached merged item list for the current user (per request). */
private static $items_cache = null;
/** Raw top-level menu snapshot, captured before apply() hides anything. */
private static $native_snapshot = null;
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
// Late priority so it runs after all other plugins have registered
// their own menu items — there's nothing left to reorder/hide otherwise.
// Snapshot the full native menu just before apply() runs, so the editor
// can list (and re-enable) items this config hides. Both run after every
// other plugin has registered its menu items.
add_action("admin_menu", [self::class, "capture_native"], PHP_INT_MAX - 1);
add_action("admin_menu", [self::class, "apply"], PHP_INT_MAX);
}
/** Captures the raw top-level menu for the editor (see native_snapshot()). */
public static function capture_native()
{
self::$native_snapshot = \AuroraAdmin\Shell\MenuSerializer::top_level_items();
}
/**
* The raw top-level menu items for the current admin page load. The Menu
* Creator page feeds this into its serverData (SubApps.php) because the
* REST route can't build the admin menu.
*/
public static function native_snapshot()
{
return is_array(self::$native_snapshot) ? self::$native_snapshot : [];
}
public static function table_name()
{
global $wpdb;
@@ -57,64 +81,112 @@ class MenuCreator
}
/**
* Applies every published config whose target roles match the current
* user: hides items marked hidden and relabels items with a custom label.
* Merged item list from every published config whose target roles match
* the current user. Cached for the request (read by both apply() and
* ordered_slugs() on every admin page load).
*/
public static function apply()
private static function current_user_items()
{
global $menu, $wpdb;
if (self::$items_cache !== null) {
return self::$items_cache;
}
self::$items_cache = [];
global $wpdb;
if (!is_admin() || !function_exists("wp_get_current_user")) {
return;
return self::$items_cache;
}
$user = wp_get_current_user();
if (!$user || !$user->exists()) {
return;
return self::$items_cache;
}
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name, not user input; no user-supplied values in this query. Runs on every admin page load to build the sidebar menu, so a per-request cache wouldn't help; a persistent cache would need its own invalidation on every config save, more complexity than this read is worth.
$configs = $wpdb->get_results("SELECT roles, items FROM {$table} WHERE status = 'published'");
if (!$configs) {
return self::$items_cache;
}
$items = [];
foreach ($configs as $config) {
$roles = json_decode($config->roles ?: "[]", true);
if (!is_array($roles) || !$roles || !array_intersect((array) $user->roles, $roles)) {
continue;
}
$decoded = json_decode($config->items ?: "[]", true);
if (is_array($decoded)) {
$items = array_merge($items, $decoded);
}
}
self::$items_cache = $items;
return $items;
}
/**
* Hides items marked hidden and relabels items with a custom label, on the
* native top-level $menu (which Aurora's MenuSerializer then reads).
*/
public static function apply()
{
global $menu;
if (!is_array($menu)) {
return;
}
foreach ($configs as $config) {
$roles = json_decode($config->roles ?: "[]", true);
if (!is_array($roles) || !$roles) {
continue;
}
if (!array_intersect((array) $user->roles, $roles)) {
foreach (self::current_user_items() as $item) {
$native_id = isset($item["native_id"]) ? $item["native_id"] : "";
if ($native_id === "") {
continue;
}
$items = json_decode($config->items ?: "[]", true);
if (!is_array($items)) {
continue;
}
foreach ($items as $item) {
$native_id = isset($item["native_id"]) ? $item["native_id"] : "";
if ($native_id === "") {
foreach ($menu as $key => $entry) {
if (!isset($entry[2]) || $entry[2] !== $native_id) {
continue;
}
foreach ($menu as $key => $entry) {
if (!isset($entry[2]) || $entry[2] !== $native_id) {
continue;
}
if (!empty($item["hidden"])) {
unset($menu[$key]);
continue 2;
}
if (!empty($item["hidden"])) {
unset($menu[$key]);
continue 2;
}
if (!empty($item["label"])) {
$menu[$key][0] = sanitize_text_field($item["label"]);
}
if (!empty($item["label"])) {
$menu[$key][0] = sanitize_text_field($item["label"]);
}
}
}
}
/**
* Desired top-level slug order for the current user, from the saved config
* (items sorted by their 'order' field). Empty when no config applies.
*
* Consumed by MenuSerializer so Aurora's own sidebar reflects the order —
* WordPress's native `menu_order` filter only reorders the core admin menu,
* which Aurora hides, so it can't be used here.
*/
public static function ordered_slugs()
{
$items = self::current_user_items();
if (!$items) {
return [];
}
usort($items, function ($a, $b) {
$ao = isset($a["order"]) ? (int) $a["order"] : 0;
$bo = isset($b["order"]) ? (int) $b["order"] : 0;
return $ao <=> $bo;
});
$slugs = [];
foreach ($items as $item) {
$slug = isset($item["native_id"]) ? $item["native_id"] : "";
if ($slug !== "" && !in_array($slug, $slugs, true)) {
$slugs[] = $slug;
}
}
return $slugs;
}
}
+4 -6
View File
@@ -82,17 +82,15 @@ class MenuCreatorData
{
$menu = \AuroraAdmin\Shell\MenuSerializer::serialize();
$flat = [];
// Top-level items only. These are what the Menu Creator can reorder,
// hide, and rename today; submenu editing isn't applied yet, so it isn't
// offered (showing controls that do nothing is worse than not showing
// them).
foreach ($menu as $item) {
if (!empty($item["separator"]) || empty($item["id"])) {
continue;
}
$flat[] = ["native_id" => $item["id"], "title" => $item["title"], "icon" => $item["icon"]];
foreach ($item["submenu"] ?? [] as $sub) {
if (empty($sub["id"])) {
continue;
}
$flat[] = ["native_id" => $sub["id"], "title" => $item["title"] . " › " . $sub["title"], "icon" => null];
}
}
return new \WP_REST_Response($flat, 200);
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the Export screen (export.php) for the "full admin takeover"
* initiative — a real Aurora-built form, not a reframed native page.
*
* Safe to fully rebuild (unlike Tools/Site Health): export.php's actual file
* generation (the `?download=true` request) calls export_wp() and die()s
* before ever reaching in_admin_header/admin_footer — the two hooks
* ScreenHijack's buffering relies on — so that request is never touched by
* the hijack at all and runs completely native, unmodified. Only the HTML
* form (the plain GET view with no `download` param) is replaced; Aurora's
* form submits via a real browser navigation to the same
* export.php?download=true&... URL, so the download itself is byte-for-byte
* what core would have produced.
*/
class ExportPage
{
public function __construct()
{
ScreenHijack::register(
"load-export.php",
"export", // Matches core's own capability for this screen.
"full_admin_takeover",
"export",
"aurora-admin-export-root",
null,
null,
function () {
return [
"exportUrl" => esc_url_raw(admin_url("export.php")),
];
}
);
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the Import screen (import.php) for the "full admin takeover"
* initiative — a real Aurora-built listing, not a reframed native page.
*
* Only the LISTING page is rebuilt. Running an already-active importer
* (native "Run Importer" link) still goes to admin.php?import={id} — a
* screen registered and entirely rendered by whatever third-party importer
* plugin owns that id (register_importer()'s own callback). That page is
* arbitrary, unpredictable third-party UI outside this hijack's scope
* (a different load-{hook}), and stays native, same as e.g. the block
* editor stays native beyond its frame.
*
* Install/activate reuses existing, already-proven mechanisms instead of
* new logic: WordPress core's own admin-ajax "install-plugin" action (same
* one native's "Install Now" button triggers) for installing, and Aurora's
* own aurora-admin/v1/plugins/activate route (see PluginsData.php) for
* activation.
*/
class ImportPage
{
public function __construct()
{
ScreenHijack::register(
"load-import.php",
"import", // Matches core's own capability for this screen.
"full_admin_takeover",
"import",
"aurora-admin-import-root",
null,
null,
function () {
return self::build_data();
}
);
}
private static function build_data()
{
if (!function_exists("get_importers")) {
require_once ABSPATH . "wp-admin/includes/import.php";
}
// Real, already-active importers (registered via register_importer() by
// an active importer plugin) — global $wp_importers. get_importers()
// returns that global as-is, which is null (not an empty array) until
// something actually calls register_importer() — true whenever no
// importer plugin is active, e.g. a fresh install. Native import.php
// never hits this directly: it only ever reads $importers via isset(),
// and PHP auto-vivifies null into an array the moment popular_importers
// gets merged in via `$importers[$id] = [...]`, so by the time IT
// foreach()es, an assignment has always already happened. This code
// foreach()es the raw result directly, so it needs its own guard.
$active = get_importers();
if (!is_array($active)) {
$active = [];
}
$can_install = current_user_can("install_plugins");
$popular = $can_install ? wp_get_popular_importers() : [];
$merged = [];
foreach ($active as $id => $data) {
$merged[] = [
"id" => $id,
"name" => $data[0],
"description" => $data[1],
"status" => "active",
"runUrl" => esc_url_raw(self_admin_url("admin.php?import=" . $id)),
];
}
// Same de-dup + dummy-registration logic as native import.php: a
// popular importer already covered by an active one is skipped.
foreach ($popular as $pop_key => $pop) {
$importer_id = isset($pop["importer-id"]) ? $pop["importer-id"] : $pop_key;
if (isset($active[$pop_key]) || isset($active[$importer_id])) {
continue;
}
$plugin_slug = isset($pop["plugin-slug"]) ? $pop["plugin-slug"] : "";
$entry = [
"id" => $importer_id,
"name" => $pop["name"],
"description" => $pop["description"],
"slug" => $plugin_slug,
// Deterministic regardless of current status — provided for every
// entry so the front end never has to construct an admin URL
// itself (this respects any custom/network admin path).
"runUrl" => esc_url_raw(self_admin_url("admin.php?import=" . $importer_id)),
];
if ($plugin_slug !== "" && file_exists(WP_PLUGIN_DIR . "/" . $plugin_slug)) {
if (!function_exists("get_plugins")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
$plugins = get_plugins("/" . $plugin_slug);
if ($plugins) {
$keys = array_keys($plugins);
$entry["status"] = "installed_inactive";
$entry["pluginFile"] = $plugin_slug . "/" . $keys[0];
} else {
$entry["status"] = "not_installed";
}
} else {
$entry["status"] = "not_installed";
}
$merged[] = $entry;
}
usort($merged, function ($a, $b) {
return strcasecmp($a["name"], $b["name"]);
});
// Any third-party output hooked to import_filters (a genuine, if rarely
// used, extension point) — same capture-and-relay approach as Tools'
// tool_box. Empty on a site with nothing hooked to it.
ob_start();
do_action("import_filters");
$native_filters_html = trim((string) ob_get_clean());
return [
"importers" => $merged,
"canInstallPlugins" => $can_install,
"installNonce" => wp_create_nonce("updates"),
"ajaxUrl" => esc_url_raw(admin_url("admin-ajax.php")),
"pluginSearchUrl" => esc_url_raw(network_admin_url("plugin-install.php?tab=search&type=tag&s=importer")),
"nativeFiltersHtml" => $native_filters_html,
];
}
}
+8
View File
@@ -35,6 +35,7 @@ class SubApps
public static function pages()
{
return [
"aurora-admin-modules" => ["title" => __("Modules", "aurora-admin"), "entry" => "modules"],
"aurora-admin-menu-creator" => ["title" => __("Menu Creator", "aurora-admin"), "entry" => "menu-creator"],
"aurora-admin-notices" => ["title" => __("Admin Notices", "aurora-admin"), "entry" => "admin-notices"],
"aurora-admin-roles" => ["title" => __("Role Editor", "aurora-admin"), "entry" => "role-editor"],
@@ -164,6 +165,13 @@ class SubApps
$data["theme"] = wp_get_theme()->get("Name");
}
// The Menu Creator editor needs the raw top-level menu, which can't be
// read over REST (the admin $menu isn't built there). Pass the snapshot
// captured during this admin page load instead.
if ($page === "aurora-admin-menu-creator") {
$data["nativeItems"] = \AuroraAdmin\MenuCreator\MenuCreator::native_snapshot();
}
$root_id = "aurora-admin-" . $pages[$page]["entry"] . "-root";
?>
<div
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the "Available Tools" screen (tools.php) for the "full admin
* takeover" initiative — a real hijacked replacement, not just a reframed
* native page.
*
* tools.php's whole purpose is being an extension point: other plugins add
* their own cards to it via the `tool_box` action (WooCommerce, migration
* tools, etc. have historically used it). A pure from-scratch rebuild would
* silently drop that content the moment Aurora takes over the screen, so
* this still captures and re-renders whatever `tool_box` produces — Aurora
* owns the page shell (header, card styling, layout) but not the content of
* hooks it doesn't control.
*
* Import, Export, Site Health, Export/Erase Personal Data, and the Theme/
* Plugin File Editors are separate screens (their own load-{file} hooks) and
* are not covered by this — Available Tools only.
*
* Native tools.php's "Categories and Tags Converter" card is NOT produced by
* the tool_box hook — it's hardcoded directly in core's own template, gated
* on the `import` capability plus category/tag manage_terms. Replicated here
* with the identical gate so visibility exactly matches what core itself
* would have shown.
*/
class ToolsPage
{
public function __construct()
{
ScreenHijack::register(
"load-tools.php",
"edit_posts", // Matches core's own capability for this screen.
"full_admin_takeover",
"tools",
"aurora-admin-tools-root",
null,
null,
function () {
// Same gate core's own tools.php applies before showing the
// Categories and Tags Converter card.
$show_converter = false;
if (current_user_can("import")) {
$cats = get_taxonomy("category");
$tags = get_taxonomy("post_tag");
$show_converter =
($cats && current_user_can($cats->cap->manage_terms)) ||
($tags && current_user_can($tags->cap->manage_terms));
}
// The native call to do_action('tool_box') already happened during
// this same request (inside the buffer ScreenHijack discards) — this
// is a second, independent invocation solely to capture its output
// for Aurora's own page, so any OTHER plugin's tool_box card still
// shows up here. Standard "buffer a hook to capture its markup"
// pattern; safe for the well-behaved, output-only callbacks this
// hook is meant for.
ob_start();
do_action("tool_box");
$native_tools_html = trim((string) ob_get_clean());
return [
"showCategoriesConverter" => $show_converter,
"importUrl" => esc_url_raw(admin_url("import.php")),
"nativeToolsHtml" => $native_tools_html,
];
}
);
}
}
+1
View File
@@ -111,6 +111,7 @@ class Security
// Drop the s.w.org DNS-prefetch the emoji script adds.
add_filter("wp_resource_hints", function ($hints, $relation_type) {
if ($relation_type === "dns-prefetch") {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- "emoji_svg_url" is a WordPress core filter (wp-includes/formatting.php), not a hook this plugin owns; it is applied here only to read core's own emoji SVG base URL so the matching s.w.org dns-prefetch hint can be identified and removed.
$emoji_svg = apply_filters("emoji_svg_url", "https://s.w.org/images/core/emoji/");
$hints = array_filter($hints, function ($h) use ($emoji_svg) {
return strpos((string) $h, $emoji_svg) === false && strpos((string) $h, "s.w.org") === false;
+56
View File
@@ -102,6 +102,62 @@ class MenuSerializer
$items[] = $entry;
}
// Apply the Menu Creator's saved order (if any) for the current user.
// Configured items come first in their saved order; anything not in the
// config — separators, and newly installed plugins' menu items — keeps
// its original position after them, so new menu items never disappear.
$order = \AuroraAdmin\MenuCreator\MenuCreator::ordered_slugs();
if ($order) {
$rank = array_flip($order);
$tail = count($order);
$decorated = [];
foreach ($items as $i => $entry) {
$slug = isset($entry["id"]) ? $entry["id"] : "";
$r = $slug !== "" && isset($rank[$slug]) ? $rank[$slug] : $tail + $i;
$decorated[] = ["r" => $r, "i" => $i, "entry" => $entry];
}
usort($decorated, function ($a, $b) {
return $a["r"] <=> $b["r"] ?: $a["i"] <=> $b["i"];
});
$items = array_map(function ($d) {
return $d["entry"];
}, $decorated);
}
return $items;
}
/**
* Raw top-level menu items ({native_id,title,icon}) for the Menu Creator
* editor. Unlike serialize() this must be captured BEFORE MenuCreator hides
* anything (so hidden items still appear and can be toggled back on) — and
* it can't run over REST, where the admin $menu isn't built. See
* MenuCreator::capture_native().
*/
public static function top_level_items()
{
global $menu;
if (!is_array($menu)) {
return [];
}
$items = [];
foreach ($menu as $item) {
$classes = isset($item[4]) ? $item[4] : "";
if (strpos($classes, "wp-menu-separator") !== false) {
continue;
}
$cap = isset($item[1]) ? $item[1] : "";
$slug = isset($item[2]) ? $item[2] : "";
if ($slug === "" || $slug === "aurora-admin" || empty($cap) || !current_user_can($cap)) {
continue;
}
$title = self::clean_title(isset($item[0]) ? $item[0] : "");
$items[] = [
"native_id" => $slug,
"title" => $title["title"],
"icon" => self::resolve_icon(isset($item[6]) ? $item[6] : ""),
];
}
return $items;
}
+1
View File
@@ -250,6 +250,7 @@ class Shell
{
$items = [
["id" => "aurora-admin", "title" => __("Settings", "aurora-admin"), "icon" => "dashicons-admin-generic"],
["id" => "aurora-admin-modules", "title" => __("Modules", "aurora-admin"), "icon" => "dashicons-screenoptions"],
["id" => "aurora-admin-menu-creator", "title" => __("Menu Creator", "aurora-admin"), "icon" => "dashicons-menu-alt"],
["id" => "aurora-admin-notices", "title" => __("Admin Notices", "aurora-admin"), "icon" => "dashicons-megaphone"],
["id" => "aurora-admin-roles", "title" => __("Role Editor", "aurora-admin"), "icon" => "dashicons-groups"],