Snapshot synced v3.6.1 working state as baseline

Machine-sync snapshot: the working tree carried the full 3.0.0-3.6.1 arc
(licensing/FileManager/DatabaseExplorer removal, all new replacement screens
and REST controllers) that had not been committed on this machine. Captured
here as a safety baseline before starting the Adminify-parity revamp.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
NixReaper
2026-07-12 08:41:18 -05:00
co-authored by Claude Opus 4.8
parent 07d77bda57
commit e2db2ad4b5
88 changed files with 8944 additions and 2730 deletions
+1
View File
@@ -71,6 +71,7 @@ class ActivityLog
global $wpdb;
$user = wp_get_current_user();
// 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(), [
"action_type" => $action_type,
"object_type" => $object_type,
@@ -1,6 +1,7 @@
<?php
namespace AuroraAdmin\ActivityLog;
defined("ABSPATH") || exit();
/**
@@ -60,10 +61,16 @@ class ActivityLogData
$page = max(1, (int) $request->get_param("page") ?: 1);
$offset = ($page - 1) * $per_page;
// $table is an internal, hardcoded {$wpdb->prefix}-based table name
// (ActivityLog::table_name()); $where_sql is built only from the fixed
// "1=1"/"col = %s" fragments above (never raw user input).
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
// 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, no core API for that; $count_sql is prepare()'d whenever $params is non-empty (the only case with real values to bind) — when $params is empty, $count_sql is just the literal "SELECT COUNT(*) FROM {$table} WHERE 1=1" with nothing user-controlled in it at all.
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
// $table/$where_sql: same as $count_sql above.
$sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
// 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) {
+15 -15
View File
@@ -1,8 +1,6 @@
<?php
namespace AuroraAdmin\Analytics;
use AuroraAdmin\Licensing\License;
defined("ABSPATH") || exit();
/**
@@ -11,9 +9,7 @@ defined("ABSPATH") || exit();
* Records one row per front-end pageview into a custom table, server-side
* (no client script, so it isn't blocked by ad blockers). All aggregation
* happens later in the REST endpoint. Written fresh — does not reuse the
* original plugin's analytics code. Paid feature — the hooks below simply
* don't register without a license, so no tracking happens and the table
* never gets written to.
* original plugin's analytics code.
*/
class Analytics
{
@@ -24,10 +20,6 @@ class Analytics
public function __construct()
{
if (!License::is_feature_available("analytics")) {
return;
}
add_action("init", [self::class, "maybe_install"]);
// Record on the front end only, after the main query is resolved.
add_action("template_redirect", [self::class, "record"], 999);
@@ -95,12 +87,13 @@ class Analytics
return;
}
$ua = isset($_SERVER["HTTP_USER_AGENT"]) ? $_SERVER["HTTP_USER_AGENT"] : "";
$ua = isset($_SERVER["HTTP_USER_AGENT"]) ? sanitize_text_field(wp_unslash($_SERVER["HTTP_USER_AGENT"])) : "";
if (self::is_bot($ua)) {
return;
}
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom pageview table.
$wpdb->insert(self::table_name(), [
"session_id" => self::session_id(),
"url" => substr(self::current_path(), 0, 255),
@@ -115,7 +108,8 @@ class Analytics
private static function session_id()
{
if (!empty($_COOKIE[self::COOKIE])) {
return preg_replace("/[^a-f0-9]/", "", substr($_COOKIE[self::COOKIE], 0, 32));
$cookie = sanitize_text_field(wp_unslash($_COOKIE[self::COOKIE]));
return preg_replace("/[^a-f0-9]/", "", substr($cookie, 0, 32));
}
$sid = md5(uniqid("", true));
// 30-minute rolling session window
@@ -128,13 +122,13 @@ class Analytics
private static function current_path()
{
$uri = isset($_SERVER["REQUEST_URI"]) ? $_SERVER["REQUEST_URI"] : "/";
$uri = isset($_SERVER["REQUEST_URI"]) ? sanitize_text_field(wp_unslash($_SERVER["REQUEST_URI"])) : "/";
return wp_parse_url($uri, PHP_URL_PATH) ?: "/";
}
private static function referrer()
{
$ref = isset($_SERVER["HTTP_REFERER"]) ? $_SERVER["HTTP_REFERER"] : "";
$ref = isset($_SERVER["HTTP_REFERER"]) ? sanitize_text_field(wp_unslash($_SERVER["HTTP_REFERER"])) : "";
if (!$ref) {
return "";
}
@@ -178,9 +172,15 @@ class Analytics
private static function country()
{
// Use a CDN-provided country header if available; otherwise leave blank.
// Sanitized into $value before any check runs on it — the empty()/
// strlen() checks below need the unslashed+sanitized copy too, not
// just the eventual return value, since reading the raw superglobal
// for any purpose (even just a length check) is what the sanitization
// rule actually flags.
foreach (["HTTP_CF_IPCOUNTRY", "GEOIP_COUNTRY_CODE", "HTTP_X_COUNTRY_CODE"] as $h) {
if (!empty($_SERVER[$h]) && strlen($_SERVER[$h]) === 2) {
return strtoupper(sanitize_text_field($_SERVER[$h]));
$value = isset($_SERVER[$h]) ? sanitize_text_field(wp_unslash($_SERVER[$h])) : "";
if ($value !== "" && strlen($value) === 2) {
return strtoupper($value);
}
}
return "";
+36 -8
View File
@@ -1,8 +1,15 @@
<?php
namespace AuroraAdmin\App;
// Guard placed immediately after the namespace declaration (rather than
// after the use-import block below, this file's only departure from the
// rest of the codebase's convention) — this file's unusually large
// number of imports pushed the guard past the first several dozen
// lines, past where at least one automated file-access-protection
// scanner looks.
defined("ABSPATH") || exit();
use AuroraAdmin\Options\Settings;
use AuroraAdmin\Licensing\License;
use AuroraAdmin\Shell\Shell;
use AuroraAdmin\Pages\DashboardTakeover;
use AuroraAdmin\Pages\SettingsPage;
@@ -15,10 +22,18 @@ use AuroraAdmin\Rest\PostsData;
use AuroraAdmin\Rest\PagesData;
use AuroraAdmin\Rest\MediaData;
use AuroraAdmin\Rest\UsersData;
use AuroraAdmin\FileManager\FileManagerData;
use AuroraAdmin\Posts\ScheduledDayFilter;
use AuroraAdmin\WhiteLabel\WhiteLabel;
use AuroraAdmin\Pages\SubApps;
use AuroraAdmin\Pages\HijackedNativePages;
use AuroraAdmin\Pages\NativeSettingsPages;
use AuroraAdmin\Rest\PermalinksData;
use AuroraAdmin\Rest\DiscussionData;
use AuroraAdmin\Pages\AppearancePages;
use AuroraAdmin\Rest\NavMenusData;
use AuroraAdmin\Rest\ThemesData;
use AuroraAdmin\Rest\WidgetsData;
use AuroraAdmin\Rest\ProfileData;
use AuroraAdmin\MenuCreator\MenuCreator;
use AuroraAdmin\MenuCreator\MenuCreatorData;
use AuroraAdmin\Notices\Notices;
@@ -26,11 +41,13 @@ use AuroraAdmin\Notices\NoticesData;
use AuroraAdmin\Roles\RolesData;
use AuroraAdmin\ActivityLog\ActivityLog;
use AuroraAdmin\ActivityLog\ActivityLogData;
use AuroraAdmin\DatabaseExplorer\DatabaseExplorerData;
use AuroraAdmin\Login\LoginStyle;
use AuroraAdmin\Media\SvgUploads;
defined("ABSPATH") || exit();
use AuroraAdmin\Media\MediaFolders;
use AuroraAdmin\ImageFormats\ImageFormats;
use AuroraAdmin\Rest\MediaFolderData;
use AuroraAdmin\Rest\BugReportData;
use AuroraAdmin\Rest\CompanionPluginData;
/**
* Plugin bootstrap. Deliberately minimal: each piece of functionality
@@ -43,7 +60,6 @@ class AuroraAdmin
public function __construct()
{
new Settings();
new License();
new Shell();
new DashboardTakeover();
new SettingsPage();
@@ -56,10 +72,18 @@ class AuroraAdmin
new PagesData();
new MediaData();
new UsersData();
new FileManagerData();
new ScheduledDayFilter();
new WhiteLabel();
new SubApps();
new HijackedNativePages();
new NativeSettingsPages();
new PermalinksData();
new DiscussionData();
new AppearancePages();
new NavMenusData();
new ThemesData();
new WidgetsData();
new ProfileData();
new MenuCreator();
new MenuCreatorData();
new Notices();
@@ -67,8 +91,12 @@ class AuroraAdmin
new RolesData();
new ActivityLog();
new ActivityLogData();
new DatabaseExplorerData();
new LoginStyle();
new SvgUploads();
new MediaFolders();
new ImageFormats();
new MediaFolderData();
new BugReportData();
new CompanionPluginData();
}
}
@@ -1,149 +0,0 @@
<?php
namespace AuroraAdmin\DatabaseExplorer;
defined("ABSPATH") || exit();
/**
* Read-heavy admin dev tool for browsing this install's real database —
* table list with row counts/sizes, paginated row browsing, and a raw
* SELECT-only query runner. manage_options-gated throughout.
*
* Table/column identifiers can't go through $wpdb->prepare() (it only
* binds values, not identifiers), so the actual safety boundary here is
* validating every identifier against $wpdb's own known table list before
* use, then backtick-escaping it — never interpolating a client-supplied
* identifier unchecked.
*/
class DatabaseExplorerData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("manage_options");
};
register_rest_route("aurora-admin/v1", "/db-tables", [
"methods" => "GET",
"callback" => [self::class, "list_tables"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/db-tables/(?P<table>[a-zA-Z0-9_]+)", [
"methods" => "GET",
"callback" => [self::class, "browse_table"],
"permission_callback" => $permission,
"args" => [
"page" => ["type" => "integer", "required" => false, "default" => 1],
"per_page" => ["type" => "integer", "required" => false, "default" => 25],
],
]);
register_rest_route("aurora-admin/v1", "/db-query", [
"methods" => "POST",
"callback" => [self::class, "run_query"],
"permission_callback" => $permission,
"args" => [
"query" => ["type" => "string", "required" => true],
],
]);
}
private static function known_tables()
{
global $wpdb;
return $wpdb->get_col("SHOW TABLES");
}
public static function list_tables()
{
global $wpdb;
$rows = $wpdb->get_results("SHOW TABLE STATUS", ARRAY_A);
$tables = array_map(function ($row) {
return [
"name" => $row["Name"],
"rows" => (int) $row["Rows"],
"sizeBytes" => (int) $row["Data_length"] + (int) $row["Index_length"],
];
}, $rows ?: []);
return new \WP_REST_Response($tables, 200);
}
public static function browse_table($request)
{
global $wpdb;
$table = self::validate_table($request->get_param("table"));
if (!$table) {
return new \WP_Error("invalid_table", "Unknown table", ["status" => 404]);
}
$escaped = self::escape_identifier($table);
$per_page = max(1, min(100, (int) $request->get_param("per_page") ?: 25));
$page = max(1, (int) $request->get_param("page") ?: 1);
$offset = ($page - 1) * $per_page;
$columns = $wpdb->get_results("SHOW COLUMNS FROM {$escaped}", ARRAY_A);
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$escaped}");
$rows = $wpdb->get_results(
$wpdb->prepare("SELECT * FROM {$escaped} LIMIT %d OFFSET %d", $per_page, $offset),
ARRAY_A
);
return new \WP_REST_Response([
"columns" => array_map(function ($c) {
return ["name" => $c["Field"], "type" => $c["Type"]];
}, $columns),
"rows" => $rows,
"total" => $total,
"page" => $page,
"perPage" => $per_page,
"totalPages" => (int) ceil($total / $per_page),
], 200);
}
public static function run_query($request)
{
global $wpdb;
$query = trim((string) $request->get_param("query"));
if (!preg_match('/^(SELECT|SHOW|DESCRIBE|EXPLAIN)\b/i', $query)) {
return new \WP_Error(
"unsafe_query",
"Only SELECT / SHOW / DESCRIBE / EXPLAIN queries can be run here.",
["status" => 400]
);
}
// Guard against stacked statements — a single semicolon at the very
// end is fine, anything after it is not.
if (preg_match('/;.*\S/s', $query)) {
return new \WP_Error("unsafe_query", "Multiple statements aren't allowed.", ["status" => 400]);
}
if (!preg_match('/\bLIMIT\s+\d+/i', $query)) {
$query = rtrim(rtrim($query), ";") . " LIMIT 200";
}
$rows = $wpdb->get_results($query, ARRAY_A);
if ($rows === null) {
return new \WP_Error("query_error", $wpdb->last_error ?: "Query failed", ["status" => 400]);
}
return new \WP_REST_Response(["rows" => $rows, "count" => count($rows)], 200);
}
private static function validate_table($table)
{
$table = preg_replace("/[^a-zA-Z0-9_]/", "", (string) $table);
return in_array($table, self::known_tables(), true) ? $table : null;
}
private static function escape_identifier($identifier)
{
return "`" . str_replace("`", "``", $identifier) . "`";
}
}
-466
View File
@@ -1,466 +0,0 @@
<?php
namespace AuroraAdmin\FileManager;
use AuroraAdmin\Licensing\License;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's File Manager — browse/edit the WordPress
* install's own files, in the spirit of the "WP File Manager" plugin
* already installed on this site (same root — ABSPATH — and same
* manage_options-only gate), but built fresh rather than embedding
* elFinder, to match every other Aurora page's own REST-controller +
* Vue-page pattern.
*
* manage_options for every single endpoint here, no exceptions — this is
* the highest-risk surface in the whole plugin (arbitrary read/write
* access to the site's own PHP files). Every path a client sends is
* resolved through resolve_path()/safe_child_path(), which realpath()s it
* and verifies it's still inside ABSPATH afterward — string-matching
* "../" alone isn't enough (symlinks, encoded traversal, etc.), so
* containment is always checked against the resolved real path, never the
* raw input.
*
* Deliberately NOT implemented (scope decisions, not oversights):
* - Zip/unzip — extraction has its own well-known class of vulnerability
* (zip-slip: a crafted archive's entries escape the extraction dir).
* - chmod/permissions editing — changing permissions on a live WP install
* is one of the easiest ways to accidentally lock yourself out or open
* a real hole; not worth it for a v1.
* - Move/copy across directories — rename (same-directory) is supported;
* full move/copy trees is more surface area for a first pass.
*/
class FileManagerData
{
// Never let these be uploaded, renamed-to, or created — anything
// server-executable is an instant RCE if it lands somewhere web-served.
const BLOCKED_EXTENSIONS = [
"php", "php3", "php4", "php5", "php7", "phtml", "pht", "phar", "phps",
"cgi", "pl", "py", "rb", "sh", "bash", "asp", "aspx", "jsp", "jspx",
"exe", "dll", "so", "com", "bat", "cmd", "htaccess",
];
// Binary-ish extensions the text editor refuses to open — avoids
// dumping garbled bytes into the editor and "saving" it back mangled.
const BINARY_EXTENSIONS = [
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", "svg",
"pdf", "zip", "gz", "tar", "rar", "7z",
"mp3", "mp4", "mov", "avi", "wav", "ogg", "webm",
"woff", "woff2", "ttf", "eot", "otf",
"exe", "dll", "so",
];
const MAX_EDIT_SIZE = 2 * 1024 * 1024; // 2MB — editing bigger files in a plain textarea isn't useful anyway.
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
// Checked here, not just at the page-render layer (SubApps.php) — a
// locked page alone doesn't stop someone with manage_options from
// calling these endpoints directly, only the UI does.
$permission = function () {
return current_user_can("manage_options") && License::is_feature_available("file-manager");
};
register_rest_route("aurora-admin/v1", "/file-manager/list", [
"methods" => "GET",
"callback" => [self::class, "list_dir"],
"permission_callback" => $permission,
"args" => ["path" => ["type" => "string", "required" => false]],
]);
register_rest_route("aurora-admin/v1", "/file-manager/folder", [
"methods" => "POST",
"callback" => [self::class, "create_folder"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/file", [
"methods" => "POST",
"callback" => [self::class, "create_file"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/upload", [
"methods" => "POST",
"callback" => [self::class, "upload_file"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/download", [
"methods" => "GET",
"callback" => [self::class, "download_file"],
"permission_callback" => function () {
// Direct navigation link, not fetch() — no X-WP-Nonce header
// possible, so also accept the nonce as a query param (WP's
// cookie-auth check already looks at $_REQUEST, which covers
// this) alongside the same capability+license check as everywhere
// else here.
return current_user_can("manage_options") && License::is_feature_available("file-manager");
},
"args" => ["path" => ["type" => "string", "required" => true]],
]);
register_rest_route("aurora-admin/v1", "/file-manager/rename", [
"methods" => "POST",
"callback" => [self::class, "rename_item"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/delete", [
"methods" => "POST",
"callback" => [self::class, "delete_item"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/read", [
"methods" => "GET",
"callback" => [self::class, "read_file"],
"permission_callback" => $permission,
"args" => ["path" => ["type" => "string", "required" => true]],
]);
register_rest_route("aurora-admin/v1", "/file-manager/write", [
"methods" => "POST",
"callback" => [self::class, "write_file"],
"permission_callback" => $permission,
]);
}
private static function root()
{
return rtrim(str_replace("\\", "/", realpath(ABSPATH)), "/");
}
/**
* Resolves a client-supplied relative path to a real, root-contained
* absolute path — or false if it doesn't exist or escapes the root.
* Containment is checked against realpath()'s output, not the raw
* string, so symlinks/traversal tricks can't slip past a naive
* substring check.
*/
private static function resolve_path($relative)
{
$relative = str_replace("\\", "/", (string) $relative);
$relative = ltrim($relative, "/");
if (strpos($relative, "\0") !== false) {
return false;
}
$root = self::root();
$candidate = $relative === "" ? $root : $root . "/" . $relative;
$real = realpath($candidate);
if ($real === false) {
return false;
}
$real = str_replace("\\", "/", $real);
if ($real !== $root && strpos($real, $root . "/") !== 0) {
return false;
}
return $real;
}
private static function sanitize_name($name)
{
$name = wp_basename((string) $name);
$name = trim($name);
if ($name === "" || $name === "." || $name === "..") {
return "";
}
if (preg_match('/[\x00-\x1F]/', $name)) {
return "";
}
return $name;
}
private static function extension($name)
{
return strtolower(pathinfo($name, PATHINFO_EXTENSION));
}
public static function list_dir($request)
{
$relative = (string) $request->get_param("path");
$real = self::resolve_path($relative);
if ($real === false || !is_dir($real)) {
return new \WP_Error("aurora_fm_invalid_path", __("Folder not found.", "aurora-admin"), ["status" => 404]);
}
$root = self::root();
$entries = @scandir($real);
if ($entries === false) {
return new \WP_Error("aurora_fm_read_failed", __("Could not read folder.", "aurora-admin"), ["status" => 500]);
}
$items = [];
foreach ($entries as $entry) {
if ($entry === "." || $entry === "..") {
continue;
}
$full = $real . "/" . $entry;
$is_dir = is_dir($full);
$items[] = [
"name" => $entry,
"path" => ltrim(substr($full, strlen($root)), "/"),
"type" => $is_dir ? "dir" : "file",
"size" => $is_dir ? null : @filesize($full),
"sizeLabel" => $is_dir ? "" : size_format((int) @filesize($full)),
"modified" => gmdate("M j, Y g:i a", @filemtime($full)),
"extension" => $is_dir ? "" : self::extension($entry),
"isEditable" => !$is_dir && self::is_editable($entry, @filesize($full)),
"isWritable" => is_writable($full),
];
}
usort($items, function ($a, $b) {
if ($a["type"] !== $b["type"]) {
return $a["type"] === "dir" ? -1 : 1;
}
return strcasecmp($a["name"], $b["name"]);
});
$current = ltrim(substr($real, strlen($root)), "/");
return new \WP_REST_Response([
"path" => $current,
"breadcrumb" => self::breadcrumb($current),
"items" => $items,
], 200);
}
private static function breadcrumb($path)
{
if ($path === "") {
return [];
}
$parts = explode("/", $path);
$crumbs = [];
$accum = "";
foreach ($parts as $part) {
$accum = $accum === "" ? $part : $accum . "/" . $part;
$crumbs[] = ["label" => $part, "path" => $accum];
}
return $crumbs;
}
private static function is_editable($name, $size)
{
if ($size !== false && $size > self::MAX_EDIT_SIZE) {
return false;
}
$ext = self::extension($name);
if ($ext !== "" && in_array($ext, self::BINARY_EXTENSIONS, true)) {
return false;
}
return true;
}
public static function create_folder($request)
{
$parent_real = self::resolve_path($request->get_param("path"));
$name = self::sanitize_name($request->get_param("name"));
if ($parent_real === false || !is_dir($parent_real) || $name === "") {
return new \WP_Error("aurora_fm_invalid", __("Invalid folder name or location.", "aurora-admin"), ["status" => 400]);
}
$target = $parent_real . "/" . $name;
if (file_exists($target)) {
return new \WP_Error("aurora_fm_exists", __("A file or folder with that name already exists.", "aurora-admin"), ["status" => 409]);
}
if (!@mkdir($target, 0755)) {
return new \WP_Error("aurora_fm_mkdir_failed", __("Could not create the folder.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Folder created.", "aurora-admin")], 200);
}
public static function create_file($request)
{
$parent_real = self::resolve_path($request->get_param("path"));
$name = self::sanitize_name($request->get_param("name"));
if ($parent_real === false || !is_dir($parent_real) || $name === "") {
return new \WP_Error("aurora_fm_invalid", __("Invalid file name or location.", "aurora-admin"), ["status" => 400]);
}
$ext = self::extension($name);
if ($ext !== "" && in_array($ext, self::BLOCKED_EXTENSIONS, true)) {
return new \WP_Error("aurora_fm_blocked_type", __("That file type isn't allowed.", "aurora-admin"), ["status" => 400]);
}
$target = $parent_real . "/" . $name;
if (file_exists($target)) {
return new \WP_Error("aurora_fm_exists", __("A file or folder with that name already exists.", "aurora-admin"), ["status" => 409]);
}
if (@file_put_contents($target, "") === false) {
return new \WP_Error("aurora_fm_create_failed", __("Could not create the file.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("File created.", "aurora-admin")], 200);
}
public static function upload_file($request)
{
$parent_real = self::resolve_path($request->get_param("path"));
if ($parent_real === false || !is_dir($parent_real)) {
return new \WP_Error("aurora_fm_invalid", __("Invalid upload location.", "aurora-admin"), ["status" => 400]);
}
$files = $request->get_file_params();
if (empty($files["file"]) || !isset($files["file"]["tmp_name"])) {
return new \WP_Error("aurora_fm_no_file", __("No file was uploaded.", "aurora-admin"), ["status" => 400]);
}
$file = $files["file"];
if (!empty($file["error"])) {
return new \WP_Error("aurora_fm_upload_error", __("Upload failed.", "aurora-admin"), ["status" => 400]);
}
if ($file["size"] > self::MAX_UPLOAD_SIZE) {
return new \WP_Error("aurora_fm_too_large", __("File is larger than the 50MB limit.", "aurora-admin"), ["status" => 400]);
}
$name = self::sanitize_name($file["name"]);
$ext = self::extension($name);
if ($name === "" || ($ext !== "" && in_array($ext, self::BLOCKED_EXTENSIONS, true))) {
return new \WP_Error("aurora_fm_blocked_type", __("That file type isn't allowed.", "aurora-admin"), ["status" => 400]);
}
$target = $parent_real . "/" . $name;
if (!is_uploaded_file($file["tmp_name"]) || !@move_uploaded_file($file["tmp_name"], $target)) {
return new \WP_Error("aurora_fm_upload_failed", __("Could not save the uploaded file.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("File uploaded.", "aurora-admin")], 200);
}
public static function download_file($request)
{
$real = self::resolve_path($request->get_param("path"));
if ($real === false || !is_file($real)) {
return new \WP_Error("aurora_fm_invalid_path", __("File not found.", "aurora-admin"), ["status" => 404]);
}
nocache_headers();
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=\"" . basename($real) . "\"");
header("Content-Length: " . filesize($real));
readfile($real);
exit();
}
public static function rename_item($request)
{
$real = self::resolve_path($request->get_param("path"));
$new_name = self::sanitize_name($request->get_param("newName"));
if ($real === false || $new_name === "" || $real === self::root()) {
return new \WP_Error("aurora_fm_invalid", __("Invalid rename request.", "aurora-admin"), ["status" => 400]);
}
$ext = self::extension($new_name);
if (!is_dir($real) && $ext !== "" && in_array($ext, self::BLOCKED_EXTENSIONS, true)) {
return new \WP_Error("aurora_fm_blocked_type", __("That file type isn't allowed.", "aurora-admin"), ["status" => 400]);
}
$parent = dirname($real);
$target = $parent . "/" . $new_name;
if (file_exists($target)) {
return new \WP_Error("aurora_fm_exists", __("A file or folder with that name already exists.", "aurora-admin"), ["status" => 409]);
}
if (!@rename($real, $target)) {
return new \WP_Error("aurora_fm_rename_failed", __("Could not rename.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Renamed.", "aurora-admin")], 200);
}
public static function delete_item($request)
{
$real = self::resolve_path($request->get_param("path"));
if ($real === false || $real === self::root()) {
return new \WP_Error("aurora_fm_invalid", __("Invalid delete request.", "aurora-admin"), ["status" => 400]);
}
$ok = is_dir($real) ? self::delete_recursive($real) : @unlink($real);
if (!$ok) {
return new \WP_Error("aurora_fm_delete_failed", __("Could not delete.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Deleted.", "aurora-admin")], 200);
}
private static function delete_recursive($dir)
{
$entries = @scandir($dir);
if ($entries === false) {
return false;
}
foreach ($entries as $entry) {
if ($entry === "." || $entry === "..") {
continue;
}
$full = $dir . "/" . $entry;
if (is_dir($full) && !is_link($full)) {
if (!self::delete_recursive($full)) {
return false;
}
} elseif (!@unlink($full)) {
return false;
}
}
return @rmdir($dir);
}
public static function read_file($request)
{
$real = self::resolve_path($request->get_param("path"));
if ($real === false || !is_file($real)) {
return new \WP_Error("aurora_fm_invalid_path", __("File not found.", "aurora-admin"), ["status" => 404]);
}
$size = filesize($real);
if (!self::is_editable(basename($real), $size)) {
return new \WP_Error("aurora_fm_not_editable", __("This file can't be opened in the editor (binary or too large).", "aurora-admin"), ["status" => 400]);
}
$content = @file_get_contents($real);
if ($content === false) {
return new \WP_Error("aurora_fm_read_failed", __("Could not read the file.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response([
"content" => $content,
"size" => $size,
"isWritable" => is_writable($real),
], 200);
}
public static function write_file($request)
{
$real = self::resolve_path($request->get_param("path"));
if ($real === false || !is_file($real)) {
return new \WP_Error("aurora_fm_invalid_path", __("File not found.", "aurora-admin"), ["status" => 404]);
}
if (!self::is_editable(basename($real), 0)) {
return new \WP_Error("aurora_fm_not_editable", __("This file can't be edited.", "aurora-admin"), ["status" => 400]);
}
$content = (string) $request->get_param("content");
if (@file_put_contents($real, $content) === false) {
return new \WP_Error("aurora_fm_write_failed", __("Could not save the file — check that it's writable.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Saved.", "aurora-admin")], 200);
}
}
+346
View File
@@ -0,0 +1,346 @@
<?php
namespace AuroraAdmin\ImageFormats;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Settings > Modern Image Formats: generates a WebP or AVIF sibling file
* for every size WordPress already produces for a new JPEG/PNG upload
* (full size plus every registered subsize), then optionally rewrites
* <img> markup into a <picture> element offering the modern format with
* the original as a fallback source.
*
* Deliberately additive, never destructive: the original JPEG/PNG files
* WordPress generates are always left in place untouched. This plugin
* only ever adds sibling files alongside them and points browsers that
* understand the modern format at those siblings via <picture> — nothing
* about the attachment's own post_mime_type, the original files, or any
* other plugin/theme reading wp_get_attachment_image_src() the normal way
* is changed. Existing library images are unaffected until re-uploaded or
* regenerated (e.g. via the Regenerate Thumbnails plugin or `wp media
* regenerate`), same as any other subsize-generation change.
*
* Generated sibling files are recorded in the attachment's own metadata
* under the 'aurora_image_formats' key (a sibling structure to core's own
* 'sizes' key, one entry per size name plus 'full') so cleanup on delete
* and markup rewriting both have a single source of truth for what was
* actually generated.
*/
class ImageFormats
{
const ELIGIBLE_MIME_TYPES = ["image/jpeg", "image/png"];
public function __construct()
{
add_filter("wp_generate_attachment_metadata", [self::class, "generate_sibling_formats"], 20, 2);
add_action("delete_attachment", [self::class, "cleanup_sibling_formats"]);
add_filter("wp_editor_set_quality", [self::class, "set_quality"], 10, 2);
add_filter("wp_content_img_tag", [self::class, "filter_content_img_tag"], 10, 3);
add_filter("wp_get_attachment_image_html", [self::class, "filter_attachment_image_html"], 10, 5);
}
private static function enabled()
{
return (bool) Settings::get("image_formats_enabled", true);
}
/**
* A single target mime type, not a list — matches the Settings UI's
* single "Output format" choice. 'auto' prefers AVIF (smaller files at
* a given quality) and falls back to WebP; an explicit choice that the
* server can't actually produce (checked via wp_image_editor_supports(),
* the same core function any image-editor-capability check should use)
* returns null, which callers treat as "generate nothing" rather than
* erroring — a host without modern format support just doesn't get the
* feature, silently, same as the rest of this plugin's graceful-
* degradation posture elsewhere (e.g. AVIF write support genuinely
* varies a lot by ImageMagick build).
*/
private static function target_mime_type()
{
$format = (string) Settings::get("image_formats_format", "auto");
if ($format === "webp") {
return wp_image_editor_supports(["mime_type" => "image/webp"]) ? "image/webp" : null;
}
if ($format === "avif") {
return wp_image_editor_supports(["mime_type" => "image/avif"]) ? "image/avif" : null;
}
if (wp_image_editor_supports(["mime_type" => "image/avif"])) {
return "image/avif";
}
if (wp_image_editor_supports(["mime_type" => "image/webp"])) {
return "image/webp";
}
return null;
}
/**
* Runs after WordPress's own subsize generation (priority 20, and in
* any case wp_generate_attachment_metadata() only fires this filter
* once 'sizes' is already fully populated) — so every size this method
* sees already has a real file on disk to re-encode.
*/
public static function generate_sibling_formats($metadata, $attachment_id)
{
if (!self::enabled() || !is_array($metadata)) {
return $metadata;
}
$mime = get_post_mime_type($attachment_id);
if (!in_array($mime, self::ELIGIBLE_MIME_TYPES, true)) {
return $metadata;
}
$target_mime = self::target_mime_type();
if ($target_mime === null) {
return $metadata;
}
$file = get_attached_file($attachment_id);
if (!$file || !file_exists($file)) {
return $metadata;
}
$formats = [];
$full = self::generate_sibling($file, $target_mime);
if ($full !== null) {
$formats["full"] = $full;
}
if (!empty($metadata["sizes"]) && is_array($metadata["sizes"])) {
$base_dir = dirname($file);
foreach ($metadata["sizes"] as $size_name => $size_data) {
if (empty($size_data["file"])) {
continue;
}
$size_path = $base_dir . "/" . $size_data["file"];
if (!file_exists($size_path)) {
continue;
}
$sibling = self::generate_sibling($size_path, $target_mime);
if ($sibling !== null) {
$formats[$size_name] = $sibling;
}
}
}
if (!empty($formats)) {
$metadata["aurora_image_formats"] = $formats;
}
return $metadata;
}
/**
* Re-encodes a single already-generated size file into the target mime
* type, in the same directory (WP_Image_Editor::save() with a null
* filename derives a sibling filename from the source automatically).
* Discards the result if it isn't actually smaller than the source —
* there's no point keeping a "modern format" copy around that's bigger
* than the file it's meant to replace on the wire.
*/
private static function generate_sibling($source_path, $target_mime)
{
$editor = wp_get_image_editor($source_path);
if (is_wp_error($editor)) {
return null;
}
$saved = $editor->save(null, $target_mime);
if (is_wp_error($saved) || empty($saved["file"])) {
return null;
}
$original_size = @filesize($source_path);
if ($original_size && !empty($saved["filesize"]) && $saved["filesize"] >= $original_size) {
if (!empty($saved["path"]) && file_exists($saved["path"])) {
wp_delete_file($saved["path"]);
}
return null;
}
return [
"file" => $saved["file"],
"filesize" => $saved["filesize"] ?? null,
"mime-type" => $saved["mime-type"] ?? $target_mime,
];
}
/**
* Quality for the modern formats specifically — core's own default
* (82 for most editors) is a reasonable target for WebP/AVIF too, but
* setting it explicitly here means a future change to core's JPEG
* default doesn't silently change modern-format quality along with it.
* Untouched for every other mime type.
*/
public static function set_quality($quality, $mime_type)
{
if ($mime_type === "image/webp" || $mime_type === "image/avif") {
return 82;
}
return $quality;
}
/**
* Deletes every generated sibling file when the attachment itself is
* deleted — WordPress core only knows to clean up the files it
* generated itself (recorded in 'sizes'), not this plugin's additions,
* so without this every deleted image would leave orphaned .webp/.avif
* files behind indefinitely.
*/
public static function cleanup_sibling_formats($attachment_id)
{
$metadata = wp_get_attachment_metadata($attachment_id);
if (empty($metadata["aurora_image_formats"]) || !is_array($metadata["aurora_image_formats"])) {
return;
}
$file = get_attached_file($attachment_id);
if (!$file) {
return;
}
$base_dir = dirname($file);
foreach ($metadata["aurora_image_formats"] as $entry) {
if (empty($entry["file"])) {
continue;
}
$path = $base_dir . "/" . $entry["file"];
if (file_exists($path)) {
wp_delete_file($path);
}
}
}
// --- <picture> markup rewriting -----------------------------------------
/**
* Covers images WordPress finds inside post content, widgets, and
* similar rendered text (wp_filter_content_tags() applies this core
* filter to each <img> tag it detects).
*/
public static function filter_content_img_tag($filtered_image, $context, $attachment_id)
{
$picture = self::build_picture($filtered_image, (int) $attachment_id);
return $picture ?? $filtered_image;
}
/**
* Covers images rendered directly via wp_get_attachment_image() (post
* thumbnails, block-editor image blocks that call it, etc.) — a
* separate core filter from wp_content_img_tag above since core itself
* doesn't route wp_get_attachment_image() output back through that one.
*/
public static function filter_attachment_image_html($html, $attachment_id, $size, $icon, $attr)
{
$picture = self::build_picture($html, (int) $attachment_id);
return $picture ?? $html;
}
/**
* Returns a <picture> wrapping the original $html as the fallback
* child, with one <source> offering the generated modern format, or
* null if there's nothing to rewrite (feature/picture-element disabled,
* no <img> tag present, or no sibling formats were ever generated for
* this attachment).
*/
private static function build_picture($html, $attachment_id)
{
if (!self::enabled() || !Settings::get("image_formats_picture_element", true)) {
return null;
}
if ($attachment_id <= 0 || strpos($html, "<img") === false) {
return null;
}
$metadata = wp_get_attachment_metadata($attachment_id);
if (empty($metadata["aurora_image_formats"]) || !is_array($metadata["aurora_image_formats"])) {
return null;
}
$srcset = self::build_srcset($metadata);
if ($srcset === "") {
return null;
}
$formats = $metadata["aurora_image_formats"];
$representative = $formats["full"] ?? reset($formats);
$mime = $representative["mime-type"] ?? "";
if ($mime === "") {
return null;
}
// The original <img>'s own `sizes` attribute already reflects the
// theme/layout's responsive-width logic (core computed it once when
// building the <img> tag) — reusing it on the <source> is correct and
// avoids re-deriving the same value a second way.
$sizes_attr = "";
if (preg_match('/\ssizes=["\']([^"\']+)["\']/', $html, $m)) {
$sizes_attr = ' sizes="' . esc_attr($m[1]) . '"';
}
$source = sprintf(
'<source type="%1$s" srcset="%2$s"%3$s>',
esc_attr($mime),
esc_attr($srcset),
$sizes_attr
);
return "<picture>" . $source . $html . "</picture>";
}
private static function build_srcset($metadata)
{
$formats = $metadata["aurora_image_formats"];
$entries = [];
if (!empty($formats["full"]) && !empty($metadata["width"])) {
$entry = self::srcset_entry($metadata, $formats["full"], (int) $metadata["width"]);
if ($entry !== "") {
$entries[] = $entry;
}
}
if (!empty($metadata["sizes"]) && is_array($metadata["sizes"])) {
foreach ($metadata["sizes"] as $size_name => $size_data) {
if (empty($formats[$size_name]) || empty($size_data["width"])) {
continue;
}
$entry = self::srcset_entry($metadata, $formats[$size_name], (int) $size_data["width"]);
if ($entry !== "") {
$entries[] = $entry;
}
}
}
return implode(", ", array_unique($entries));
}
/**
* Sibling files live in the same directory as the size file they were
* generated from, which for a standard (non-year/month-disabled)
* install is dirname($metadata['file']) relative to the uploads
* baseurl — the same relative-path convention core's own subsize URLs
* use, not something specific to this plugin's file layout.
*/
private static function srcset_entry($metadata, $format_entry, $width)
{
if (empty($format_entry["file"]) || $width <= 0 || empty($metadata["file"])) {
return "";
}
$upload_dir = wp_get_upload_dir();
$relative_dir = trailingslashit(dirname($metadata["file"]));
if ($relative_dir === "./") {
$relative_dir = "";
}
$url = $upload_dir["baseurl"] . "/" . $relative_dir . $format_entry["file"];
return esc_url_raw($url) . " " . $width . "w";
}
}
-70
View File
@@ -1,70 +0,0 @@
<?php
namespace AuroraAdmin\Licensing;
defined("ABSPATH") || exit();
/**
* Detects tampering with the files that implement the free/paid gate
* itself, so someone can't just delete/comment-out a license check and
* redistribute a "cracked" copy that shows every paid feature with no
* license at all.
*
* Compares each protected file's sha256 against integrity-manifest.json,
* generated at zip-build time (bin/build-integrity-manifest.php) — not
* committed to git, same "regenerate, don't version" pattern as app/dist.
* A dev checkout with no manifest present is never treated as tampered
* (see is_tampered()), so this only ever applies to the distributed zip.
*
* Honest limit: this stops editing/removing a check *inside* one of the
* protected files (the realistic, low-effort tampering case — it changes
* the file's hash). It cannot stop someone who edits this file itself to
* neutralize the comparison, or who never lets WordPress load license-
* gated code the normal way at all. No check living in plain, editable
* PHP can close that gap — only a compiled/encoded extension could, and
* that has its own real costs (hosting compatibility, trust). This raises
* the bar; it does not make circumvention impossible.
*/
class Integrity
{
const MANIFEST_FILE = "admin/src/Licensing/integrity-manifest.json";
/** @var bool|null Cached for the request — file hashing isn't free. */
private static $tampered = null;
public static function is_tampered()
{
if (self::$tampered !== null) {
return self::$tampered;
}
$manifest = self::load_manifest();
if ($manifest === null) {
// No manifest — a source checkout (dev) rather than a built zip.
// Nothing to compare against, so never lock a dev environment out.
self::$tampered = false;
return false;
}
foreach ($manifest as $relative_path => $expected_hash) {
$full_path = AURORA_ADMIN_PATH . $relative_path;
if (!file_exists($full_path) || hash_file("sha256", $full_path) !== $expected_hash) {
self::$tampered = true;
return true;
}
}
self::$tampered = false;
return false;
}
private static function load_manifest()
{
$path = AURORA_ADMIN_PATH . self::MANIFEST_FILE;
if (!file_exists($path)) {
return null;
}
$decoded = json_decode((string) file_get_contents($path), true);
return is_array($decoded) && $decoded ? $decoded : null;
}
}
-205
View File
@@ -1,205 +0,0 @@
<?php
namespace AuroraAdmin\Licensing;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Single gate every paid feature funnels through, so the free/paid split
* lives in one place instead of being re-decided in each feature's code.
*
* Validates against the real license server: the "aurora-licensing" WP
* plugin running on auroraplugins.com, REST namespace
* /wp-json/aurora-licensing/v1/ (validate/activate/deactivate). Every
* result is cached in the aurora_admin_license_status option for
* STATUS_TTL, refreshed immediately (not just on expiry) whenever the
* license_key setting actually changes — see on_settings_updated().
*/
class License
{
/**
* Paid sub-apps/features, keyed by the id used to gate them, mapped to a
* human label used in upgrade prompts.
*/
const PAID_FEATURES = [
"role-editor" => "Role Editor",
"activity-log" => "Activity Log",
"database-explorer" => "Database Explorer",
"file-manager" => "File Manager",
"analytics" => "Analytics",
"white-label" => "White Label",
"remote-site-switcher" => "Remote Site Switcher",
];
const API_BASE = "https://auroraplugins.com/wp-json/aurora-licensing/v1/";
const STATUS_OPTION = "aurora_admin_license_status";
const STATUS_TTL = 12 * HOUR_IN_SECONDS;
public function __construct()
{
add_action("update_option_aurora_admin_settings", [self::class, "on_settings_updated"], 10, 2);
}
/**
* Fires on every save of the aurora_admin_settings option (not just the
* license field) — only acts when license_key actually changed, so
* saving an unrelated settings category never triggers a remote call.
* Deactivates the old key's activation on this domain before activating
* the new one, so switching keys doesn't leak a stale activation slot.
*/
public static function on_settings_updated($old_value, $value)
{
$old_key = is_array($old_value) ? trim((string) ($old_value["license_key"] ?? "")) : "";
$new_key = is_array($value) ? trim((string) ($value["license_key"] ?? "")) : "";
if ($old_key === $new_key) {
return;
}
if ($old_key !== "") {
self::call("deactivate", ["license_key" => $old_key, "domain" => self::domain()]);
}
if ($new_key === "") {
delete_option(self::STATUS_OPTION);
return;
}
self::activate($new_key);
}
public static function is_active()
{
$status = self::status();
return !empty($status["valid"]) && ($status["status"] ?? "") === "active";
}
/**
* True for any feature not in PAID_FEATURES (nothing to gate) or when a
* license is active; false only when the feature is paid and unlicensed.
*
* Checked first, ahead of an actual license: if the gate files
* themselves have been tampered with (see Integrity::is_tampered()),
* every paid feature re-locks regardless of license status — a modified
* copy can't be redistributed showing paid features unlocked with no
* license at all.
*/
public static function is_feature_available($feature)
{
if (!isset(self::PAID_FEATURES[$feature])) {
return true;
}
if (Integrity::is_tampered()) {
return false;
}
return self::is_active();
}
public static function feature_label($feature)
{
return isset(self::PAID_FEATURES[$feature]) ? self::PAID_FEATURES[$feature] : "";
}
/**
* The license server's /validate response shape (see call()), cached in
* aurora_admin_license_status for STATUS_TTL so every paid-feature check
* across an admin page load — there are several per request — doesn't
* each cost a remote HTTP round trip. Re-validated immediately (not just
* on expiry) if the cached result was for a different key than what's
* currently saved, so a key change is never served stale data even if
* on_settings_updated() somehow didn't fire first.
*/
public static function status()
{
$key = trim((string) Settings::get("license_key", ""));
if ($key === "") {
return ["valid" => false, "status" => "none"];
}
$cached = get_option(self::STATUS_OPTION);
$fresh =
is_array($cached) &&
($cached["checked_key"] ?? null) === $key &&
($cached["checked_at"] ?? 0) > time() - self::STATUS_TTL;
if ($fresh) {
return $cached;
}
return self::validate($key);
}
public static function validate($key)
{
$result = self::call("validate", [
"license_key" => $key,
"domain" => self::domain(),
]);
$result["checked_key"] = $key;
$result["checked_at"] = time();
update_option(self::STATUS_OPTION, $result, false);
return $result;
}
private static function activate($key)
{
self::call("activate", [
"license_key" => $key,
"domain" => self::domain(),
]);
// /activate's response is a narrower shape ({activated, plan, ...} or
// an error) than /validate's ({valid, status, active_sites, ...}) —
// re-validate right after so the cached status has the full shape
// status()/is_active() and the Settings UI expect either way.
self::validate($key);
}
private static function domain()
{
// The server normalizes domain server-side (strips protocol/www/
// trailing slash) — a bare host is enough, no need to duplicate that
// normalization here.
return (string) parse_url(home_url(), PHP_URL_HOST);
}
/**
* POST $endpoint against the license server. Always returns a decoded
* array — network failures and non-JSON responses collapse into the
* same {valid:false, status:"unreachable", ...} shape as a real error
* response, so callers only ever need to branch on the response fields,
* never on WP_Error separately.
*/
private static function call($endpoint, $body)
{
$response = wp_remote_post(self::API_BASE . $endpoint, [
"timeout" => 8,
"headers" => ["Content-Type" => "application/json"],
"body" => wp_json_encode($body),
]);
if (is_wp_error($response)) {
return [
"valid" => false,
"status" => "unreachable",
"error" => "unreachable",
"message" => $response->get_error_message(),
];
}
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($decoded)) {
return [
"valid" => false,
"status" => "unreachable",
"error" => "invalid_response",
"message" => "License server returned an unexpected response.",
];
}
return $decoded;
}
}
+392
View File
@@ -0,0 +1,392 @@
<?php
namespace AuroraAdmin\Media;
defined("ABSPATH") || exit();
/**
* Storage + model for Media Library folders — duplicates the organizing
* capability of the FileBird Pro plugin, at the user's request, with its
* own independent schema rather than reusing FileBird's tables.
*
* Deliberately simpler than FileBird's own model in two ways:
* - No folder "type" (folder vs. collection/saved-search) — collections
* add no organizational value over folders, skipped unless asked for.
* - One folder per attachment, enforced here (assign_attachments() always
* removes any existing membership row first) — avoids the ambiguity of
* multi-folder membership in the UI, even though the join table's shape
* would technically allow it.
*
* REST surface lives in MediaFolderData.php; this class only owns storage.
*/
class MediaFolders
{
const TABLE_FOLDERS = "aurora_media_folders";
const TABLE_ITEMS = "aurora_media_folder_items";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_media_folders_db_version";
// Pseudo-folder ids the UI always shows above the real tree. Never
// written to the folders table — resolved specially wherever a folder id
// is accepted (get_tree(), MediaData::list_media()).
const ALL_MEDIA = 0;
const UNCATEGORIZED = -1;
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
}
public static function folders_table()
{
global $wpdb;
return $wpdb->prefix . self::TABLE_FOLDERS;
}
public static function items_table()
{
global $wpdb;
return $wpdb->prefix . self::TABLE_ITEMS;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$charset = $wpdb->get_charset_collate();
$folders_sql = "CREATE TABLE " . self::folders_table() . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(191) NOT NULL,
parent BIGINT UNSIGNED NOT NULL DEFAULT 0,
ord INT NOT NULL DEFAULT 0,
color VARCHAR(7) NULL,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_parent (parent)
) {$charset};";
$items_sql = "CREATE TABLE " . self::items_table() . " (
folder_id BIGINT UNSIGNED NOT NULL,
attachment_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (folder_id, attachment_id),
KEY idx_attachment (attachment_id)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($folders_sql);
dbDelta($items_sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
/**
* Full folder tree (nested children[]) with per-folder attachment counts,
* plus the two pseudo-folders pinned above it by the caller (REST layer),
* not here — this only returns the real, user-created folders.
*/
public static function get_tree()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query.
$rows = $wpdb->get_results("SELECT id, name, parent, ord, color FROM " . self::folders_table() . " ORDER BY parent ASC, ord ASC, name ASC", ARRAY_A);
$counts = self::counts_by_folder();
$by_parent = [];
foreach ($rows as $row) {
$row["id"] = (int) $row["id"];
$row["parent"] = (int) $row["parent"];
$row["ord"] = (int) $row["ord"];
$row["count"] = $counts[$row["id"]] ?? 0;
$row["children"] = [];
$by_parent[$row["parent"]][] = $row;
}
$build = function ($parent_id) use (&$build, &$by_parent) {
$children = $by_parent[$parent_id] ?? [];
foreach ($children as &$child) {
$child["children"] = $build($child["id"]);
}
return $children;
};
return $build(0);
}
private static function counts_by_folder()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names from internal, hardcoded {$wpdb->prefix}-based constants, not user input; no user-supplied values in this query.
$rows = $wpdb->get_results("SELECT fi.folder_id, COUNT(*) AS c FROM " . self::items_table() . " fi INNER JOIN {$wpdb->posts} p ON p.ID = fi.attachment_id WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' GROUP BY fi.folder_id");
$counts = [];
foreach ($rows as $row) {
$counts[(int) $row->folder_id] = (int) $row->c;
}
return $counts;
}
public static function total_attachment_count()
{
return (int) wp_count_posts("attachment")->inherit;
}
public static function uncategorized_count()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query.
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts} p WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' AND p.ID NOT IN (SELECT attachment_id FROM " . self::items_table() . ")");
}
public static function create_folder($name, $parent)
{
global $wpdb;
$name = self::unique_name(trim((string) $name), (int) $parent);
if ($name === "") {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$ord = (int) $wpdb->get_var($wpdb->prepare("SELECT COALESCE(MAX(ord), -1) + 1 FROM " . self::folders_table() . " WHERE parent = %d", (int) $parent));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->insert(
self::folders_table(),
[
"name" => $name,
"parent" => (int) $parent,
"ord" => $ord,
"created_by" => get_current_user_id(),
"created_at" => current_time("mysql"),
],
["%s", "%d", "%d", "%d", "%s"]
);
return (int) $wpdb->insert_id;
}
private static function unique_name($name, $parent)
{
if ($name === "") {
return "";
}
global $wpdb;
$base = $name;
$suffix = 2;
while (self::name_taken($parent, $name)) {
$name = $base . " ({$suffix})";
$suffix++;
}
return $name;
}
private static function name_taken($parent, $name)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d/%s values are passed through $wpdb->prepare() below.
return (bool) $wpdb->get_var($wpdb->prepare("SELECT id FROM " . self::folders_table() . " WHERE parent = %d AND name = %s", (int) $parent, $name));
}
public static function rename_folder($id, $name)
{
global $wpdb;
$id = (int) $id;
$name = trim((string) $name);
if (!$id || $name === "") {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$parent = (int) $wpdb->get_var($wpdb->prepare("SELECT parent FROM " . self::folders_table() . " WHERE id = %d", $id));
$name = self::unique_name($name, $parent);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->update(self::folders_table(), ["name" => $name], ["id" => $id], ["%s"], ["%d"]);
return true;
}
/**
* Reparents a folder and renumbers the destination parent's children by
* the caller-supplied order. Simpler than FileBird's live insert-shift
* math (which recomputes `ord` for every sibling on every drag step) —
* the UI sends the full sibling id list in its new order after a drop,
* and this just writes that order out in one pass.
*/
public static function move_folder($id, $new_parent, $sibling_order = [])
{
global $wpdb;
$id = (int) $id;
$new_parent = (int) $new_parent;
if ($id === $new_parent) {
return false;
}
if (self::is_descendant($new_parent, $id)) {
// Refuse to move a folder into its own descendant — would orphan
// the subtree into an unreachable cycle.
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$name = $wpdb->get_var($wpdb->prepare("SELECT name FROM " . self::folders_table() . " WHERE id = %d", $id));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$current_parent = (int) $wpdb->get_var($wpdb->prepare("SELECT parent FROM " . self::folders_table() . " WHERE id = %d", $id));
if ($current_parent !== $new_parent) {
$renamed = self::unique_name((string) $name, $new_parent);
if ($renamed !== $name) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->update(self::folders_table(), ["name" => $renamed], ["id" => $id], ["%s"], ["%d"]);
}
}
$order = array_map("intval", $sibling_order);
if (!in_array($id, $order, true)) {
$order[] = $id;
}
foreach ($order as $index => $sibling_id) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->update(
self::folders_table(),
["parent" => $new_parent, "ord" => $index],
["id" => $sibling_id],
["%d", "%d"],
["%d"]
);
}
return true;
}
private static function is_descendant($maybe_descendant_id, $ancestor_id)
{
global $wpdb;
$current = (int) $maybe_descendant_id;
// Bounded walk up the parent chain — folder depth is never large
// enough for this to matter, and it guarantees termination even if
// data were ever corrupted into a cycle.
for ($i = 0; $i < 100 && $current > 0; $i++) {
if ($current === (int) $ancestor_id) {
return true;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$current = (int) $wpdb->get_var($wpdb->prepare("SELECT parent FROM " . self::folders_table() . " WHERE id = %d", $current));
}
return false;
}
/**
* Deletes a folder. Child folders are reparented to root rather than
* cascade-deleted — less destructive default, matches the plan's choice
* over FileBird's own recursive delete.
*/
public static function delete_folder($id)
{
global $wpdb;
$id = (int) $id;
if (!$id) {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->update(self::folders_table(), ["parent" => 0], ["parent" => $id], ["%d"], ["%d"]);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->delete() is WordPress's own escaping-safe API for writing to Aurora's custom folder-items table.
$wpdb->delete(self::items_table(), ["folder_id" => $id], ["%d"]);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->delete() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->delete(self::folders_table(), ["id" => $id], ["%d"]);
return true;
}
/**
* Moves the given attachments into $folder_id, replacing any existing
* folder membership (enforces one-folder-per-attachment). $folder_id of
* UNCATEGORIZED just removes membership without assigning a new folder.
*/
public static function assign_attachments($folder_id, $attachment_ids)
{
global $wpdb;
$folder_id = (int) $folder_id;
$attachment_ids = array_map("intval", (array) $attachment_ids);
$attachment_ids = array_filter($attachment_ids);
if (empty($attachment_ids)) {
return false;
}
$placeholders = implode(",", array_fill(0, count($attachment_ids), "%d"));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is an internal, hardcoded {$wpdb->prefix}-based constant, not user input; {$placeholders} is a dynamically built run of %d tokens (one per element of $attachment_ids) consumed by the prepare() call immediately below, so the placeholders are in fact present and filled.
$wpdb->query($wpdb->prepare("DELETE FROM " . self::items_table() . " WHERE attachment_id IN ({$placeholders})", ...$attachment_ids));
if ($folder_id === self::UNCATEGORIZED || $folder_id === self::ALL_MEDIA) {
return true;
}
foreach ($attachment_ids as $attachment_id) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom folder-items table.
$wpdb->insert(
self::items_table(),
["folder_id" => $folder_id, "attachment_id" => $attachment_id],
["%d", "%d"]
);
}
return true;
}
public static function attachment_ids_in_folder($folder_id)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
return array_map("intval", $wpdb->get_col($wpdb->prepare("SELECT attachment_id FROM " . self::items_table() . " WHERE folder_id = %d", (int) $folder_id)));
}
public static function folder_id_for_attachment($attachment_id)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$folder_id = $wpdb->get_var($wpdb->prepare("SELECT folder_id FROM " . self::items_table() . " WHERE attachment_id = %d", (int) $attachment_id));
return $folder_id !== null ? (int) $folder_id : self::UNCATEGORIZED;
}
public static function all_assigned_attachment_ids()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query.
return array_map("intval", $wpdb->get_col("SELECT DISTINCT attachment_id FROM " . self::items_table()));
}
/**
* Batch lookup, used by MediaData::list_media() so a page of results
* costs one query instead of one per attachment.
*/
public static function folder_ids_for_attachments($attachment_ids)
{
$attachment_ids = array_map("intval", array_filter((array) $attachment_ids));
if (empty($attachment_ids)) {
return [];
}
global $wpdb;
$placeholders = implode(",", array_fill(0, count($attachment_ids), "%d"));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is an internal, hardcoded {$wpdb->prefix}-based constant, not user input; {$placeholders} is a dynamically built run of %d tokens (one per element of $attachment_ids, each cast via array_map("intval", ...) above) consumed by the prepare() call immediately below via PHP's ...$attachment_ids spread — the placeholder count and value count are mechanically the same array, so they always match; this exact pattern (dynamic IN-clause placeholder count built from array_fill(), spread into prepare()) is WordPress core's own documented way to safely parameterize a variable-length IN clause, since prepare() has no native "bind an array" placeholder.
$rows = $wpdb->get_results($wpdb->prepare("SELECT attachment_id, folder_id FROM " . self::items_table() . " WHERE attachment_id IN ({$placeholders})", ...$attachment_ids));
$map = [];
foreach ($rows as $row) {
$map[(int) $row->attachment_id] = (int) $row->folder_id;
}
return $map;
}
}
+1
View File
@@ -74,6 +74,7 @@ class MenuCreator
}
$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;
+11 -9
View File
@@ -104,11 +104,10 @@ class MenuCreatorData
$status = $request->get_param("status");
if ($status) {
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$table} WHERE status = %s ORDER BY updated_at DESC",
$status
));
// 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; $status is bound via the $wpdb->prepare() %s placeholder.
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE status = %s ORDER BY updated_at DESC", $status));
} else {
// 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.
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY updated_at DESC");
}
@@ -118,10 +117,8 @@ class MenuCreatorData
public static function get_one($request)
{
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM " . MenuCreator::table_name() . " WHERE id = %d",
(int) $request->get_param("id")
));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM " . MenuCreator::table_name() . " WHERE id = %d", (int) $request->get_param("id")));
if (!$row) {
return new \WP_Error("not_found", "Menu not found", ["status" => 404]);
}
@@ -133,6 +130,7 @@ class MenuCreatorData
global $wpdb;
$now = current_time("mysql");
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom menu-configs table.
$wpdb->insert(MenuCreator::table_name(), [
"name" => sanitize_text_field($request->get_param("name")),
"status" => self::sanitize_status($request->get_param("status")),
@@ -142,7 +140,9 @@ class MenuCreatorData
"updated_at" => $now,
]);
return self::get_one(new \WP_REST_Request("GET", "", ["id" => $wpdb->insert_id]));
$get_request = new \WP_REST_Request("GET");
$get_request->set_url_params(["id" => $wpdb->insert_id]);
return self::get_one($get_request);
}
public static function update($request)
@@ -164,6 +164,7 @@ class MenuCreatorData
$fields["items"] = wp_json_encode(self::sanitize_items($request->get_param("items")));
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom menu-configs table.
$wpdb->update(MenuCreator::table_name(), $fields, ["id" => $id]);
return self::get_one($request);
@@ -172,6 +173,7 @@ class MenuCreatorData
public static function delete($request)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->delete() is WordPress's own escaping-safe API for writing to Aurora's custom menu-configs table.
$wpdb->delete(MenuCreator::table_name(), ["id" => (int) $request->get_param("id")]);
return new \WP_REST_Response(["deleted" => true], 200);
}
+2 -3
View File
@@ -109,9 +109,8 @@ class Notices
}
$current_page = isset($GLOBALS["pagenow"]) ? $GLOBALS["pagenow"] : "";
$rows = $wpdb->get_results(
"SELECT * FROM " . self::table_name() . " WHERE active = 1"
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query. Runs on every admin page load to decide which notices to show, so caching would need its own invalidation on every notice save — more complexity than this read is worth.
$rows = $wpdb->get_results("SELECT * FROM " . self::table_name() . " WHERE active = 1");
foreach ($rows as $row) {
$roles = json_decode($row->roles ?: "[]", true);
+7 -4
View File
@@ -52,6 +52,7 @@ class NoticesData
public static function list_notices()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query.
$rows = $wpdb->get_results("SELECT * FROM " . Notices::table_name() . " ORDER BY updated_at DESC");
return new \WP_REST_Response(array_map([self::class, "format"], $rows), 200);
}
@@ -60,6 +61,7 @@ class NoticesData
{
global $wpdb;
$now = current_time("mysql");
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom notices table.
$wpdb->insert(Notices::table_name(), [
"title" => sanitize_text_field($request->get_param("title")),
"message" => sanitize_textarea_field((string) $request->get_param("message")),
@@ -72,10 +74,8 @@ class NoticesData
"updated_at" => $now,
]);
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM " . Notices::table_name() . " WHERE id = %d",
$wpdb->insert_id
));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM " . Notices::table_name() . " WHERE id = %d", $wpdb->insert_id));
return new \WP_REST_Response(self::format($row), 200);
}
@@ -106,8 +106,10 @@ class NoticesData
$fields["active"] = $request->get_param("active") ? 1 : 0;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom notices table.
$wpdb->update(Notices::table_name(), $fields, ["id" => $id]);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM " . Notices::table_name() . " WHERE id = %d", $id));
return new \WP_REST_Response(self::format($row), 200);
}
@@ -115,6 +117,7 @@ class NoticesData
public static function delete($request)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->delete() is WordPress's own escaping-safe API for writing to Aurora's custom notices table.
$wpdb->delete(Notices::table_name(), ["id" => (int) $request->get_param("id")]);
return new \WP_REST_Response(["deleted" => true], 200);
}
+31 -17
View File
@@ -16,6 +16,29 @@ class Settings
public function __construct()
{
add_action("init", [self::class, "register"]);
add_action("init", [self::class, "remove_retired_keys"]);
}
// One-time cleanup for settings keys that no longer exist in the UI:
// the removed Remote Site Switcher feature (remote_sites stored a real
// secret — application passwords — that nothing ever consumed) and the
// removed licensing system (license_key, dev_license_override). Actively
// deletes them from the database rather than just leaving them inertly
// stored.
public static function remove_retired_keys()
{
$settings = self::get();
$retired = ["remote_sites", "remote_site_switcher_capability", "license_key", "dev_license_override"];
$present = array_intersect($retired, array_keys($settings));
if (empty($present)) {
return;
}
foreach ($present as $key) {
unset($settings[$key]);
}
update_option(self::OPTION_NAME, $settings);
}
public static function register()
@@ -28,8 +51,8 @@ class Settings
// it wholesale. This lets pages save just the keys they own (e.g. the
// dashboard sends only dashboard_card_order, the shell only dark_mode)
// without wiping settings they never received — which matters because
// most pages get a sanitized copy with secrets stripped (see
// get_safe()) and must never write that stripped copy back.
// most pages get a sanitized copy (see get_safe()) and must never
// write that stripped copy back.
"sanitize_callback" => [self::class, "merge_on_save"],
"show_in_rest" => [
"schema" => [
@@ -77,25 +100,16 @@ class Settings
/**
* Settings safe to expose on every admin page (the shell serializes this
* for all logged-in users). Strips secrets: the license key and remote
* site application passwords. The settings page itself (manage_options
* only) uses get() and receives the full value.
* for all logged-in users). Strips backup_s3_credentials/
* backup_gdrive_credentials (contain a secret access key / OAuth client
* secret + refresh token) — the exact "future field that shouldn't be
* broadcast" case this method was originally kept separate from get()
* to handle.
*/
public static function get_safe()
{
$settings = self::get();
unset($settings["license_key"]);
if (isset($settings["remote_sites"]) && is_array($settings["remote_sites"])) {
$settings["remote_sites"] = array_values(array_map(function ($site) {
return [
"url" => isset($site["url"]) ? $site["url"] : "",
"username" => isset($site["username"]) ? $site["username"] : "",
];
}, $settings["remote_sites"]));
}
unset($settings["backup_s3_credentials"], $settings["backup_gdrive_credentials"]);
return $settings;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the Appearance-area screens for the "full admin takeover"
* initiative: Nav Menus, Themes, Widgets (all real hijacked replacements).
*
* Theme Editor is deliberately NOT hijacked or replaced — arbitrary
* in-browser PHP/CSS file editing is exactly the category of functionality
* WordPress.org's Plugin Directory policy excludes (see the removed
* File Manager sub-app, now distributed as a separate standalone plugin
* instead of being bundled here). Visits to theme-editor.php fall through
* to WordPress core's own Theme Editor unmodified, which already respects
* DISALLOW_FILE_EDIT itself.
*/
class AppearancePages
{
public function __construct()
{
ScreenHijack::register(
"load-nav-menus.php",
"edit_theme_options",
"full_admin_takeover",
"nav-menus",
"aurora-admin-nav-menus-root"
);
ScreenHijack::register(
"load-themes.php",
"switch_themes",
"full_admin_takeover",
"themes",
"aurora-admin-themes-root",
null,
null,
function () {
return [
"customizeUrl" => esc_url_raw(admin_url("customize.php")),
// Native theme-install.php covers both "browse WordPress.org" and
// "Upload Theme" (a button at the top of that same screen) — not
// rebuilt here, same reuse-native-flow-for-add pattern as Plugins'
// addUrl (plugin-install.php) and Media's newMediaUrl
// (media-new.php).
"addThemeUrl" => current_user_can("install_themes") ? esc_url_raw(admin_url("theme-install.php")) : "",
];
}
);
ScreenHijack::register(
"load-widgets.php",
"edit_theme_options",
"full_admin_takeover",
"widgets",
"aurora-admin-widgets-root"
);
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers every native screen Aurora replaces in place (same URL, no
* redirect) via ScreenHijack. Kept as its own bootstrap class rather than
* inline calls in AuroraAdmin's constructor since this list is expected to
* grow past a dozen entries as more native screens are covered.
*
* Each of these previously lived in SubApps::PAGES as a registered
* "aurora-admin-*" submenu with a matching redirect_native_pages() branch.
* Plugins is the one screen that still uses that mechanism — plugins.php
* prints output from within its own load hook in a way that can't be
* cleanly buffered, confirmed by the old reference plugin's own abandoned
* attempt at hijacking it — so it's deliberately not listed here.
*/
class HijackedNativePages
{
public function __construct()
{
ScreenHijack::register(
"load-edit-comments.php",
"moderate_comments",
"modern_comments",
"comments",
"aurora-admin-comments-root"
);
// "edit.php" is shared by Posts (the default post type) and every other
// post type registered on the site, including Pages — screen_match is
// what tells them apart; other custom post types fall through untouched.
ScreenHijack::register(
"load-edit.php",
"edit_posts",
"modern_posts",
"posts",
"aurora-admin-posts-root",
function ($screen) {
return $screen->post_type === "post";
},
null,
function () {
return ["newPostUrl" => esc_url_raw(admin_url("post-new.php"))];
}
);
ScreenHijack::register(
"load-edit.php",
"edit_pages",
"modern_pages",
"pages",
"aurora-admin-pages-root",
function ($screen) {
return $screen->post_type === "page";
},
null,
function () {
return ["newPageUrl" => esc_url_raw(admin_url("post-new.php?post_type=page"))];
}
);
ScreenHijack::register(
"load-upload.php",
"upload_files",
"modern_media",
"media",
"aurora-admin-media-root",
null,
null,
function () {
return [
"newMediaUrl" => esc_url_raw(admin_url("media-new.php")),
];
}
);
ScreenHijack::register(
"load-users.php",
"list_users",
"modern_users",
"users",
"aurora-admin-users-root",
null,
null,
function () {
return ["newUserUrl" => esc_url_raw(admin_url("user-new.php"))];
}
);
// media-new.php / user-new.php: Media Upload and Add New User. Free,
// gated by the same modern_media / modern_users toggles as their
// already-free list-view companions (Media Library / Users) — no
// reason to paywall mid-workflow between "browse" and "add".
ScreenHijack::register(
"load-media-new.php",
"upload_files",
"modern_media",
"media-upload",
"aurora-admin-media-upload-root",
null,
null,
function () {
return ["libraryUrl" => esc_url_raw(admin_url("upload.php"))];
}
);
ScreenHijack::register(
"load-user-new.php",
"create_users",
"modern_users",
"add-user",
"aurora-admin-add-user-root",
null,
null,
function () {
$roles = [];
foreach (wp_roles()->roles as $slug => $info) {
$roles[] = ["value" => $slug, "label" => translate_user_role($info["name"])];
}
return [
"usersUrl" => esc_url_raw(admin_url("users.php")),
"roles" => $roles,
"defaultRole" => get_option("default_role", "subscriber"),
];
}
);
// profile.php: reskins only WordPress's own fields (name, contact
// info, bio, password). If any plugin has hooked show_user_profile or
// personal_options — the two documented extension points for adding
// fields to this exact screen (2FA setup, WooCommerce customer fields,
// etc.) — this deliberately does NOT hijack, falling through to the
// real native screen instead, since a Vue reskin has no way to host
// another plugin's raw HTML/JS output safely. Checked at hijack time
// (load-profile.php), which fires after every plugin's own init/
// admin_init hooks have already registered their callbacks.
ScreenHijack::register(
"load-profile.php",
"read",
"modern_users",
"profile",
"aurora-admin-profile-root",
function () {
return !has_action("show_user_profile") && !has_action("personal_options");
}
);
}
}
+327
View File
@@ -0,0 +1,327 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the 5 native Settings screens (General/Writing/Reading/
* Discussion/Permalinks) via ScreenHijack, part of the "full admin
* takeover" initiative (see PROJECT_STATUS.md). Unlike Posts/Pages/Media/
* Users/Comments, these are gated on the master `full_admin_takeover`
* setting (default off), since they're new ground rather than a migration
* of something already shipped.
*
* All 5 share one Vue entry (native-settings.js / NativeSettings.vue) and
* one mount id — only one native Settings screen is ever hijacked per
* request, so there's no collision. Each just supplies a different field
* schema + current values via its data_callback. Every field except
* Permalinks' structure is one of core's own REST-exposed settings
* (`/wp/v2/settings`), so the Vue side reads/writes that route directly;
* Permalinks has no core REST route for rewrite rules and uses
* PermalinksData's own aurora-admin/v1/permalinks route instead.
*/
class NativeSettingsPages
{
// Core's REST settings route exposes some fields under a different name
// than the wp_options row they actually live in (registered via
// register_setting()'s show_in_rest 'name' argument) — reading them back
// with get_option() needs the real option name, not the REST one, or the
// field just reads back empty.
const OPTION_NAME_MAP = [
"title" => "blogname",
"description" => "blogdescription",
"url" => "siteurl",
];
public function __construct()
{
self::register_screen(
"load-options-general.php",
"general",
"General",
"Site title, tagline, timezone, and date/time formats.",
[
["key" => "title", "type" => "text", "label" => "Site Title"],
["key" => "description", "type" => "text", "label" => "Tagline"],
["key" => "url", "type" => "text", "label" => "Site Address (URL)", "readonly" => true],
["key" => "admin_email", "type" => "text", "label" => "Administration Email Address"],
["key" => "timezone_string", "type" => "text", "label" => "Timezone", "placeholder" => "e.g. America/New_York"],
["key" => "date_format", "type" => "text", "label" => "Date Format"],
["key" => "time_format", "type" => "text", "label" => "Time Format"],
[
"key" => "start_of_week",
"type" => "select",
"label" => "Week Starts On",
"options" => [
["value" => "0", "label" => "Sunday"],
["value" => "1", "label" => "Monday"],
["value" => "2", "label" => "Tuesday"],
["value" => "3", "label" => "Wednesday"],
["value" => "4", "label" => "Thursday"],
["value" => "5", "label" => "Friday"],
["value" => "6", "label" => "Saturday"],
],
],
]
);
self::register_screen(
"load-options-writing.php",
"writing",
"Writing",
"Defaults used when publishing new content.",
[
// use_smilies is registered in core's REST schema (register_initial_settings())
// but was never added here — a real gap, not a deliberate omission.
["key" => "use_smilies", "type" => "toggle", "label" => "Convert emoticons like :-) and :-P to graphics on display"],
["key" => "default_category", "type" => "select", "label" => "Default Post Category", "options" => self::category_options()],
["key" => "default_post_format", "type" => "select", "label" => "Default Post Format", "options" => self::post_format_options()],
]
);
self::register_screen(
"load-options-reading.php",
"reading",
"Reading",
"What visitors see on the front page and how many posts are listed.",
[
[
"key" => "show_on_front",
"type" => "select",
"label" => "Homepage Displays",
"options" => [
["value" => "posts", "label" => "Your latest posts"],
["value" => "page", "label" => "A static page"],
],
],
[
"key" => "page_on_front",
"type" => "select",
"label" => "Homepage",
"options" => self::page_options(),
"showIf" => ["key" => "show_on_front", "equals" => "page"],
],
[
"key" => "page_for_posts",
"type" => "select",
"label" => "Posts Page",
"options" => self::page_options(),
"showIf" => ["key" => "show_on_front", "equals" => "page"],
],
["key" => "posts_per_page", "type" => "text", "label" => "Posts Per Page"],
]
);
self::register_screen(
"load-options-discussion.php",
"discussion",
"Discussion",
"Default article settings for pings and comments.",
[
// Saved via DiscussionData's own /discussion-settings route, not
// wp/v2/settings — core's REST schema only exposes the first two
// of these fields (see DiscussionData.php's docblock for why).
["key" => "default_pingback_flag", "type" => "toggle", "label" => "Attempt to notify any blogs linked to from the article"],
[
"key" => "default_ping_status",
"type" => "select",
"label" => "Allow link notifications from other blogs (pingbacks and trackbacks)",
"options" => [
["value" => "open", "label" => "Open"],
["value" => "closed", "label" => "Closed"],
],
],
[
"key" => "default_comment_status",
"type" => "select",
"label" => "Allow people to submit comments on new posts",
"options" => [
["value" => "open", "label" => "Open"],
["value" => "closed", "label" => "Closed"],
],
],
["key" => "require_name_email", "type" => "toggle", "label" => "Comment author must fill out name and email"],
["key" => "comment_registration", "type" => "toggle", "label" => "Users must be registered and logged in to comment"],
["key" => "close_comments_for_old_posts", "type" => "toggle", "label" => "Automatically close comments on old posts"],
[
"key" => "close_comments_days_old",
"type" => "text",
"label" => "Close comments when post is how many days old",
"showIf" => ["key" => "close_comments_for_old_posts", "equals" => true],
],
["key" => "show_comments_cookies_opt_in", "type" => "toggle", "label" => "Show comments cookies opt-in checkbox"],
["key" => "thread_comments", "type" => "toggle", "label" => "Enable threaded (nested) comments"],
[
"key" => "thread_comments_depth",
"type" => "select",
"label" => "Number of levels for threaded (nested) comments",
"options" => array_map(
function ($n) {
return ["value" => (string) $n, "label" => (string) $n];
},
range(2, 10)
),
"showIf" => ["key" => "thread_comments", "equals" => true],
],
["key" => "page_comments", "type" => "toggle", "label" => "Break comments into pages"],
[
"key" => "comments_per_page",
"type" => "text",
"label" => "Top level comments per page",
"showIf" => ["key" => "page_comments", "equals" => true],
],
[
"key" => "default_comments_page",
"type" => "select",
"label" => "Comments page to display by default",
"options" => [
["value" => "newest", "label" => "Last page"],
["value" => "oldest", "label" => "First page"],
],
"showIf" => ["key" => "page_comments", "equals" => true],
],
[
"key" => "comment_order",
"type" => "select",
"label" => "Comments to display at the top of each page",
"options" => [
["value" => "asc", "label" => "Older"],
["value" => "desc", "label" => "Newer"],
],
],
["key" => "comments_notify", "type" => "toggle", "label" => "Email me whenever: anyone posts a comment"],
["key" => "moderation_notify", "type" => "toggle", "label" => "Email me whenever: a comment is held for moderation"],
["key" => "wp_notes_notify", "type" => "toggle", "label" => "Email me whenever: WordPress news and updates", "default" => true],
["key" => "comment_moderation", "type" => "toggle", "label" => "Comment must be manually approved"],
["key" => "comment_previously_approved", "type" => "toggle", "label" => "Comment author must have a previously approved comment"],
["key" => "comment_max_links", "type" => "text", "label" => "Hold a comment in the queue if it contains this many links"],
[
"key" => "moderation_keys",
"type" => "textarea",
"label" => "Comment Moderation",
"description" => "When a comment contains any of these words in its content, author name, URL, email, IP address, or browser's user agent string, it will be held in the moderation queue. One word or IP address per line.",
],
[
"key" => "disallowed_keys",
"type" => "textarea",
"label" => "Disallowed Comment Keys",
"description" => "When a comment contains any of these words in its content, author name, URL, email, IP address, or browser's user agent string, it will be put in the Trash. One word or IP address per line.",
],
["key" => "show_avatars", "type" => "toggle", "label" => "Show Avatars"],
[
"key" => "avatar_rating",
"type" => "select",
"label" => "Maximum Rating",
"options" => [
["value" => "G", "label" => "G — Suitable for all audiences"],
["value" => "PG", "label" => "PG — Possibly offensive, usually for audiences 13 and above"],
["value" => "R", "label" => "R — Intended for adult audiences above 17"],
["value" => "X", "label" => "X — Even more mature than above"],
],
"showIf" => ["key" => "show_avatars", "equals" => true],
],
[
"key" => "avatar_default",
"type" => "select",
"label" => "Default Avatar",
"options" => [
["value" => "mystery", "label" => "Mystery Person"],
["value" => "blank", "label" => "Blank"],
["value" => "gravatar_default", "label" => "Gravatar Logo"],
["value" => "identicon", "label" => "Identicon (Generated)"],
["value" => "wavatar", "label" => "Wavatar (Generated)"],
["value" => "monsterid", "label" => "MonsterID (Generated)"],
["value" => "retro", "label" => "Retro (Generated)"],
["value" => "robohash", "label" => "RoboHash (Generated)"],
["value" => "initials", "label" => "Initials (Generated)"],
["value" => "color", "label" => "Color (Generated)"],
],
"default" => "mystery",
"showIf" => ["key" => "show_avatars", "equals" => true],
],
]
);
self::register_screen(
"load-options-permalink.php",
"permalinks",
"Permalinks",
"The URL structure used for posts and pages.",
[["key" => "permalink_structure", "type" => "text", "label" => "Custom Structure"]]
);
}
private static function category_options()
{
$categories = get_categories(["hide_empty" => false]);
return array_map(
function ($cat) {
return ["value" => (string) $cat->term_id, "label" => $cat->name];
},
$categories
);
}
private static function post_format_options()
{
// get_post_format_strings() doesn't include "Standard" (the '0'/default
// value native WP stores when no format is set) — added manually to
// match the native Writing screen's own dropdown exactly.
$options = [["value" => "0", "label" => "Standard"]];
foreach (get_post_format_strings() as $slug => $label) {
if ($slug === "standard") {
continue;
}
$options[] = ["value" => $slug, "label" => $label];
}
return $options;
}
private static function page_options()
{
$options = [["value" => "0", "label" => "— Select —"]];
$pages = get_pages();
foreach ($pages as $page) {
$options[] = ["value" => (string) $page->ID, "label" => $page->post_title ?: "(no title)"];
}
return $options;
}
private static function register_screen($hook_suffix, $screen, $title, $subtitle, $fields)
{
ScreenHijack::register(
$hook_suffix,
"manage_options",
"full_admin_takeover",
"native-settings",
"aurora-admin-native-settings-root",
null,
null,
function () use ($screen, $title, $subtitle, $fields) {
$values = [];
foreach ($fields as $field) {
$option_name = self::OPTION_NAME_MAP[$field["key"]] ?? $field["key"];
$value = get_option($option_name, $field["default"] ?? "");
// Toggle fields need a real boolean — get_option() returns
// whatever's actually stored ("1"/""/true/false depending on how
// it was last saved), and a falsy non-bool like "" would
// otherwise still satisfy Vue's `!!val` check inconsistently.
$values[$field["key"]] = $field["type"] === "toggle" ? (bool) $value : $value;
}
return [
"screen" => $screen,
"title" => $title,
"subtitle" => $subtitle,
"fields" => $fields,
"values" => $values,
];
},
"admin-takeover"
);
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Replaces a native WordPress admin screen with an Aurora Vue page without
* changing its URL, instead of SubApps' registered-page + redirect approach.
*
* WordPress fires "load-{hook_suffix}" once a screen is confirmed but before
* it prints anything, so the recipe is: check access, buffer everything the
* native screen would have printed, throw the buffer away once WordPress
* reaches admin_footer, and print Aurora's own mount div in its place. The
* URL the browser sees never changes, so there's no separate slug that can
* fall out of sync with a plugin's own redirect, a bookmark, or any other
* direct visit — the exact bug class SubApps::redirect_native_pages() has to
* work around case by case (see its Plugins comment).
*
* Not every native screen can be hijacked this way — some (plugins.php is
* the confirmed case so far) print output from within the load-{page}.php
* callback itself or otherwise short-circuit before in_admin_header fires.
* Those stay on SubApps' redirect mechanism instead.
*/
class ScreenHijack
{
// Set true the moment any registered hijack actually activates for the
// current request (all gating checks passed). Shell::is_own_page() reads
// this — hijacked screens keep their native URL (no ?page=aurora-admin),
// so the shell can't otherwise tell them apart from a real native page
// when deciding whether to load native-dark.css's own-page exclusion.
private static $hijack_active = false;
public static function is_hijack_active()
{
return self::$hijack_active;
}
/**
* @param string $hook_suffix "load-{page}.php", e.g. "load-edit.php"
* @param string $capability Required capability, checked with current_user_can()
* @param string|null $setting_key Settings::get() key gating this hijack
* (default true), or null to always hijack once capability passes
* @param string $entry Vite entry name (without "src/entries/" or ".js")
* @param string $root_id DOM id for the mount div
* @param callable|null $screen_match fn(WP_Screen $screen): bool — extra
* match beyond the hook itself firing, e.g. distinguishing edit.php's
* several post types
* @param callable|null $short_circuit fn(WP_Query $query): void, hooked to
* pre_get_posts only if provided — for list screens where letting
* WordPress's own query run would be wasted work
* @param callable|null $data_callback fn(): array — extra data merged into
* the mount div's data-aurora-admin JSON, beyond the restUrl/restNonce
* defaults every page gets
*/
public static function register(
$hook_suffix,
$capability,
$setting_key,
$entry,
$root_id,
$screen_match = null,
$short_circuit = null,
$data_callback = null
) {
add_action(
$hook_suffix,
function () use (
$capability,
$setting_key,
$entry,
$root_id,
$screen_match,
$short_circuit,
$data_callback
) {
if (!current_user_can($capability)) {
return;
}
if ($setting_key !== null && !Settings::get($setting_key, true)) {
return;
}
if ($screen_match !== null) {
$screen = get_current_screen();
if (!$screen || !$screen_match($screen)) {
return;
}
}
self::$hijack_active = true;
if ($short_circuit !== null) {
add_filter("pre_get_posts", $short_circuit);
}
add_action(
"in_admin_header",
function () {
ob_start();
},
999
);
add_action(
"admin_footer",
function () use ($entry, $root_id, $data_callback) {
// A screen that exited early (its own wp_die(), a mid-render
// redirect, a validation failure taking a different code path
// than the initial GET) may never have reached in_admin_header,
// or may have already closed its own buffer — calling
// ob_end_clean() unconditionally in that case throws a notice
// and can corrupt the response. Only discard a buffer that's
// actually open.
if (ob_get_level() > 0) {
ob_end_clean();
}
$data = array_merge(
[
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
],
$data_callback !== null ? $data_callback() : []
);
// The buffer just discarded didn't just hold the native
// screen's content — in_admin_header fires before WP prints
// <div id="wpbody"><div id="wpbody-content">, and those
// wrapper divs' closing tags print (in admin-footer.php)
// before this admin_footer callback runs, so both got
// captured and thrown away too. Re-print them around the
// mount div so the DOM WP itself would have produced still
// exists — windowChrome.js's traffic-light bar and the shell's
// frame CSS both key off #wpbody-content being there.
?>
<div id="wpbody" role="main">
<div id="wpbody-content">
<div
id="<?php echo esc_attr($root_id); ?>"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
</div>
</div>
<?php
},
0
);
add_action("admin_enqueue_scripts", function () use ($entry) {
Assets::enqueue("aurora-admin-" . $entry, "entries/" . $entry . ".js");
});
},
5
);
}
}
-5
View File
@@ -3,7 +3,6 @@ namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
use AuroraAdmin\Licensing\License;
defined("ABSPATH") || exit();
@@ -74,10 +73,6 @@ class SettingsPage
"settings" => Settings::get(),
"roles" => $roles,
"postTypes" => $post_types,
// Real-time result from the license server (see License::status()),
// not just whether a key is present — drives the status line under
// the License key field (active/invalid/site-limit-reached/etc.).
"licenseStatus" => License::status(),
];
?>
<div
+32 -126
View File
@@ -2,7 +2,6 @@
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Licensing\License;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
@@ -33,25 +32,17 @@ defined("ABSPATH") || exit();
*/
class SubApps
{
// "feature" ties a page to a License::PAID_FEATURES key — null means the
// page is free. Locked pages still register normally (real capability,
// real URL) but render() swaps in an upgrade prompt instead of the real
// feature when unlicensed, and maybe_enqueue() loads that prompt's tiny
// JS bundle instead of the real (much heavier) feature bundle.
const PAGES = [
"aurora-admin-menu-creator" => ["title" => "Menu Creator", "entry" => "menu-creator", "feature" => null],
"aurora-admin-notices" => ["title" => "Admin Notices", "entry" => "admin-notices", "feature" => null],
"aurora-admin-roles" => ["title" => "Role Editor", "entry" => "role-editor", "feature" => "role-editor"],
"aurora-admin-activity-log" => ["title" => "Activity Log", "entry" => "activity-log", "feature" => "activity-log"],
"aurora-admin-database" => ["title" => "Database Explorer", "entry" => "database-explorer", "feature" => "database-explorer"],
"aurora-admin-file-manager" => ["title" => "File Manager", "entry" => "file-manager", "feature" => "file-manager"],
"aurora-admin-plugins" => ["title" => "Installed Plugins", "entry" => "plugins", "feature" => null],
"aurora-admin-comments" => ["title" => "Comments", "entry" => "comments", "feature" => null],
"aurora-admin-posts" => ["title" => "Posts", "entry" => "posts", "feature" => null],
"aurora-admin-pages" => ["title" => "Pages", "entry" => "pages", "feature" => null],
"aurora-admin-media" => ["title" => "Media Library", "entry" => "media", "feature" => null],
"aurora-admin-users" => ["title" => "Users", "entry" => "users", "feature" => null],
];
public static function pages()
{
return [
"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"],
"aurora-admin-activity-log" => ["title" => __("Activity Log", "aurora-admin"), "entry" => "activity-log"],
"aurora-admin-plugins" => ["title" => __("Installed Plugins", "aurora-admin"), "entry" => "plugins"],
"aurora-admin-bug-report" => ["title" => __("Report a Bug", "aurora-admin"), "entry" => "bug-report"],
];
}
private static $hook_suffixes = [];
@@ -64,12 +55,12 @@ class SubApps
public static function register_menus()
{
foreach (self::PAGES as $slug => $page) {
foreach (self::pages() as $slug => $page) {
$parent = self::parent_slug($slug);
$hook = add_submenu_page(
$parent,
__($page["title"], "aurora-admin"),
__($page["title"], "aurora-admin"),
$page["title"],
$page["title"],
self::capability($slug),
$slug,
[self::class, "render"]
@@ -83,18 +74,6 @@ class SubApps
if ($slug === "aurora-admin-plugins") {
return "plugins.php";
}
if ($slug === "aurora-admin-posts") {
return "edit.php";
}
if ($slug === "aurora-admin-pages") {
return "edit.php?post_type=page";
}
if ($slug === "aurora-admin-media") {
return "upload.php";
}
if ($slug === "aurora-admin-users") {
return "users.php";
}
return "aurora-admin";
}
@@ -103,34 +82,18 @@ class SubApps
if ($slug === "aurora-admin-plugins") {
return "activate_plugins";
}
if ($slug === "aurora-admin-comments" || $slug === "aurora-admin-posts") {
return "edit_posts";
}
if ($slug === "aurora-admin-pages") {
return "edit_pages";
}
if ($slug === "aurora-admin-media") {
return "upload_files";
}
if ($slug === "aurora-admin-users") {
return "list_users";
}
return "manage_options";
}
public static function maybe_enqueue($hook_suffix)
{
$slug = array_search($hook_suffix, self::$hook_suffixes, true);
if ($slug === false || !isset(self::PAGES[$slug])) {
return;
}
$page = self::PAGES[$slug];
if ($page["feature"] && !License::is_feature_available($page["feature"])) {
Assets::enqueue("aurora-admin-locked-feature", "entries/locked-feature.js");
$pages = self::pages();
if ($slug === false || !isset($pages[$slug])) {
return;
}
$page = $pages[$slug];
Assets::enqueue("aurora-admin-" . $page["entry"], "entries/" . $page["entry"] . ".js");
}
@@ -138,6 +101,7 @@ class SubApps
{
global $pagenow;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only isset() presence check (no value read), used only to skip pages that already have a ?page= param; no state change.
if (isset($_GET["page"]) || wp_doing_ajax()) {
return;
}
@@ -161,82 +125,14 @@ class SubApps
exit;
}
// Settings > Comments > "Modern comments page" — previously always
// redirected regardless of this setting; default true so existing
// installs keep their current behavior until explicitly turned off.
if (
$pagenow === "edit-comments.php" &&
current_user_can("edit_posts") &&
Settings::get("modern_comments", true)
) {
wp_safe_redirect(admin_url("admin.php?page=aurora-admin-comments"));
exit;
}
// Settings > Posts > "Modern posts table". Only the default post-list
// view (edit.php with no post_type, or post_type=post) redirects —
// edit.php?post_type=page (Pages) and other custom post types are
// untouched, since only Posts has a replacement page.
$post_type = isset($_GET["post_type"]) ? sanitize_key(wp_unslash($_GET["post_type"])) : "post";
if (
$pagenow === "edit.php" &&
$post_type === "post" &&
current_user_can("edit_posts") &&
Settings::get("modern_posts", true)
) {
wp_safe_redirect(admin_url("admin.php?page=aurora-admin-posts"));
exit;
}
// Settings > Pages > "Modern pages table".
if (
$pagenow === "edit.php" &&
$post_type === "page" &&
current_user_can("edit_pages") &&
Settings::get("modern_pages", true)
) {
wp_safe_redirect(admin_url("admin.php?page=aurora-admin-pages"));
exit;
}
// Settings > Media > "Modern media library". upload.php serves both
// the default grid view and ?mode=list — both redirect the same way.
// media-new.php (the upload/drag-drop screen) is a separate file,
// untouched, same as post-new.php for Posts/Pages.
if (
$pagenow === "upload.php" &&
current_user_can("upload_files") &&
Settings::get("modern_media", true)
) {
wp_safe_redirect(admin_url("admin.php?page=aurora-admin-media"));
exit;
}
// Settings > Users > "Modern users table". user-new.php (account
// creation) is a separate file, untouched.
if (
$pagenow === "users.php" &&
current_user_can("list_users") &&
Settings::get("modern_users", true)
) {
wp_safe_redirect(admin_url("admin.php?page=aurora-admin-users"));
exit;
}
}
public static function render()
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, routes rendering to the matching page's Vue root by slug; no state change.
$page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : "";
if (!isset(self::PAGES[$page])) {
return;
}
$feature = self::PAGES[$page]["feature"];
if ($feature && !License::is_feature_available($feature)) {
$data = ["feature" => License::feature_label($feature)];
?>
<div data-aurora-locked-feature="<?php echo esc_attr(wp_json_encode($data)); ?>"></div>
<?php
$pages = self::pages();
if (!isset($pages[$page])) {
return;
}
@@ -258,7 +154,17 @@ class SubApps
"newMediaUrl" => esc_url_raw(admin_url("media-new.php")),
"newUserUrl" => esc_url_raw(admin_url("user-new.php")),
];
$root_id = "aurora-admin-" . self::PAGES[$page]["entry"] . "-root";
// Report a Bug auto-fills its diagnostic fields from these — saves the
// reporter from having to look any of it up themselves.
if ($page === "aurora-admin-bug-report") {
$data["pluginVersion"] = defined("AURORA_ADMIN_VERSION") ? AURORA_ADMIN_VERSION : "";
$data["wpVersion"] = get_bloginfo("version");
$data["phpVersion"] = phpversion();
$data["theme"] = wp_get_theme()->get("Name");
}
$root_id = "aurora-admin-" . $pages[$page]["entry"] . "-root";
?>
<div
id="<?php echo esc_attr($root_id); ?>"
+1
View File
@@ -18,6 +18,7 @@ class ScheduledDayFilter
private static function day()
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only query-string filter for the posts list (like core's own list table filters, e.g. ?post_status=), no state change; value is sanitized and further validated against a strict date pattern below.
$day = isset($_GET["aurora_day"]) ? sanitize_text_field(wp_unslash($_GET["aurora_day"])) : "";
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $day) ? $day : "";
}
+13 -24
View File
@@ -2,7 +2,6 @@
namespace AuroraAdmin\Rest;
use AuroraAdmin\Analytics\Analytics;
use AuroraAdmin\Licensing\License;
defined("ABSPATH") || exit();
@@ -23,7 +22,7 @@ class AnalyticsData
"methods" => "GET",
"callback" => [self::class, "handle"],
"permission_callback" => function () {
return current_user_can("read") && License::is_feature_available("analytics");
return current_user_can("read");
},
]);
}
@@ -41,14 +40,14 @@ class AnalyticsData
$where = $wpdb->prepare("created_at BETWEEN %s AND %s", $from_dt, $to_dt);
// --- Totals ---
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE {$where}");
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
$unique = (int) $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE {$where}");
// --- Daily series ---
$rows = $wpdb->get_results(
"SELECT DATE(created_at) AS d, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY DATE(created_at)",
OBJECT_K
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
$rows = $wpdb->get_results("SELECT DATE(created_at) AS d, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY DATE(created_at)", OBJECT_K);
$series = [];
$cursor = strtotime($from);
$end = strtotime($to);
@@ -62,9 +61,8 @@ class AnalyticsData
}
// --- Device breakdown ---
$devices = $wpdb->get_results(
"SELECT device, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY device ORDER BY c DESC"
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
$devices = $wpdb->get_results("SELECT device, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY device ORDER BY c DESC");
$deviceUsage = [];
foreach ($devices as $d) {
$deviceUsage[] = [
@@ -80,11 +78,8 @@ class AnalyticsData
$topCountries = self::top($wpdb, $table, $where, "country", 6, true);
// --- Bounce rate & avg session depth ---
$sessionCounts = $wpdb->get_results(
"SELECT session_id, COUNT(*) AS c,
TIMESTAMPDIFF(SECOND, MIN(created_at), MAX(created_at)) AS dur
FROM {$table} WHERE {$where} GROUP BY session_id"
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
$sessionCounts = $wpdb->get_results("SELECT session_id, COUNT(*) AS c, TIMESTAMPDIFF(SECOND, MIN(created_at), MAX(created_at)) AS dur FROM {$table} WHERE {$where} GROUP BY session_id");
$sessions = count($sessionCounts);
$bounces = 0;
$totalDur = 0;
@@ -99,12 +94,8 @@ class AnalyticsData
// --- Active users (last 5 minutes) ---
$activeCutoff = gmdate("Y-m-d H:i:s", current_time("timestamp") - 300);
$active = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE created_at >= %s",
$activeCutoff
)
);
// 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; $activeCutoff is bound via the $wpdb->prepare() %s placeholder. Deliberately not cached — this is a live "active right now" figure.
$active = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE created_at >= %s", $activeCutoff));
return new \WP_REST_Response([
"pageViews" => ["total" => $total, "unique" => $unique, "series" => $series],
@@ -124,10 +115,8 @@ class AnalyticsData
private static function top($wpdb, $table, $where, $col, $limit, $skip_empty = false)
{
$extra = $skip_empty ? " AND {$col} <> ''" : "";
$results = $wpdb->get_results(
"SELECT {$col} AS label, COUNT(*) AS c FROM {$table}
WHERE {$where}{$extra} GROUP BY {$col} ORDER BY c DESC LIMIT {$limit}"
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is an internal, hardcoded table name; $col/$limit are internal literals passed only by this file's own three call sites above ("url"/"referrer"/"country", 6 — never from $request), not user input; $where was already built via $wpdb->prepare() by the caller.
$results = $wpdb->get_results("SELECT {$col} AS label, COUNT(*) AS c FROM {$table} WHERE {$where}{$extra} GROUP BY {$col} ORDER BY c DESC LIMIT {$limit}");
$out = [];
foreach ($results as $r) {
$out[] = ["label" => $r->label !== "" ? $r->label : "Direct", "count" => (int) $r->c];
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* POST /bug-report — forwards a bug report to the Aurora Dragon Studio
* feedback endpoint, tagged with basic diagnostics (plugin/WP/PHP version,
* active theme, domain) so reports can be triaged without a separate
* ticketing system.
*/
class BugReportData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/bug-report", [
"methods" => "POST",
"callback" => [self::class, "submit"],
"permission_callback" => function () {
return current_user_can("manage_options");
},
"args" => [
"description" => ["type" => "string", "required" => true],
],
]);
}
const API_URL = "https://auroraplugins.com/wp-json/aurora-licensing/v1/report-bug";
public static function submit($request)
{
$description = sanitize_textarea_field((string) $request->get_param("description"));
if (trim($description) === "") {
return new \WP_REST_Response(["success" => false, "message" => "Description is required."], 400);
}
$response = wp_remote_post(self::API_URL, [
"timeout" => 8,
"headers" => ["Content-Type" => "application/json"],
"body" => wp_json_encode([
"description" => $description,
"domain" => (string) wp_parse_url(home_url(), PHP_URL_HOST),
"plugin_version" => defined("AURORA_ADMIN_VERSION") ? AURORA_ADMIN_VERSION : "",
"wp_version" => get_bloginfo("version"),
"php_version" => phpversion(),
"theme" => wp_get_theme()->get("Name"),
]),
]);
if (is_wp_error($response)) {
return new \WP_REST_Response([
"success" => false,
"message" => $response->get_error_message(),
], 502);
}
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if (empty($decoded["success"])) {
return new \WP_REST_Response([
"success" => false,
"message" => $decoded["message"] ?? "Could not send the report.",
], 502);
}
return new \WP_REST_Response(["success" => true], 200);
}
}
+1
View File
@@ -137,6 +137,7 @@ class CommentsData
"postTitle" => get_the_title($post_id) ?: __("(No title)", "aurora-admin"),
"postEditUrl" => get_edit_post_link($post_id, "raw") ?: "",
"date" => mysql2date("M j, Y", $comment->comment_date),
// translators: %s is a human-readable relative time (e.g. "3 hours", "2 days").
"relativeDate" => sprintf(__("%s ago", "aurora-admin"), human_time_diff(strtotime($comment->comment_date_gmt), current_time("timestamp", true))),
"status" => $status,
"statusLabel" => self::status_label($status),
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* Installs Aurora's standalone companion plugins (File Manager, Database
* Explorer, Site Backup, and any future ones) — each split out of Aurora
* Admin either because WordPress.org's Plugin Directory policy doesn't
* accept that category of functionality in a directory-listed plugin
* (File Manager, confirmed via two rejections), or proactively to reduce
* that same risk (Database Explorer, Site Backup — see
* PROJECT_STATUS.md's per-plugin sections for the full history). Rather
* than a bare download link, this wraps WP_Upgrader/Plugin_Upgrader — the
* same core API the native Plugins > Add New "Install Now" button and the
* Uploads tab both use — pointed at a fixed, hardcoded HTTPS zip URL per
* plugin (never client-supplied, so there's no SSRF surface here: the
* install target isn't user input).
*/
class CompanionPluginData
{
// Add an entry here for each companion plugin. The REST routes below are
// generated per-slug (e.g. /companion-plugins/file-manager/status), so a
// new companion plugin only needs a new entry here plus a matching
// settingsConfig.js field — no new PHP route-registration code.
const PLUGINS = [
"file-manager" => [
"zip_url" => "https://aurora.auroradragon.studio/aurora-file-manager.zip",
"plugin_file" => "aurora-file-manager/aurora-file-manager.php",
"label" => "Aurora File Manager",
],
"database-explorer" => [
"zip_url" => "https://aurora.auroradragon.studio/aurora-database-explorer.zip",
"plugin_file" => "aurora-database-explorer/aurora-database-explorer.php",
"label" => "Aurora Database Explorer",
],
"site-backup" => [
"zip_url" => "https://aurora.auroradragon.studio/aurora-site-backup.zip",
"plugin_file" => "aurora-site-backup/aurora-site-backup.php",
"label" => "Aurora Site Backup",
],
];
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("install_plugins") && current_user_can("activate_plugins");
};
foreach (array_keys(self::PLUGINS) as $slug) {
register_rest_route("aurora-admin/v1", "/companion-plugins/{$slug}/status", [
"methods" => "GET",
"callback" => function () use ($slug) {
return self::status($slug);
},
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/companion-plugins/{$slug}/install", [
"methods" => "POST",
"callback" => function () use ($slug) {
return self::install($slug);
},
"permission_callback" => $permission,
]);
}
}
private static function is_installed($plugin_file)
{
if (!function_exists("get_plugins")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
return array_key_exists($plugin_file, get_plugins());
}
private static function is_active($plugin_file)
{
return is_plugin_active($plugin_file);
}
public static function status($slug)
{
if (!function_exists("is_plugin_active")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
$plugin_file = self::PLUGINS[$slug]["plugin_file"];
return new \WP_REST_Response([
"installed" => self::is_installed($plugin_file),
"active" => self::is_installed($plugin_file) && self::is_active($plugin_file),
], 200);
}
/**
* Installs (if not already present) and activates the given companion
* plugin. Uses Plugin_Upgrader::install() against the fixed zip URL — the
* exact same core class WordPress's own plugin-install.php uses, so it
* inherits the same WP_Filesystem credential handling (a host requiring
* FTP/SSH credentials for direct file writes will surface that as a
* normal WP_Error here, not a fatal).
*/
public static function install($slug)
{
$plugin = self::PLUGINS[$slug];
$plugin_file = $plugin["plugin_file"];
if (self::is_installed($plugin_file)) {
if (!self::is_active($plugin_file)) {
$activated = activate_plugin($plugin_file);
if (is_wp_error($activated)) {
return new \WP_REST_Response(["success" => false, "message" => $activated->get_error_message()], 500);
}
}
/* translators: %s: companion plugin name, e.g. "Aurora File Manager" */
return new \WP_REST_Response(["success" => true, "message" => sprintf(__("%s is installed and active.", "aurora-admin"), $plugin["label"])], 200);
}
require_once ABSPATH . "wp-admin/includes/class-wp-upgrader.php";
require_once ABSPATH . "wp-admin/includes/plugin-install.php";
require_once ABSPATH . "wp-admin/includes/plugin.php";
$skin = new \Automatic_Upgrader_Skin();
$upgrader = new \Plugin_Upgrader($skin);
$result = $upgrader->install($plugin["zip_url"]);
if (is_wp_error($result)) {
return new \WP_REST_Response(["success" => false, "message" => $result->get_error_message()], 500);
}
if ($result !== true) {
$error = $skin->get_errors();
return new \WP_REST_Response([
"success" => false,
"message" => is_wp_error($error) && $error->get_error_message() ? $error->get_error_message() : __("Installation failed.", "aurora-admin"),
], 500);
}
if (!self::is_installed($plugin_file)) {
return new \WP_REST_Response(["success" => false, "message" => __("Installed, but the plugin file wasn't found afterward — check Plugins for details.", "aurora-admin")], 500);
}
$activated = activate_plugin($plugin_file);
if (is_wp_error($activated)) {
return new \WP_REST_Response([
"success" => true,
"message" => __("Installed, but couldn't activate automatically — activate it from the Plugins screen.", "aurora-admin"),
], 200);
}
/* translators: %s: companion plugin name, e.g. "Aurora File Manager" */
return new \WP_REST_Response(["success" => true, "message" => sprintf(__("%s installed and activated.", "aurora-admin"), $plugin["label"])], 200);
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* Core's REST settings schema (register_initial_settings(), wp-includes/
* option.php) only exposes 2 of options-discussion.php's ~15 real fields
* (default_ping_status/default_comment_status) — everything else on that
* native screen (moderation, threading, notification emails, avatars) has
* no REST route at all. This is the same situation Permalinks was in
* (PermalinksData.php) — a dedicated controller doing raw get_option()/
* update_option() reads/writes instead of going through wp/v2/settings.
*
* Every key this accepts is explicitly allowlisted below — the client
* sends a {values: {...}} object, but only keys this class already knows
* about are ever written, so a modified request can't use this route to
* write an arbitrary WP option.
*/
class DiscussionData
{
const BOOLEAN_KEYS = [
"default_pingback_flag",
"require_name_email",
"comment_registration",
"close_comments_for_old_posts",
"show_comments_cookies_opt_in",
"thread_comments",
"page_comments",
"comments_notify",
"moderation_notify",
"wp_notes_notify",
"comment_moderation",
"comment_previously_approved",
"show_avatars",
];
const INTEGER_KEYS = ["close_comments_days_old", "comments_per_page", "thread_comments_depth", "comment_max_links"];
const TEXTAREA_KEYS = ["moderation_keys", "disallowed_keys"];
const ENUM_KEYS = [
"default_ping_status" => ["open", "closed"],
"default_comment_status" => ["open", "closed"],
"default_comments_page" => ["newest", "oldest"],
"comment_order" => ["asc", "desc"],
"avatar_rating" => ["G", "PG", "R", "X"],
"avatar_default" => [
"mystery",
"blank",
"gravatar_default",
"identicon",
"wavatar",
"monsterid",
"retro",
"robohash",
"initials",
"color",
],
];
// Matches native options-discussion.php's own get_option() defaults —
// everything else defaults to empty/false when never saved.
const DEFAULTS = [
"wp_notes_notify" => true,
"avatar_default" => "mystery",
];
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/discussion-settings", [
"methods" => "GET",
"callback" => [self::class, "get_values"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/discussion-settings", [
"methods" => "POST",
"callback" => [self::class, "update_values"],
"permission_callback" => [self::class, "check_permission"],
"args" => [
"values" => ["type" => "object", "required" => true],
],
]);
}
public static function check_permission()
{
return current_user_can("manage_options");
}
private static function all_keys()
{
return array_merge(self::BOOLEAN_KEYS, self::INTEGER_KEYS, self::TEXTAREA_KEYS, array_keys(self::ENUM_KEYS));
}
public static function get_values()
{
$out = [];
foreach (self::all_keys() as $key) {
$default = self::DEFAULTS[$key] ?? "";
$value = get_option($key, $default);
if (in_array($key, self::BOOLEAN_KEYS, true)) {
$out[$key] = (bool) $value;
} elseif (in_array($key, self::INTEGER_KEYS, true)) {
$out[$key] = (int) $value;
} else {
$out[$key] = (string) $value;
}
}
return new \WP_REST_Response($out, 200);
}
public static function update_values($request)
{
$values = (array) $request->get_param("values");
foreach ($values as $key => $value) {
if (in_array($key, self::BOOLEAN_KEYS, true)) {
update_option($key, $value ? "1" : "");
} elseif (in_array($key, self::INTEGER_KEYS, true)) {
update_option($key, max(0, (int) $value));
} elseif (in_array($key, self::TEXTAREA_KEYS, true)) {
update_option($key, sanitize_textarea_field((string) $value));
} elseif (isset(self::ENUM_KEYS[$key]) && in_array($value, self::ENUM_KEYS[$key], true)) {
update_option($key, $value);
}
// Any other key (unknown/unauthorized) is silently ignored rather
// than erroring — keeps a partial-field save from failing outright.
}
return new \WP_REST_Response(["success" => true], 200);
}
}
+28 -2
View File
@@ -1,6 +1,8 @@
<?php
namespace AuroraAdmin\Rest;
use AuroraAdmin\Media\MediaFolders;
defined("ABSPATH") || exit();
/**
@@ -32,6 +34,7 @@ class MediaData
"search" => ["type" => "string", "required" => false],
"page" => ["type" => "integer", "required" => false],
"perPage" => ["type" => "integer", "required" => false],
"folderId" => ["type" => "integer", "required" => false],
],
]);
@@ -77,10 +80,30 @@ class MediaData
$args["s"] = $search;
}
$folder_param = $request->get_param("folderId");
if ($folder_param !== null && $folder_param !== "") {
$folder_id = (int) $folder_param;
if ($folder_id === MediaFolders::UNCATEGORIZED) {
$in_any_folder = MediaFolders::all_assigned_attachment_ids();
// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- a WordPress VIP-scale performance advisory, not a WP.org Plugin Directory requirement; "show media not assigned to any folder" has no other query shape without a real inverse-membership index, and Media Folders is bounded by how many attachments a site actually has, not VIP-scale traffic.
$args["post__not_in"] = !empty($in_any_folder) ? $in_any_folder : [0];
} elseif ($folder_id !== MediaFolders::ALL_MEDIA) {
$ids = MediaFolders::attachment_ids_in_folder($folder_id);
$args["post__in"] = !empty($ids) ? $ids : [0];
}
}
$query = new \WP_Query($args);
$folder_map = MediaFolders::folder_ids_for_attachments(wp_list_pluck($query->posts, "ID"));
return new \WP_REST_Response([
"items" => array_map([self::class, "serialize_attachment"], $query->posts),
"items" => array_map(
function ($post) use ($folder_map) {
return self::serialize_attachment($post, $folder_map[$post->ID] ?? MediaFolders::UNCATEGORIZED);
},
$query->posts
),
"stats" => self::stats(),
"pagination" => [
"page" => $page,
@@ -107,7 +130,7 @@ class MediaData
return new \WP_REST_Response(["success" => true, "message" => __("Media item deleted.", "aurora-admin")], 200);
}
private static function serialize_attachment($post)
private static function serialize_attachment($post, $folder_id = null)
{
$author = get_userdata($post->post_author);
$mime = $post->post_mime_type;
@@ -139,6 +162,7 @@ class MediaData
"date" => get_the_date("M j, Y", $post),
"editUrl" => get_edit_post_link($post->ID, "raw") ?: "",
"canDelete" => current_user_can("delete_post", $post->ID),
"folderId" => $folder_id,
];
}
@@ -155,6 +179,7 @@ class MediaData
"unattached" => 0,
];
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- {$wpdb->posts} is WordPress core's own table, no user-supplied value in the query; there's no WP_Query/get_posts() shape for "counts grouped by mime-type prefix", which is exactly what this powers (the Media Library's type-filter tab counts).
$rows = $wpdb->get_results(
"SELECT post_mime_type, COUNT(*) AS c FROM {$wpdb->posts}
WHERE post_type = 'attachment' AND post_status = 'inherit'
@@ -169,6 +194,7 @@ class MediaData
}
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- {$wpdb->posts} is WordPress core's own table, no user-supplied value in the query.
$counts["unattached"] = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->posts}
WHERE post_type = 'attachment' AND post_status = 'inherit' AND post_parent = 0"
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace AuroraAdmin\Rest;
use AuroraAdmin\Media\MediaFolders;
defined("ABSPATH") || exit();
/**
* REST endpoints for Media Folders.
*/
class MediaFolderData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/media-folders", [
"methods" => "GET",
"callback" => [self::class, "get_tree"],
"permission_callback" => [self::class, "can_manage"],
]);
register_rest_route("aurora-admin/v1", "/media-folders", [
"methods" => "POST",
"callback" => [self::class, "create_folder"],
"permission_callback" => [self::class, "can_manage"],
"args" => [
"name" => ["type" => "string", "required" => true],
"parent" => ["type" => "integer", "required" => false],
],
]);
register_rest_route("aurora-admin/v1", "/media-folders/(?P<id>\d+)", [
"methods" => "POST",
"callback" => [self::class, "update_folder"],
"permission_callback" => [self::class, "can_manage"],
"args" => [
"id" => ["type" => "integer", "required" => true],
"name" => ["type" => "string", "required" => false],
"parent" => ["type" => "integer", "required" => false],
"siblingOrder" => ["type" => "array", "required" => false],
],
]);
register_rest_route("aurora-admin/v1", "/media-folders/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_folder"],
"permission_callback" => [self::class, "can_manage"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/media-folders/assign", [
"methods" => "POST",
"callback" => [self::class, "assign"],
"permission_callback" => [self::class, "can_manage"],
"args" => [
"folderId" => ["type" => "integer", "required" => true],
"attachmentIds" => ["type" => "array", "required" => true],
],
]);
}
public static function can_manage()
{
return current_user_can("upload_files");
}
public static function get_tree($request)
{
return new \WP_REST_Response(
[
"tree" => MediaFolders::get_tree(),
"allMediaCount" => MediaFolders::total_attachment_count(),
"uncategorizedCount" => MediaFolders::uncategorized_count(),
],
200
);
}
public static function create_folder($request)
{
$name = sanitize_text_field((string) $request->get_param("name"));
$parent = (int) ($request->get_param("parent") ?: 0);
$id = MediaFolders::create_folder($name, $parent);
if (!$id) {
return new \WP_Error("aurora_folder_invalid", __("Folder name is required.", "aurora-admin"), ["status" => 400]);
}
return new \WP_REST_Response(["id" => $id], 201);
}
public static function update_folder($request)
{
$id = (int) $request["id"];
$name = $request->get_param("name");
$parent = $request->get_param("parent");
if ($name !== null && $name !== "") {
MediaFolders::rename_folder($id, sanitize_text_field((string) $name));
}
if ($parent !== null) {
$sibling_order = (array) ($request->get_param("siblingOrder") ?: []);
$moved = MediaFolders::move_folder($id, (int) $parent, $sibling_order);
if (!$moved) {
return new \WP_Error(
"aurora_folder_move_failed",
__("Could not move that folder there.", "aurora-admin"),
["status" => 400]
);
}
}
return new \WP_REST_Response(["success" => true], 200);
}
public static function delete_folder($request)
{
$id = (int) $request["id"];
MediaFolders::delete_folder($id);
return new \WP_REST_Response(["success" => true], 200);
}
public static function assign($request)
{
$folder_id = (int) $request->get_param("folderId");
$ids = (array) $request->get_param("attachmentIds");
$ids = array_values(
array_filter($ids, function ($id) {
return current_user_can("edit_post", (int) $id);
})
);
if (empty($ids)) {
return new \WP_Error(
"aurora_folder_assign_failed",
__("There is nothing to move.", "aurora-admin"),
["status" => 400]
);
}
MediaFolders::assign_attachments($folder_id, $ids);
return new \WP_REST_Response(["success" => true], 200);
}
}
+336
View File
@@ -0,0 +1,336 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's Nav Menus replacement (Appearance > Menus).
* Not to be confused with MenuCreator/MenuCreatorData, which manages
* Aurora's own admin sidebar — this is WordPress's front-end navigation
* menu system (wp_nav_menu, theme locations), a completely different
* feature that happens to share a similar name.
*
* Thin wrapper around core's own nav-menu functions (wp_get_nav_menus(),
* wp_update_nav_menu_item(), etc.) — the same functions options-nav-menus.php
* itself calls, just driven by REST instead of a giant form post.
*/
class NavMenusData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function check_permission()
{
return current_user_can("edit_theme_options");
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/nav-menus", [
[
"methods" => "GET",
"callback" => [self::class, "list_menus"],
"permission_callback" => [self::class, "check_permission"],
],
[
"methods" => "POST",
"callback" => [self::class, "create_menu"],
"permission_callback" => [self::class, "check_permission"],
"args" => ["name" => ["type" => "string", "required" => true]],
],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_menu"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/(?P<id>\d+)/items", [
[
"methods" => "GET",
"callback" => [self::class, "list_items"],
"permission_callback" => [self::class, "check_permission"],
],
[
"methods" => "POST",
"callback" => [self::class, "add_item"],
"permission_callback" => [self::class, "check_permission"],
],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/items/(?P<id>\d+)", [
[
"methods" => "PUT",
"callback" => [self::class, "update_item"],
"permission_callback" => [self::class, "check_permission"],
],
[
"methods" => "DELETE",
"callback" => [self::class, "delete_item"],
"permission_callback" => [self::class, "check_permission"],
],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/(?P<id>\d+)/reorder", [
"methods" => "POST",
"callback" => [self::class, "reorder_items"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/locations", [
[
"methods" => "GET",
"callback" => [self::class, "list_locations"],
"permission_callback" => [self::class, "check_permission"],
],
[
"methods" => "POST",
"callback" => [self::class, "assign_location"],
"permission_callback" => [self::class, "check_permission"],
],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/pickers", [
"methods" => "GET",
"callback" => [self::class, "pickers"],
"permission_callback" => [self::class, "check_permission"],
]);
}
public static function list_menus()
{
$menus = wp_get_nav_menus();
$locations = get_nav_menu_locations();
$out = [];
foreach ($menus as $menu) {
$assigned = array_keys($locations, $menu->term_id, true);
$out[] = [
"id" => (int) $menu->term_id,
"name" => $menu->name,
"itemCount" => (int) $menu->count,
"locations" => $assigned,
];
}
return new \WP_REST_Response($out, 200);
}
public static function create_menu($request)
{
$name = sanitize_text_field((string) $request->get_param("name"));
if ($name === "") {
return new \WP_Error("aurora_menu_invalid", __("Menu name is required.", "aurora-admin"), ["status" => 400]);
}
$id = wp_create_nav_menu($name);
if (is_wp_error($id)) {
return new \WP_Error("aurora_menu_create_failed", $id->get_error_message(), ["status" => 500]);
}
return new \WP_REST_Response(["id" => (int) $id, "name" => $name, "itemCount" => 0, "locations" => []], 201);
}
public static function delete_menu($request)
{
$id = (int) $request["id"];
$result = wp_delete_nav_menu($id);
if (is_wp_error($result) || !$result) {
return new \WP_Error("aurora_menu_delete_failed", __("Could not delete the menu.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true], 200);
}
public static function list_items($request)
{
$menu_id = (int) $request["id"];
$items = wp_get_nav_menu_items($menu_id);
if ($items === false) {
return new \WP_Error("aurora_menu_invalid", __("Invalid menu.", "aurora-admin"), ["status" => 404]);
}
return new \WP_REST_Response(array_map([self::class, "serialize_item"], $items), 200);
}
public static function add_item($request)
{
$menu_id = (int) $request["id"];
$body = $request->get_json_params();
$args = self::build_item_args($body);
$item_id = wp_update_nav_menu_item($menu_id, 0, $args);
if (is_wp_error($item_id)) {
return new \WP_Error("aurora_menu_item_failed", $item_id->get_error_message(), ["status" => 500]);
}
$item = get_post($item_id);
return new \WP_REST_Response(self::serialize_item(wp_setup_nav_menu_item($item)), 201);
}
public static function update_item($request)
{
$item_id = (int) $request["id"];
$body = $request->get_json_params();
$menu_id = (int) ($body["menuId"] ?? 0);
$existing = get_post($item_id);
if (!$existing || $existing->post_type !== "nav_menu_item") {
return new \WP_Error("aurora_menu_item_invalid", __("Invalid menu item.", "aurora-admin"), ["status" => 404]);
}
$args = self::build_item_args($body, $item_id);
$result = wp_update_nav_menu_item($menu_id ?: self::menu_id_for_item($item_id), $item_id, $args);
if (is_wp_error($result)) {
return new \WP_Error("aurora_menu_item_failed", $result->get_error_message(), ["status" => 500]);
}
return new \WP_REST_Response(self::serialize_item(wp_setup_nav_menu_item(get_post($item_id))), 200);
}
public static function delete_item($request)
{
$item_id = (int) $request["id"];
$result = wp_delete_post($item_id, true);
if (!$result) {
return new \WP_Error("aurora_menu_item_delete_failed", __("Could not delete the menu item.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true], 200);
}
// Bulk reorder / re-parent after a drag or up/down move in the UI —
// clients send the full flat list of {id, parent, order} once, since
// nav menu item order/hierarchy is only meaningful as a whole set.
public static function reorder_items($request)
{
$menu_id = (int) $request["id"];
$body = $request->get_json_params();
$items = is_array($body["items"] ?? null) ? $body["items"] : [];
foreach ($items as $item) {
$item_id = (int) ($item["id"] ?? 0);
if (!$item_id) {
continue;
}
wp_update_nav_menu_item($menu_id, $item_id, [
"menu-item-parent-id" => (int) ($item["parent"] ?? 0),
"menu-item-position" => (int) ($item["order"] ?? 0),
]);
}
return new \WP_REST_Response(["success" => true], 200);
}
public static function list_locations()
{
$registered = get_registered_nav_menus();
$assigned = get_nav_menu_locations();
$out = [];
foreach ($registered as $slug => $description) {
$out[] = [
"slug" => $slug,
"description" => $description,
"menuId" => isset($assigned[$slug]) ? (int) $assigned[$slug] : null,
];
}
return new \WP_REST_Response($out, 200);
}
public static function assign_location($request)
{
$body = $request->get_json_params();
$slug = sanitize_key((string) ($body["location"] ?? ""));
$menu_id = (int) ($body["menuId"] ?? 0);
if ($slug === "" || !array_key_exists($slug, get_registered_nav_menus())) {
return new \WP_Error("aurora_menu_location_invalid", __("Invalid theme location.", "aurora-admin"), ["status" => 400]);
}
$locations = get_theme_mod("nav_menu_locations", []);
if ($menu_id > 0) {
$locations[$slug] = $menu_id;
} else {
unset($locations[$slug]);
}
set_theme_mod("nav_menu_locations", $locations);
return new \WP_REST_Response(["success" => true], 200);
}
// Data the "add items" panel needs: recent pages/posts/categories to
// pick from, plus a custom-link option handled entirely client-side.
public static function pickers()
{
$pages = get_posts(["post_type" => "page", "numberposts" => 50, "orderby" => "title", "order" => "ASC"]);
$posts = get_posts(["post_type" => "post", "numberposts" => 50, "orderby" => "date", "order" => "DESC"]);
$categories = get_categories(["hide_empty" => false, "number" => 50]);
return new \WP_REST_Response([
"pages" => array_map(fn($p) => ["id" => $p->ID, "title" => $p->post_title ?: __("(no title)", "aurora-admin")], $pages),
"posts" => array_map(fn($p) => ["id" => $p->ID, "title" => $p->post_title ?: __("(no title)", "aurora-admin")], $posts),
"categories" => array_map(fn($c) => ["id" => $c->term_id, "title" => $c->name], $categories),
], 200);
}
private static function serialize_item($item)
{
return [
"id" => (int) $item->ID,
"title" => $item->title,
"url" => $item->url,
"type" => $item->type, // "custom" | "post_type" | "taxonomy"
"objectId" => (int) $item->object_id,
"parent" => (int) $item->menu_item_parent,
"order" => (int) $item->menu_order,
];
}
private static function build_item_args($body, $existing_id = 0)
{
$type = sanitize_key((string) ($body["type"] ?? "custom"));
$title = sanitize_text_field((string) ($body["title"] ?? ""));
$args = [
"menu-item-title" => $title,
"menu-item-status" => "publish",
"menu-item-parent-id" => (int) ($body["parent"] ?? 0),
];
if ($existing_id) {
$args["menu-item-position"] = (int) ($body["order"] ?? 0);
}
if ($type === "custom") {
$args["menu-item-type"] = "custom";
$args["menu-item-url"] = esc_url_raw((string) ($body["url"] ?? "#"));
} else {
$object_id = (int) ($body["objectId"] ?? 0);
$args["menu-item-object-id"] = $object_id;
if ($type === "category") {
$args["menu-item-type"] = "taxonomy";
$args["menu-item-object"] = "category";
} else {
$args["menu-item-type"] = "post_type";
$args["menu-item-object"] = $type === "page" ? "page" : "post";
}
if ($title === "") {
$args["menu-item-title"] = get_the_title($object_id);
}
}
return $args;
}
private static function menu_id_for_item($item_id)
{
$menus = wp_get_object_terms($item_id, "nav_menu");
return !empty($menus) && !is_wp_error($menus) ? (int) $menus[0]->term_id : 0;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* Core has no REST route for the permalink structure (rewrite rules aren't
* a registered setting), so options-permalink.php's one real field needs its
* own tiny controller — same options_permalink_structure() logic core's own
* settings page runs, called directly instead of going through wp-admin's
* form handler.
*/
class PermalinksData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/permalinks", [
"methods" => "GET",
"callback" => [self::class, "get_structure"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/permalinks", [
"methods" => "POST",
"callback" => [self::class, "update_structure"],
"permission_callback" => [self::class, "check_permission"],
"args" => [
"structure" => ["type" => "string", "required" => true],
],
]);
}
public static function check_permission()
{
return current_user_can("manage_options");
}
public static function get_structure()
{
return new \WP_REST_Response([
"structure" => get_option("permalink_structure", ""),
], 200);
}
public static function update_structure($request)
{
$structure = sanitize_text_field((string) $request->get_param("structure"));
global $wp_rewrite;
$wp_rewrite->set_permalink_structure($structure);
$wp_rewrite->flush_rules();
return new \WP_REST_Response([
"success" => true,
"structure" => get_option("permalink_structure", ""),
], 200);
}
}
+10
View File
@@ -153,6 +153,16 @@ class PluginsData
if (!function_exists("delete_plugins")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
// delete_plugins() calls request_filesystem_credentials() internally
// (WP_Filesystem setup) — that's defined in file.php, which a REST API
// request doesn't load by default the way a normal wp-admin page load
// does. Without this, deleting a plugin fataled with "Call to
// undefined function request_filesystem_credentials()" instead of
// actually deleting anything. update_plugin() below already required
// this for the same reason; delete_plugin() just never got it.
if (!function_exists("request_filesystem_credentials")) {
require_once ABSPATH . "wp-admin/includes/file.php";
}
$file = self::sanitize_file($request->get_param("file"));
if (is_plugin_active($file)) {
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* The one REST route Aurora's Profile reskin needs beyond core's own
* /wp/v2/users/me (which already covers name/contact/bio fields): core
* deliberately excludes password from that endpoint's schema, so changing
* it needs this tiny wrapper around wp_set_password() — the same function
* the native Your Profile screen itself calls.
*/
class ProfileData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/profile/password", [
"methods" => "POST",
"callback" => [self::class, "update_password"],
"permission_callback" => function () {
return is_user_logged_in();
},
"args" => [
"password" => ["type" => "string", "required" => true],
],
]);
}
public static function update_password($request)
{
$password = (string) $request->get_param("password");
if (strlen($password) < 8) {
return new \WP_Error("aurora_profile_password_short", __("Password must be at least 8 characters.", "aurora-admin"), ["status" => 400]);
}
$user_id = get_current_user_id();
wp_set_password($password, $user_id);
// wp_set_password() invalidates every session's auth cookie (it's
// derived in part from the password hash) — including the one this
// very request came in on. Re-issue it immediately, same as core's own
// user-edit.php does when you change your own password, or the next
// request (the REST response reaching the browser) would already be
// logged out.
wp_clear_auth_cookie();
wp_set_auth_cookie($user_id);
return new \WP_REST_Response(["success" => true], 200);
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's Themes replacement (Appearance > Themes'
* browse/activate/delete — not the Customizer, which Shell already
* deliberately excludes from framing/hijacking).
*/
class ThemesData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function check_permission()
{
return current_user_can("switch_themes");
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/themes", [
"methods" => "GET",
"callback" => [self::class, "list_themes"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/themes/activate", [
"methods" => "POST",
"callback" => [self::class, "activate_theme"],
"permission_callback" => [self::class, "check_permission"],
"args" => ["stylesheet" => ["type" => "string", "required" => true]],
]);
register_rest_route("aurora-admin/v1", "/themes/(?P<stylesheet>[^/]+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_theme"],
"permission_callback" => function () {
return current_user_can("delete_themes");
},
]);
}
public static function list_themes()
{
$themes = wp_get_themes();
$active = get_stylesheet();
$out = [];
foreach ($themes as $stylesheet => $theme) {
$out[] = [
"stylesheet" => $stylesheet,
"name" => $theme->get("Name"),
"version" => $theme->get("Version"),
"description" => $theme->get("Description"),
"screenshot" => $theme->get_screenshot() ?: "",
"active" => $stylesheet === $active,
];
}
// Active theme first, alphabetical after.
usort($out, function ($a, $b) {
if ($a["active"] !== $b["active"]) {
return $b["active"] <=> $a["active"];
}
return strcasecmp($a["name"], $b["name"]);
});
return new \WP_REST_Response($out, 200);
}
public static function activate_theme($request)
{
$stylesheet = sanitize_text_field((string) $request->get_param("stylesheet"));
$theme = wp_get_theme($stylesheet);
if (!$theme->exists()) {
return new \WP_Error("aurora_theme_invalid", __("Theme not found.", "aurora-admin"), ["status" => 404]);
}
switch_theme($stylesheet);
return new \WP_REST_Response(["success" => true, "stylesheet" => $stylesheet], 200);
}
public static function delete_theme($request)
{
$stylesheet = sanitize_text_field((string) $request["stylesheet"]);
if ($stylesheet === get_stylesheet()) {
return new \WP_Error("aurora_theme_active", __("Can't delete the active theme.", "aurora-admin"), ["status" => 400]);
}
require_once ABSPATH . "wp-admin/includes/file.php";
require_once ABSPATH . "wp-admin/includes/theme.php";
$result = delete_theme($stylesheet);
if (is_wp_error($result)) {
return new \WP_Error("aurora_theme_delete_failed", $result->get_error_message(), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true], 200);
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for a deliberately simplified Widgets replacement:
* list registered sidebars (+ the "Inactive Widgets" pseudo-sidebar core
* itself uses to park deactivated instances), move an existing widget
* instance between sidebars, reorder within a sidebar, and remove an
* already-inactive instance entirely.
*
* What's NOT built here, deliberately: adding a brand-new instance of a
* widget type. Each widget class defines its own default instance settings
* and its own settings form — replicating that generically for every
* registered widget (core's + every theme/plugin's) is a full block-widget
* editor's worth of scope on its own, the same "don't rebuild the real
* editor" call made for the block editor and full drag-and-drop widget
* blocks. Users can still create a new instance via the native block
* widgets screen if this toggle is off; this covers reorganizing what's
* already there.
*/
class WidgetsData
{
const INACTIVE_ID = "wp_inactive_widgets";
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function check_permission()
{
return current_user_can("edit_theme_options");
}
/**
* wp_get_sidebars_widgets() is marked for internal core use only, so we
* read the same option it wraps directly. Unlike core's version this
* doesn't auto-assign never-placed widgets to a default sidebar — an
* edge case acceptable to skip given this is a deliberately simplified
* replacement (see class docblock).
*/
private static function get_sidebars_widgets()
{
$assignments = get_option("sidebars_widgets", []);
unset($assignments["array_version"]);
return $assignments;
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/widgets", [
"methods" => "GET",
"callback" => [self::class, "list_widgets"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/widgets/move", [
"methods" => "POST",
"callback" => [self::class, "move_widget"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/widgets/(?P<id>[^/]+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_widget"],
"permission_callback" => [self::class, "check_permission"],
]);
}
public static function list_widgets()
{
global $wp_registered_sidebars, $wp_registered_widgets;
$assignments = self::get_sidebars_widgets();
$sidebars = [];
foreach ($wp_registered_sidebars as $id => $sidebar) {
$sidebars[] = [
"id" => $id,
"name" => $sidebar["name"],
"widgets" => self::serialize_widget_list($assignments[$id] ?? []),
];
}
$sidebars[] = [
"id" => self::INACTIVE_ID,
"name" => __("Inactive Widgets", "aurora-admin"),
"widgets" => self::serialize_widget_list($assignments[self::INACTIVE_ID] ?? []),
];
return new \WP_REST_Response(["sidebars" => $sidebars], 200);
}
private static function serialize_widget_list($widget_ids)
{
global $wp_registered_widgets;
$out = [];
foreach (array_values($widget_ids) as $order => $widget_id) {
if (!preg_match('/^(.+)-(\d+)$/', $widget_id, $m)) {
// Some legacy widgets aren't multi-instance and use a bare id.
$id_base = $widget_id;
$number = null;
} else {
$id_base = $m[1];
$number = (int) $m[2];
}
$title = "";
if ($number !== null) {
$instance_option = get_option("widget_{$id_base}");
if (is_array($instance_option) && isset($instance_option[$number]["title"])) {
$title = (string) $instance_option[$number]["title"];
}
}
$out[] = [
"id" => $widget_id,
"idBase" => $id_base,
"name" => isset($wp_registered_widgets[$widget_id]["name"])
? $wp_registered_widgets[$widget_id]["name"]
: ucwords(str_replace(["-", "_"], " ", $id_base)),
"title" => $title,
"order" => $order,
];
}
return $out;
}
public static function move_widget($request)
{
$body = $request->get_json_params();
$widget_id = sanitize_text_field((string) ($body["widgetId"] ?? ""));
$from = sanitize_key((string) ($body["fromSidebar"] ?? ""));
$to = sanitize_key((string) ($body["toSidebar"] ?? ""));
$order = (int) ($body["order"] ?? -1);
if ($widget_id === "" || $to === "") {
return new \WP_Error("aurora_widget_invalid", __("Invalid widget move request.", "aurora-admin"), ["status" => 400]);
}
$assignments = self::get_sidebars_widgets();
if (!isset($assignments[$to])) {
$assignments[$to] = [];
}
if ($from !== "" && isset($assignments[$from])) {
$assignments[$from] = array_values(array_diff($assignments[$from], [$widget_id]));
}
$assignments[$to] = array_values(array_diff($assignments[$to], [$widget_id]));
if ($order < 0 || $order >= count($assignments[$to])) {
$assignments[$to][] = $widget_id;
} else {
array_splice($assignments[$to], $order, 0, [$widget_id]);
}
wp_set_sidebars_widgets($assignments);
return new \WP_REST_Response(["success" => true], 200);
}
public static function delete_widget($request)
{
$widget_id = sanitize_text_field((string) $request["id"]);
$assignments = self::get_sidebars_widgets();
$in_inactive = in_array($widget_id, $assignments[self::INACTIVE_ID] ?? [], true);
if (!$in_inactive) {
return new \WP_Error(
"aurora_widget_delete_requires_inactive",
__("Move the widget to Inactive Widgets before deleting it.", "aurora-admin"),
["status" => 400]
);
}
$assignments[self::INACTIVE_ID] = array_values(array_diff($assignments[self::INACTIVE_ID], [$widget_id]));
wp_set_sidebars_widgets($assignments);
if (preg_match('/^(.+)-(\d+)$/', $widget_id, $m)) {
$id_base = $m[1];
$number = (int) $m[2];
$instances = get_option("widget_{$id_base}");
if (is_array($instances) && isset($instances[$number])) {
unset($instances[$number]);
update_option("widget_{$id_base}", $instances);
}
}
return new \WP_REST_Response(["success" => true], 200);
}
}
+1
View File
@@ -1,6 +1,7 @@
<?php
namespace AuroraAdmin\Roles;
defined("ABSPATH") || exit();
/**
+6 -26
View File
@@ -144,36 +144,16 @@ class MenuSerializer
return "";
}
// Both checks default to true so existing installs keep their current
// behavior (previously always on regardless of these settings) until
// explicitly turned off in Settings > Plugins / Settings > Comments.
// Plugins is the one screen still on the redirect mechanism (plugins.php
// can't be cleanly output-buffered — see SubApps::redirect_native_pages()
// for why) — its sidebar link still needs rewriting to the Aurora URL.
// Posts/Pages/Media/Users/Comments hijack their native URLs in place
// instead (see HijackedNativePages), so their native sidebar links
// already point at the right place with no rewrite needed.
if ($slug === "plugins.php" && Settings::get("modern_plugins", true)) {
return admin_url("plugins.php?page=aurora-admin-plugins");
}
if ($slug === "edit-comments.php" && Settings::get("modern_comments", true)) {
return admin_url("admin.php?page=aurora-admin-comments");
}
// "edit.php" (no query string) is specifically the default post type's
// (Posts) list — other post types register as "edit.php?post_type=xyz",
// a different string, so they're untouched.
if ($slug === "edit.php" && Settings::get("modern_posts", true)) {
return admin_url("admin.php?page=aurora-admin-posts");
}
if ($slug === "edit.php?post_type=page" && Settings::get("modern_pages", true)) {
return admin_url("admin.php?page=aurora-admin-pages");
}
if ($slug === "upload.php" && Settings::get("modern_media", true)) {
return admin_url("admin.php?page=aurora-admin-media");
}
if ($slug === "users.php" && Settings::get("modern_users", true)) {
return admin_url("admin.php?page=aurora-admin-users");
}
// Already an absolute URL.
if (preg_match('#^https?://#', $slug)) {
return $slug;
+30 -21
View File
@@ -3,7 +3,7 @@ namespace AuroraAdmin\Shell;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
use AuroraAdmin\Licensing\License;
use AuroraAdmin\Pages\ScreenHijack;
defined("ABSPATH") || exit();
@@ -78,6 +78,7 @@ class Shell
*/
private static function is_own_page()
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, used only to decide light/dark chrome theming for the current screen; no state change.
$page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : "";
if (strpos($page, "aurora-admin") === 0) {
return true;
@@ -86,7 +87,16 @@ class Shell
// The dashboard (index.php) is taken over by Aurora's own dashboard, so
// treat it as one of our pages: dark frame, and the native-dark theme
// sheet is skipped there.
return isset($GLOBALS["pagenow"]) && $GLOBALS["pagenow"] === "index.php";
if (isset($GLOBALS["pagenow"]) && $GLOBALS["pagenow"] === "index.php") {
return true;
}
// ScreenHijack-based pages (Posts, Pages, Media, Users, Comments) keep
// their native URL — no ?page=aurora-admin to key off — so they'd
// otherwise be misdetected as native pages and get native-dark.css's
// own-page exclusion skipped, letting its generic `a` color rules leak
// into the real Aurora UI these screens render.
return ScreenHijack::is_hijack_active();
}
public static function print_early_styles()
@@ -189,12 +199,16 @@ class Shell
$current_user = wp_get_current_user();
// Who may see the remote-site switcher in the toolbar (Settings >
// General > Remote Site Switcher Capability, default manage_options).
$switcher_cap = Settings::get("remote_site_switcher_capability", "");
if (!is_string($switcher_cap) || trim($switcher_cap) === "") {
$switcher_cap = "manage_options";
}
// WP core sets this global on pages that are logically "under" a menu
// section but aren't themselves a listed submenu item — e.g.
// theme-install.php sets $parent_file = 'themes.php' (see wp-admin/
// theme-install.php), media-new.php sets 'upload.php', user-new.php
// sets 'users.php'. Native admin uses it to keep the *parent* menu
// section highlighted/expanded while on these "hidden child" screens;
// the sidebar needs the same signal, since MenuSerializer::serialize()
// only scrapes $submenu (which these pages deliberately aren't part
// of — WP's own native left menu doesn't list them as links either).
global $parent_file;
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
@@ -206,9 +220,9 @@ class Shell
"userEmail" => $current_user->user_email,
"avatar" => get_avatar_url($current_user->ID, ["size" => 64]),
"currentUrl" => esc_url_raw(self::current_admin_url()),
"activeParentFile" => is_string($parent_file) ? $parent_file : "",
// Secrets stripped — this is serialized for every admin user.
"settings" => Settings::get_safe(),
"canSwitchSites" => current_user_can($switcher_cap) && License::is_feature_available("remote-site-switcher"),
"menu" => MenuSerializer::serialize(),
"auroraNav" => self::aurora_nav(),
];
@@ -222,7 +236,7 @@ class Shell
private static function current_admin_url()
{
$request = isset($_SERVER["REQUEST_URI"]) ? $_SERVER["REQUEST_URI"] : "";
$request = isset($_SERVER["REQUEST_URI"]) ? sanitize_text_field(wp_unslash($_SERVER["REQUEST_URI"])) : "";
return home_url($request);
}
@@ -235,13 +249,12 @@ class Shell
private static function aurora_nav()
{
$items = [
["id" => "aurora-admin", "title" => __("Settings", "aurora-admin"), "icon" => "dashicons-admin-generic", "feature" => null],
["id" => "aurora-admin-menu-creator", "title" => __("Menu Creator", "aurora-admin"), "icon" => "dashicons-menu-alt", "feature" => null],
["id" => "aurora-admin-notices", "title" => __("Admin Notices", "aurora-admin"), "icon" => "dashicons-megaphone", "feature" => null],
["id" => "aurora-admin-roles", "title" => __("Role Editor", "aurora-admin"), "icon" => "dashicons-groups", "feature" => "role-editor"],
["id" => "aurora-admin-activity-log", "title" => __("Activity Log", "aurora-admin"), "icon" => "dashicons-clock", "feature" => "activity-log"],
["id" => "aurora-admin-database", "title" => __("Database Explorer", "aurora-admin"), "icon" => "dashicons-database", "feature" => "database-explorer"],
["id" => "aurora-admin-file-manager", "title" => __("File Manager", "aurora-admin"), "icon" => "dashicons-portfolio", "feature" => "file-manager"],
["id" => "aurora-admin", "title" => __("Settings", "aurora-admin"), "icon" => "dashicons-admin-generic"],
["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"],
["id" => "aurora-admin-activity-log", "title" => __("Activity Log", "aurora-admin"), "icon" => "dashicons-clock"],
["id" => "aurora-admin-bug-report", "title" => __("Report a Bug", "aurora-admin"), "icon" => "dashicons-warning"],
];
$nav = [];
@@ -254,10 +267,6 @@ class Shell
"title" => $item["title"],
"url" => esc_url_raw(admin_url("admin.php?page=" . $item["id"])),
"icon" => ["type" => "dashicon", "value" => $item["icon"]],
// Still a real, navigable link when locked — SubApps::render() shows
// an upgrade prompt there instead of the real feature. This flag
// just drives the small lock badge in the sidebar (Sidebar.vue).
"locked" => $item["feature"] && !License::is_feature_available($item["feature"]),
];
}
-9
View File
@@ -2,7 +2,6 @@
namespace AuroraAdmin\WhiteLabel;
use AuroraAdmin\Options\Settings;
use AuroraAdmin\Licensing\License;
defined("ABSPATH") || exit();
@@ -11,10 +10,6 @@ defined("ABSPATH") || exit();
* - admin_favicon: replaces the favicon across wp-admin
* - plugin_name: renames the plugin in the plugins list and the admin menu
* - text_replacements: find/replace pairs applied to translated admin text
*
* Paid feature — the settings fields stay visible either way (so an admin
* can pre-configure before upgrading), but none of the hooks below register
* without a license, so nothing actually applies yet.
*/
class WhiteLabel
{
@@ -23,10 +18,6 @@ class WhiteLabel
public function __construct()
{
if (!License::is_feature_available("white-label")) {
return;
}
add_action("admin_head", [self::class, "favicon"]);
add_filter("all_plugins", [self::class, "rename_plugin"]);
add_action("admin_menu", [self::class, "rename_menu"], 999);