From 15d6060ae3d5f4e685577e738ac2fce8c22b86a3 Mon Sep 17 00:00:00 2001 From: nixpanel Date: Sun, 5 Jul 2026 06:59:24 -0500 Subject: [PATCH] Add File Manager sub-app (Pro feature) (v2.7.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 30 + PROJECT_STATUS.md | 64 +- admin/src/App/AuroraAdmin.php | 2 + admin/src/FileManager/FileManagerData.php | 466 +++++++++++++++ admin/src/Licensing/License.php | 1 + admin/src/Pages/SubApps.php | 1 + admin/src/Shell/Shell.php | 1 + app/package.json | 2 +- app/src/entries/file-manager.js | 10 + app/src/pages/file-manager/FileManager.vue | 649 +++++++++++++++++++++ app/vite.config.js | 1 + aurora-admin.php | 5 +- 12 files changed, 1228 insertions(+), 4 deletions(-) create mode 100644 admin/src/FileManager/FileManagerData.php create mode 100644 app/src/entries/file-manager.js create mode 100644 app/src/pages/file-manager/FileManager.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 9636529..d817a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 252533c..0259b95 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -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 diff --git a/admin/src/App/AuroraAdmin.php b/admin/src/App/AuroraAdmin.php index 26aebf3..89a4227 100644 --- a/admin/src/App/AuroraAdmin.php +++ b/admin/src/App/AuroraAdmin.php @@ -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(); diff --git a/admin/src/FileManager/FileManagerData.php b/admin/src/FileManager/FileManagerData.php new file mode 100644 index 0000000..273c494 --- /dev/null +++ b/admin/src/FileManager/FileManagerData.php @@ -0,0 +1,466 @@ + "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); + } +} diff --git a/admin/src/Licensing/License.php b/admin/src/Licensing/License.php index db788cb..e553ab3 100644 --- a/admin/src/Licensing/License.php +++ b/admin/src/Licensing/License.php @@ -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", diff --git a/admin/src/Pages/SubApps.php b/admin/src/Pages/SubApps.php index 9f5eb1c..cddbbb4 100644 --- a/admin/src/Pages/SubApps.php +++ b/admin/src/Pages/SubApps.php @@ -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], diff --git a/admin/src/Shell/Shell.php b/admin/src/Shell/Shell.php index 88a58ea..cb99146 100644 --- a/admin/src/Shell/Shell.php +++ b/admin/src/Shell/Shell.php @@ -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 = []; diff --git a/app/package.json b/app/package.json index fbe3784..cff930b 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "aurora-admin-app", - "version": "2.6.0", + "version": "2.7.0", "private": true, "type": "module", "scripts": { diff --git a/app/src/entries/file-manager.js b/app/src/entries/file-manager.js new file mode 100644 index 0000000..befc169 --- /dev/null +++ b/app/src/entries/file-manager.js @@ -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); +} diff --git a/app/src/pages/file-manager/FileManager.vue b/app/src/pages/file-manager/FileManager.vue new file mode 100644 index 0000000..1bfd9d6 --- /dev/null +++ b/app/src/pages/file-manager/FileManager.vue @@ -0,0 +1,649 @@ + + +