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
|
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
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
"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",
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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)),
|
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
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user