Add File Manager sub-app (Pro feature) (v2.7.0)
New browse/edit interface for the WordPress install's own files, built using the installed "WP File Manager" plugin (elFinder-based) as a functional reference rather than embedding it. Own REST controller (FileManagerData.php) + Vue page, same pattern as every other sub-app. Security is the whole point here (highest-risk surface in the plugin — arbitrary filesystem read/write): - Root is ABSPATH, manage_options-gated throughout. - Every client path is resolved via realpath() and checked for root containment against the *resolved* path, not string-matched against the raw input — catches symlink tricks and encoded traversal that a naive "../" check would miss. - Uploads/new files/renames reject a blocklist of server-executable extensions (.php, .cgi, .sh, etc.) — the single most important control, since uploading a webshell is the classic exploit path for this class of feature. - Deliberately not built: zip/unzip (zip-slip vulnerability class), chmod/permissions editing, cross-directory move/copy. Gated as a paid feature (riskier than Database Explorer, which is already paid) — a judgment call flagged to the user, not something they specified. Also fixed a real bug found while wiring this up: SubApps.php's page-level lock doesn't stop someone with manage_options from calling the REST endpoints directly, bypassing the license check entirely. File Manager's REST permission callbacks now also check License::is_feature_available(). The same gap exists in Database Explorer, Role Editor, and Activity Log's REST controllers and is flagged as a follow-up task, not fixed here (out of scope for what was asked, affects three unrelated features). Verified live via direct REST calls: path traversal blocked (404), blocked extensions rejected (400), root deletion blocked (400), and a full create/write/read/rename/delete round trip succeeded cleanly. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
204c4365e2
commit
15d6060ae3
@@ -5,6 +5,36 @@ version bump. Starts at 2.1.0 — earlier history (the theming rebuild,
|
||||
window chrome/taskbar, Site Editor support, etc.) lives in `git log` and
|
||||
`PROJECT_STATUS.md`, not reconstructed here.
|
||||
|
||||
## [2.7.0] - 2026-07-05
|
||||
|
||||
### Added
|
||||
- New File Manager (`aurora-admin-file-manager`), browsing/editing the
|
||||
WordPress install's own files — built fresh (not embedding elFinder),
|
||||
in the spirit of the "WP File Manager" plugin already on this site
|
||||
(same root, same `manage_options`-only gate). Create/upload/download/
|
||||
rename/delete files and folders, plus a plain-text editor for
|
||||
code/config files. A **Pro feature** — the riskiest surface in the
|
||||
whole plugin (arbitrary read/write access to the site's own PHP files),
|
||||
so it's gated the same way as Database Explorer.
|
||||
- Every path a client sends is resolved to a real, `ABSPATH`-contained
|
||||
path via `realpath()` before any filesystem operation — containment
|
||||
is checked against the resolved path, not string-matched against the
|
||||
raw input, so symlink tricks and encoded traversal can't slip past.
|
||||
- Uploads/new files/renames all reject a blocklist of server-executable
|
||||
extensions (`.php`, `.phtml`, `.cgi`, `.sh`, etc.) — the single most
|
||||
important control here, since uploading a webshell is the whole ballgame.
|
||||
- Deliberately not built: zip/unzip (zip-slip extraction vulnerability),
|
||||
chmod/permissions editing, and cross-directory move/copy — each is a
|
||||
meaningfully larger, riskier scope than a first pass warrants.
|
||||
|
||||
### Fixed
|
||||
- File Manager's REST endpoints now also check
|
||||
`License::is_feature_available('file-manager')`, not just
|
||||
`manage_options` — found while wiring it up that the page-level lock
|
||||
alone doesn't stop a direct REST call from bypassing it entirely.
|
||||
**The same gap exists in Database Explorer, Role Editor, and Activity
|
||||
Log's REST controllers and is not yet fixed** — flagged as a follow-up.
|
||||
|
||||
## [2.6.0] - 2026-07-05
|
||||
|
||||
### Added
|
||||
|
||||
+63
-1
@@ -1,6 +1,6 @@
|
||||
# Aurora Admin v2 — Project Status
|
||||
|
||||
_Last updated: 2026-07-05 (latest session — Pages/Media/Users replacement pages, completing the "skinned vs. replaced" conversion started with Posts)_
|
||||
_Last updated: 2026-07-05 (latest session — new File Manager sub-app; found a license-bypass gap affecting several paid REST controllers)_
|
||||
|
||||
## What this project is
|
||||
|
||||
@@ -702,6 +702,68 @@ compatibility, trust), which wasn't in scope here.
|
||||
artifacts (the mu-plugin, the tampered file, the test manifest) were
|
||||
removed from the LocalWP install afterward.
|
||||
|
||||
## File Manager (new sub-app, Pro feature)
|
||||
|
||||
Added this session — the user has the "WP File Manager" plugin (elFinder-
|
||||
based) installed on the test site and asked for equivalent functionality
|
||||
built into Aurora, as a fresh implementation rather than embedding
|
||||
elFinder. `admin/src/FileManager/FileManagerData.php` (REST) +
|
||||
`app/src/pages/file-manager/FileManager.vue` (entry `file-manager.js`),
|
||||
registered as `aurora-admin-file-manager` in `SubApps.php` (parented under
|
||||
`aurora-admin` like Menu Creator/Role Editor, not a native-page
|
||||
replacement — there's no native WP file browser to redirect from).
|
||||
|
||||
- **Root is `ABSPATH`** (the WordPress install directory), same as the
|
||||
reference plugin. Capability is `manage_options` everywhere, no
|
||||
exceptions — by far the highest-risk surface in the plugin (arbitrary
|
||||
filesystem read/write on the site's own PHP).
|
||||
- **Path containment**: every client-supplied path goes through
|
||||
`resolve_path()`, which `realpath()`s the candidate and checks the
|
||||
*resolved* path is still inside the root — never a raw string check
|
||||
against `"../"`, since that alone doesn't catch symlinks or encoded
|
||||
traversal. New files/folders (which don't exist yet, so `realpath()`
|
||||
would return false) resolve their *parent* directory instead, then just
|
||||
sanitize the new name component (`wp_basename()`, reject empty/`.`/`..`/
|
||||
control characters).
|
||||
- Verified live via direct REST calls: `?path=../../../etc` → 404
|
||||
(correctly rejected); creating/renaming to a blocked extension → 400;
|
||||
deleting the root path itself → 400; a full create-folder → create-
|
||||
file → write → read → rename → recursive-delete round trip all
|
||||
succeeded and left no trace afterward.
|
||||
- **Blocked extensions on upload/create/rename**
|
||||
(`FileManagerData::BLOCKED_EXTENSIONS`): `.php` and friends, `.cgi`,
|
||||
`.sh`, `.exe`, etc. — the single most important control, since
|
||||
uploading a webshell through a "file manager" is the classic way this
|
||||
class of feature gets exploited. A separate `BINARY_EXTENSIONS` list
|
||||
(images/archives/media/fonts) plus a 2MB size cap keeps the text editor
|
||||
from trying to open something it shouldn't.
|
||||
- **Deliberately not built** (scope decisions): zip/unzip (extraction has
|
||||
its own well-known vulnerability class — zip-slip, where a crafted
|
||||
archive's entries escape the extraction directory), chmod/permissions
|
||||
editing (too easy to accidentally lock yourself out or open a real
|
||||
hole), cross-directory move/copy (rename within the same directory is
|
||||
supported; a full move/copy tree is meaningfully more surface for a v1).
|
||||
- **Gated as a paid feature** (`License::PAID_FEATURES["file-manager"]`)
|
||||
— this was a judgment call, not something the user specified; reasoned
|
||||
as "even riskier than Database Explorer, which is already paid," and
|
||||
flagged to the user as a default they could override. They didn't
|
||||
object.
|
||||
- **Real bug found and fixed while wiring this up, not specific to File
|
||||
Manager**: `SubApps.php`'s page-level lock (`render()` swaps in the
|
||||
upgrade prompt) only gates the *page* — it doesn't stop someone with
|
||||
`manage_options` from calling the REST endpoints directly and getting
|
||||
full functionality regardless of license status. File Manager's REST
|
||||
permission callbacks were fixed to also check
|
||||
`License::is_feature_available('file-manager')`. **Database Explorer,
|
||||
Role Editor, and Activity Log's REST controllers have the exact same
|
||||
gap and are NOT yet fixed** — flagged as a follow-up task
|
||||
(`task_cb1ac5ca` in this session) rather than fixed inline, since it's
|
||||
a pre-existing issue affecting three other features, out of scope for
|
||||
what was asked. If picking this back up: the fix is exactly what
|
||||
`FileManagerData.php` now does — add the license check to each
|
||||
`permission_callback`, matching the corresponding feature id in
|
||||
`SubApps.php`'s `PAGES` const.
|
||||
|
||||
## Key bugs fixed (root causes, in case they resurface)
|
||||
|
||||
- **Admin Notices invisible on the Dashboard**: `admin_notices` prints
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -55,6 +56,7 @@ class AuroraAdmin
|
||||
new PagesData();
|
||||
new MediaData();
|
||||
new UsersData();
|
||||
new FileManagerData();
|
||||
new ScheduledDayFilter();
|
||||
new WhiteLabel();
|
||||
new SubApps();
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ class License
|
||||
"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",
|
||||
|
||||
@@ -44,6 +44,7 @@ class SubApps
|
||||
"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],
|
||||
|
||||
@@ -241,6 +241,7 @@ class Shell
|
||||
["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"],
|
||||
];
|
||||
|
||||
$nav = [];
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aurora-admin-app",
|
||||
"version": "2.6.0",
|
||||
"version": "2.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import FileManager from '@/pages/file-manager/FileManager.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-file-manager-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(FileManager, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const path = ref('');
|
||||
const breadcrumb = ref([]);
|
||||
const items = ref([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const search = ref('');
|
||||
const fileInput = ref(null);
|
||||
|
||||
// Text editor state — a single file open at a time.
|
||||
const editing = ref(null); // { path, name, content, isWritable }
|
||||
const editorContent = ref('');
|
||||
const savingEdit = ref(false);
|
||||
|
||||
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
|
||||
|
||||
const request = async (path, options = {}) => {
|
||||
const response = await fetch(`${apiBase.value}${path}`, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers: {
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const load = async (targetPath = path.value) => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const params = new URLSearchParams({ path: targetPath });
|
||||
const data = await request(`/file-manager/list?${params.toString()}`);
|
||||
path.value = data.path || '';
|
||||
breadcrumb.value = data.breadcrumb || [];
|
||||
items.value = data.items || [];
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not load this folder';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => load(''));
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return items.value;
|
||||
return items.value.filter((item) => item.name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const openFolder = (item) => {
|
||||
if (item.type !== 'dir') return;
|
||||
load(item.path);
|
||||
};
|
||||
|
||||
const goToCrumb = (crumbPath) => load(crumbPath);
|
||||
const goToRoot = () => load('');
|
||||
|
||||
const newFolder = async () => {
|
||||
const name = window.prompt('New folder name:');
|
||||
if (!name) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: path.value, name }),
|
||||
});
|
||||
notice.value = data.message || 'Folder created.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not create folder';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const newFile = async () => {
|
||||
const name = window.prompt('New file name:');
|
||||
if (!name) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/file', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: path.value, name }),
|
||||
});
|
||||
notice.value = data.message || 'File created.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not create file';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const triggerUpload = () => fileInput.value?.click();
|
||||
|
||||
const onFileSelected = async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('path', path.value);
|
||||
formData.append('file', file);
|
||||
const data = await request('/file-manager/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
notice.value = data.message || 'File uploaded.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Upload failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const renameItem = async (item) => {
|
||||
const newName = window.prompt('Rename to:', item.name);
|
||||
if (!newName || newName === item.name) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: item.path, newName }),
|
||||
});
|
||||
notice.value = data.message || 'Renamed.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Rename failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async (item) => {
|
||||
const warning =
|
||||
item.type === 'dir'
|
||||
? `Permanently delete the folder "${item.name}" and everything inside it? This cannot be undone.`
|
||||
: `Permanently delete "${item.name}"? This cannot be undone.`;
|
||||
if (!window.confirm(warning)) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: item.path }),
|
||||
});
|
||||
notice.value = data.message || 'Deleted.';
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Delete failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadUrl = (item) => {
|
||||
const params = new URLSearchParams({
|
||||
path: item.path,
|
||||
_wpnonce: props.serverData.restNonce,
|
||||
});
|
||||
return `${apiBase.value}/file-manager/download?${params.toString()}`;
|
||||
};
|
||||
|
||||
const openEditor = async (item) => {
|
||||
error.value = '';
|
||||
try {
|
||||
const params = new URLSearchParams({ path: item.path });
|
||||
const data = await request(`/file-manager/read?${params.toString()}`);
|
||||
editing.value = { path: item.path, name: item.name, isWritable: data.isWritable };
|
||||
editorContent.value = data.content;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Could not open this file';
|
||||
}
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
editing.value = null;
|
||||
editorContent.value = '';
|
||||
};
|
||||
|
||||
const saveEditor = async () => {
|
||||
if (!editing.value) return;
|
||||
savingEdit.value = true;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const data = await request('/file-manager/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: editing.value.path, content: editorContent.value }),
|
||||
});
|
||||
notice.value = data.message || 'Saved.';
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Save failed';
|
||||
} finally {
|
||||
savingEdit.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const icon = (item) => {
|
||||
if (item.type === 'dir') return 'dashicons-portfolio';
|
||||
const ext = item.extension;
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(ext)) return 'dashicons-format-image';
|
||||
if (['zip', 'gz', 'tar', 'rar', '7z'].includes(ext)) return 'dashicons-media-archive';
|
||||
if (['mp3', 'wav', 'ogg'].includes(ext)) return 'dashicons-format-audio';
|
||||
if (['mp4', 'mov', 'avi', 'webm'].includes(ext)) return 'dashicons-format-video';
|
||||
if (['php', 'js', 'css', 'json', 'html', 'htm', 'xml', 'sql', 'yml', 'yaml'].includes(ext)) return 'dashicons-media-code';
|
||||
return 'dashicons-media-default';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="fm-page">
|
||||
<header class="fm-page__header">
|
||||
<div>
|
||||
<p class="fm-page__eyebrow">Tools</p>
|
||||
<h1>File Manager</h1>
|
||||
</div>
|
||||
<div class="fm-page__actions">
|
||||
<button type="button" class="icon-button" title="New folder" :disabled="busy" @click="newFolder">
|
||||
<span class="dashicons dashicons-plus-alt2" /> Folder
|
||||
</button>
|
||||
<button type="button" class="icon-button" title="New file" :disabled="busy" @click="newFile">
|
||||
<span class="dashicons dashicons-plus-alt2" /> File
|
||||
</button>
|
||||
<button type="button" class="icon-button" title="Upload file" :disabled="busy" @click="triggerUpload">
|
||||
<span class="dashicons dashicons-upload" /> Upload
|
||||
</button>
|
||||
<input ref="fileInput" type="file" hidden @change="onFileSelected" />
|
||||
<button type="button" class="icon-button" title="Refresh" :disabled="loading" @click="load()">
|
||||
<span class="dashicons dashicons-update" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="fm-breadcrumb" aria-label="Folder path">
|
||||
<button type="button" class="fm-breadcrumb__item" @click="goToRoot">WordPress root</button>
|
||||
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
||||
<span class="fm-breadcrumb__sep">/</span>
|
||||
<button type="button" class="fm-breadcrumb__item" @click="goToCrumb(crumb.path)">{{ crumb.label }}</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<section class="fm-toolbar">
|
||||
<label class="fm-search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="search" type="search" placeholder="Filter this folder..." />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div v-if="notice" class="fm-notice fm-notice--success">{{ notice }}</div>
|
||||
<div v-if="error" class="fm-notice fm-notice--error">{{ error }}</div>
|
||||
|
||||
<section class="fm-table" aria-label="Files and folders">
|
||||
<div class="fm-table__head">
|
||||
<div>Name</div>
|
||||
<div>Size</div>
|
||||
<div>Modified</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="fm-table__empty">Loading...</div>
|
||||
<div v-else-if="!filteredItems.length" class="fm-table__empty">This folder is empty.</div>
|
||||
|
||||
<article v-for="item in filteredItems" v-else :key="item.name" class="fm-row">
|
||||
<div
|
||||
class="fm-row__name"
|
||||
:class="{ 'fm-row__name--clickable': item.type === 'dir' }"
|
||||
@click="openFolder(item)"
|
||||
>
|
||||
<span class="dashicons" :class="icon(item)" />
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="fm-row__cell">{{ item.sizeLabel || '—' }}</div>
|
||||
<div class="fm-row__cell">{{ item.modified }}</div>
|
||||
<div class="fm-row__actions">
|
||||
<button
|
||||
v-if="item.isEditable"
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Edit"
|
||||
@click="openEditor(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-edit" />
|
||||
</button>
|
||||
<a
|
||||
v-if="item.type === 'file'"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Download"
|
||||
:href="downloadUrl(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-download" />
|
||||
</a>
|
||||
<button type="button" class="icon-button icon-button--inline" title="Rename" @click="renameItem(item)">
|
||||
<span class="dashicons dashicons-tag" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button icon-button--inline"
|
||||
title="Delete"
|
||||
:disabled="!item.isWritable"
|
||||
@click="deleteItem(item)"
|
||||
>
|
||||
<span class="dashicons dashicons-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Text editor overlay -->
|
||||
<div v-if="editing" class="fm-editor-backdrop" @click.self="closeEditor">
|
||||
<div class="fm-editor">
|
||||
<header class="fm-editor__header">
|
||||
<strong>{{ editing.name }}</strong>
|
||||
<span v-if="!editing.isWritable" class="fm-editor__readonly">Read-only (not writable on disk)</span>
|
||||
<div class="fm-editor__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button-primary"
|
||||
:disabled="!editing.isWritable || savingEdit"
|
||||
@click="saveEditor"
|
||||
>
|
||||
{{ savingEdit ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<button type="button" class="icon-button" @click="closeEditor">
|
||||
<span class="dashicons dashicons-no-alt" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<textarea
|
||||
v-model="editorContent"
|
||||
class="fm-editor__textarea"
|
||||
spellcheck="false"
|
||||
:readonly="!editing.isWritable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fm-page {
|
||||
min-height: calc(100vh - 112px);
|
||||
padding: 0;
|
||||
color: var(--aurora-text);
|
||||
background: transparent;
|
||||
}
|
||||
.fm-page__header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.fm-page__eyebrow {
|
||||
margin: 0 0 7px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.fm-page h1 {
|
||||
margin: 0;
|
||||
color: var(--aurora-text);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.fm-page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
color: var(--aurora-text);
|
||||
background: var(--aurora-bg-subtle);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.icon-button:hover:not(:disabled) {
|
||||
border-color: var(--aurora-text-muted);
|
||||
}
|
||||
.icon-button--inline {
|
||||
width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--aurora-text-muted);
|
||||
justify-content: center;
|
||||
}
|
||||
.icon-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.button-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-accent);
|
||||
color: var(--aurora-accent-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.fm-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.fm-breadcrumb__item {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--aurora-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.fm-breadcrumb__item:hover {
|
||||
color: var(--aurora-text);
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.fm-breadcrumb__sep {
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.fm-toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fm-search {
|
||||
width: min(320px, 100%);
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.fm-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.fm-notice {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.fm-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-frame-bg);
|
||||
}
|
||||
.fm-table__head,
|
||||
.fm-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1.8fr) 120px 180px 130px;
|
||||
align-items: center;
|
||||
}
|
||||
.fm-table__head {
|
||||
min-height: 44px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
.fm-table__head > div {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.fm-row {
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
}
|
||||
.fm-row:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.fm-row__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 10px 16px;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.fm-row__name span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm-row__name .dashicons {
|
||||
color: var(--aurora-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fm-row__name--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.fm-row__name--clickable:hover {
|
||||
color: var(--aurora-accent);
|
||||
}
|
||||
.fm-row__cell {
|
||||
min-width: 0;
|
||||
padding: 10px 16px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.78rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.fm-table__empty {
|
||||
padding: 38px 18px;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
.fm-editor-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30px;
|
||||
}
|
||||
.fm-editor {
|
||||
width: min(900px, 100%);
|
||||
height: min(640px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 10px;
|
||||
background: var(--aurora-frame-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.fm-editor__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.fm-editor__readonly {
|
||||
font-size: 0.75rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.fm-editor__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.fm-editor__textarea {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
resize: none;
|
||||
padding: 16px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fm-table__head {
|
||||
display: none;
|
||||
}
|
||||
.fm-row {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.fm-row__cell,
|
||||
.fm-row__actions {
|
||||
padding: 4px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,7 @@ export default defineConfig({
|
||||
pages: fileURLToPath(new URL('./src/entries/pages.js', import.meta.url)),
|
||||
media: fileURLToPath(new URL('./src/entries/media.js', import.meta.url)),
|
||||
users: fileURLToPath(new URL('./src/entries/users.js', import.meta.url)),
|
||||
'file-manager': fileURLToPath(new URL('./src/entries/file-manager.js', import.meta.url)),
|
||||
'locked-feature': fileURLToPath(new URL('./src/entries/locked-feature.js', import.meta.url)),
|
||||
},
|
||||
output: {
|
||||
|
||||
+3
-2
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: Aurora Admin
|
||||
* Plugin URI: https://aurora.co
|
||||
* Description: A modern, fast WordPress admin interface.
|
||||
* Version: 2.6.0
|
||||
* Version: 2.7.0
|
||||
* Author: Aurora
|
||||
* Text Domain: aurora-admin
|
||||
* Domain Path: /languages/
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
defined("ABSPATH") || exit();
|
||||
|
||||
define("AURORA_ADMIN_VERSION", "2.6.0");
|
||||
define("AURORA_ADMIN_VERSION", "2.7.0");
|
||||
define("AURORA_ADMIN_PATH", plugin_dir_path(__FILE__));
|
||||
define("AURORA_ADMIN_URL", plugins_url("aurora-admin/"));
|
||||
|
||||
@@ -36,6 +36,7 @@ require AURORA_ADMIN_PATH . "admin/src/Rest/PostsData.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Rest/PagesData.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Rest/MediaData.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Rest/UsersData.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/FileManager/FileManagerData.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Posts/ScheduledDayFilter.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/WhiteLabel/WhiteLabel.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Pages/SubApps.php";
|
||||
|
||||
Reference in New Issue
Block a user