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:
nixpanel
2026-07-05 06:59:24 -05:00
co-authored by Claude Sonnet 5
parent 204c4365e2
commit 15d6060ae3
12 changed files with 1228 additions and 4 deletions
+30
View File
@@ -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 window chrome/taskbar, Site Editor support, etc.) lives in `git log` and
`PROJECT_STATUS.md`, not reconstructed here. `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 ## [2.6.0] - 2026-07-05
### Added ### Added
+63 -1
View File
@@ -1,6 +1,6 @@
# Aurora Admin v2 — Project Status # 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 ## 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 artifacts (the mu-plugin, the tampered file, the test manifest) were
removed from the LocalWP install afterward. 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) ## Key bugs fixed (root causes, in case they resurface)
- **Admin Notices invisible on the Dashboard**: `admin_notices` prints - **Admin Notices invisible on the Dashboard**: `admin_notices` prints
+2
View File
@@ -15,6 +15,7 @@ use AuroraAdmin\Rest\PostsData;
use AuroraAdmin\Rest\PagesData; use AuroraAdmin\Rest\PagesData;
use AuroraAdmin\Rest\MediaData; use AuroraAdmin\Rest\MediaData;
use AuroraAdmin\Rest\UsersData; use AuroraAdmin\Rest\UsersData;
use AuroraAdmin\FileManager\FileManagerData;
use AuroraAdmin\Posts\ScheduledDayFilter; use AuroraAdmin\Posts\ScheduledDayFilter;
use AuroraAdmin\WhiteLabel\WhiteLabel; use AuroraAdmin\WhiteLabel\WhiteLabel;
use AuroraAdmin\Pages\SubApps; use AuroraAdmin\Pages\SubApps;
@@ -55,6 +56,7 @@ class AuroraAdmin
new PagesData(); new PagesData();
new MediaData(); new MediaData();
new UsersData(); new UsersData();
new FileManagerData();
new ScheduledDayFilter(); new ScheduledDayFilter();
new WhiteLabel(); new WhiteLabel();
new SubApps(); new SubApps();
+466
View File
@@ -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);
}
}
+1
View File
@@ -26,6 +26,7 @@ class License
"role-editor" => "Role Editor", "role-editor" => "Role Editor",
"activity-log" => "Activity Log", "activity-log" => "Activity Log",
"database-explorer" => "Database Explorer", "database-explorer" => "Database Explorer",
"file-manager" => "File Manager",
"analytics" => "Analytics", "analytics" => "Analytics",
"white-label" => "White Label", "white-label" => "White Label",
"remote-site-switcher" => "Remote Site Switcher", "remote-site-switcher" => "Remote Site Switcher",
+1
View File
@@ -44,6 +44,7 @@ class SubApps
"aurora-admin-roles" => ["title" => "Role Editor", "entry" => "role-editor", "feature" => "role-editor"], "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-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-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-plugins" => ["title" => "Installed Plugins", "entry" => "plugins", "feature" => null],
"aurora-admin-comments" => ["title" => "Comments", "entry" => "comments", "feature" => null], "aurora-admin-comments" => ["title" => "Comments", "entry" => "comments", "feature" => null],
"aurora-admin-posts" => ["title" => "Posts", "entry" => "posts", "feature" => null], "aurora-admin-posts" => ["title" => "Posts", "entry" => "posts", "feature" => null],
+1
View File
@@ -241,6 +241,7 @@ class Shell
["id" => "aurora-admin-roles", "title" => __("Role Editor", "aurora-admin"), "icon" => "dashicons-groups", "feature" => "role-editor"], ["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-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-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 = []; $nav = [];
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aurora-admin-app", "name": "aurora-admin-app",
"version": "2.6.0", "version": "2.7.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+10
View File
@@ -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);
}
+649
View File
@@ -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>
+1
View File
@@ -34,6 +34,7 @@ export default defineConfig({
pages: fileURLToPath(new URL('./src/entries/pages.js', import.meta.url)), pages: fileURLToPath(new URL('./src/entries/pages.js', import.meta.url)),
media: fileURLToPath(new URL('./src/entries/media.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)), 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)), 'locked-feature': fileURLToPath(new URL('./src/entries/locked-feature.js', import.meta.url)),
}, },
output: { output: {
+3 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Aurora Admin * Plugin Name: Aurora Admin
* Plugin URI: https://aurora.co * Plugin URI: https://aurora.co
* Description: A modern, fast WordPress admin interface. * Description: A modern, fast WordPress admin interface.
* Version: 2.6.0 * Version: 2.7.0
* Author: Aurora * Author: Aurora
* Text Domain: aurora-admin * Text Domain: aurora-admin
* Domain Path: /languages/ * Domain Path: /languages/
@@ -14,7 +14,7 @@
defined("ABSPATH") || exit(); 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_PATH", plugin_dir_path(__FILE__));
define("AURORA_ADMIN_URL", plugins_url("aurora-admin/")); 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/PagesData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/MediaData.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/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/Posts/ScheduledDayFilter.php";
require AURORA_ADMIN_PATH . "admin/src/WhiteLabel/WhiteLabel.php"; require AURORA_ADMIN_PATH . "admin/src/WhiteLabel/WhiteLabel.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/SubApps.php"; require AURORA_ADMIN_PATH . "admin/src/Pages/SubApps.php";