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:
co-authored by
Claude Sonnet 5
parent
faf9f35992
commit
24be114f9a
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user