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
+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>