Files
GoTunnel-Plugins/plugins/file-manager/plugin.js
Flik 3513a2e56b
All checks were successful
Sign Plugins / sign (push) Successful in 1m0s
feat: introduce file manager plugin with web UI and file operation capabilities.
2026-01-03 14:09:12 +08:00

726 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// GoTunnel File Manager Plugin v2.0.0
// 提供完整的 Web 文件管理界面
function metadata() {
return {
name: "file-manager",
version: "2.0.0",
description: "Web 文件管理器,提供远程文件浏览、上传、下载和管理功能",
author: "GoTunnel Official",
type: "app",
run_at: "client"
};
}
var rootPath = ".";
function start() {
rootPath = config("root_path") || ".";
log("File Manager started, root: " + rootPath);
}
function stop() {
log("File Manager stopped");
}
function handleConn(conn) {
http.serve(conn, handleRequest);
}
// ============================================================================
// 请求路由
// ============================================================================
function handleRequest(req) {
var path = req.path;
var method = req.method;
// 静态页面
if (path === "/" || path === "/index.html") {
return { status: 200, contentType: "text/html", body: getIndexHTML() };
}
// API 路由
if (path === "/api/list" && method === "GET") {
return apiList(req);
}
if (path === "/api/stat" && method === "GET") {
return apiStat(req);
}
if (path === "/api/read" && method === "POST") {
return apiRead(req);
}
if (path === "/api/write" && method === "POST") {
return apiWrite(req);
}
if (path === "/api/mkdir" && method === "POST") {
return apiMkdir(req);
}
if (path === "/api/delete" && method === "POST") {
return apiDelete(req);
}
if (path === "/api/rename" && method === "POST") {
return apiRename(req);
}
if (path === "/api/download" && method === "GET") {
return apiDownload(req);
}
return jsonError(404, "Not Found");
}
// ============================================================================
// API 处理函数
// ============================================================================
function apiList(req) {
var dir = getQueryParam(req.path, "path") || "/";
var fullPath = resolvePath(dir);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.readDir(fullPath);
if (result.error) {
return jsonError(500, result.error);
}
var entries = [];
for (var i = 0; i < result.entries.length; i++) {
var e = result.entries[i];
entries.push({
name: e.name,
isDir: e.isDir,
size: e.size || 0
});
}
// 排序:文件夹在前,然后按名称
entries.sort(function (a, b) {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
return jsonSuccess({ path: dir, entries: entries });
}
function apiStat(req) {
var path = getQueryParam(req.path, "path") || "/";
var fullPath = resolvePath(path);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.stat(fullPath);
if (result.error) {
return jsonError(404, result.error);
}
return jsonSuccess({
name: result.name,
size: result.size,
isDir: result.isDir,
modTime: result.modTime
});
}
function apiRead(req) {
var body = parseBody(req.body);
var path = body.path;
if (!path) {
return jsonError(400, "path required");
}
var fullPath = resolvePath(path);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.readFile(fullPath);
if (result.error) {
return jsonError(500, result.error);
}
return jsonSuccess({ content: result.data });
}
function apiWrite(req) {
var body = parseBody(req.body);
var path = body.path;
var content = body.content;
if (!path) {
return jsonError(400, "path required");
}
var fullPath = resolvePath(path);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.writeFile(fullPath, content || "");
if (!result.ok) {
return jsonError(500, result.error);
}
return jsonSuccess({ message: "File saved" });
}
function apiMkdir(req) {
var body = parseBody(req.body);
var path = body.path;
if (!path) {
return jsonError(400, "path required");
}
var fullPath = resolvePath(path);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.mkdir(fullPath);
if (!result.ok) {
return jsonError(500, result.error);
}
return jsonSuccess({ message: "Directory created" });
}
function apiDelete(req) {
var body = parseBody(req.body);
var path = body.path;
if (!path) {
return jsonError(400, "path required");
}
var fullPath = resolvePath(path);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.remove(fullPath);
if (!result.ok) {
return jsonError(500, result.error);
}
return jsonSuccess({ message: "Deleted" });
}
function apiRename(req) {
var body = parseBody(req.body);
var oldPath = body.path;
var newPath = body.newPath;
if (!oldPath || !newPath) {
return jsonError(400, "path and newPath required");
}
var fullOldPath = resolvePath(oldPath);
var fullNewPath = resolvePath(newPath);
if (!fullOldPath || !fullNewPath) {
return jsonError(403, "Access denied");
}
// 读取旧文件
var stat = fs.stat(fullOldPath);
if (stat.error) {
return jsonError(404, stat.error);
}
if (stat.isDir) {
return jsonError(400, "Cannot rename directories");
}
var content = fs.readFile(fullOldPath);
if (content.error) {
return jsonError(500, content.error);
}
// 写入新位置
var writeResult = fs.writeFile(fullNewPath, content.data);
if (!writeResult.ok) {
return jsonError(500, writeResult.error);
}
// 删除旧文件
fs.remove(fullOldPath);
return jsonSuccess({ message: "Renamed" });
}
function apiDownload(req) {
var path = getQueryParam(req.path, "path");
if (!path) {
return jsonError(400, "path required");
}
var fullPath = resolvePath(path);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.readFile(fullPath);
if (result.error) {
return jsonError(404, result.error);
}
var filename = path.split("/").pop() || "download";
return {
status: 200,
contentType: "application/octet-stream",
body: result.data
};
}
// ============================================================================
// 工具函数
// ============================================================================
function resolvePath(path) {
// 规范化路径
path = path || "/";
// 移除开头的斜杠
while (path.charAt(0) === "/") {
path = path.substring(1);
}
// 检查路径穿越攻击
if (path.indexOf("..") !== -1) {
return null;
}
// 拼接根路径
if (path === "") {
return rootPath;
}
return rootPath + "/" + path;
}
function getQueryParam(path, key) {
var idx = path.indexOf("?");
if (idx === -1) return "";
var query = path.substring(idx + 1);
var pairs = query.split("&");
for (var i = 0; i < pairs.length; i++) {
var pair = pairs[i].split("=");
if (pair[0] === key) {
return decodeURIComponent(pair[1] || "");
}
}
return "";
}
function parseBody(body) {
try {
return JSON.parse(body || "{}");
} catch (e) {
return {};
}
}
function jsonSuccess(data) {
return {
status: 200,
contentType: "application/json",
body: http.json({ success: true, data: data })
};
}
function jsonError(status, message) {
return {
status: status,
contentType: "application/json",
body: http.json({ success: false, error: message })
};
}
// ============================================================================
// Web UI - HTML/CSS/JavaScript
// ============================================================================
function getIndexHTML() {
return '<!DOCTYPE html>\n\
<html lang="zh-CN">\n\
<head>\n\
<meta charset="UTF-8">\n\
<meta name="viewport" content="width=device-width, initial-scale=1.0">\n\
<title>File Manager</title>\n\
<style>\n\
* { margin: 0; padding: 0; box-sizing: border-box; }\n\
:root {\n\
--bg-primary: #0f0f23;\n\
--bg-secondary: #1a1a2e;\n\
--bg-tertiary: #252542;\n\
--text-primary: #e4e4e7;\n\
--text-secondary: #a1a1aa;\n\
--accent: #6366f1;\n\
--accent-hover: #818cf8;\n\
--danger: #ef4444;\n\
--success: #22c55e;\n\
--border: #2e2e4a;\n\
}\n\
body {\n\
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;\n\
background: var(--bg-primary);\n\
color: var(--text-primary);\n\
min-height: 100vh;\n\
}\n\
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }\n\
header {\n\
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));\n\
padding: 20px;\n\
border-radius: 12px;\n\
margin-bottom: 20px;\n\
border: 1px solid var(--border);\n\
}\n\
header h1 { font-size: 1.5rem; display: flex; align-items: center; gap: 10px; }\n\
header h1::before { content: "📁"; }\n\
.breadcrumb {\n\
display: flex; align-items: center; gap: 8px; margin-top: 15px;\n\
padding: 10px 15px; background: var(--bg-primary); border-radius: 8px;\n\
overflow-x: auto;\n\
}\n\
.breadcrumb a {\n\
color: var(--accent); text-decoration: none;\n\
white-space: nowrap;\n\
}\n\
.breadcrumb a:hover { color: var(--accent-hover); }\n\
.breadcrumb span { color: var(--text-secondary); }\n\
.toolbar {\n\
display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap;\n\
}\n\
.btn {\n\
padding: 10px 20px; border: none; border-radius: 8px;\n\
cursor: pointer; font-size: 14px; font-weight: 500;\n\
transition: all 0.2s; display: flex; align-items: center; gap: 6px;\n\
}\n\
.btn-primary { background: var(--accent); color: white; }\n\
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); }\n\
.btn-danger { background: var(--danger); color: white; }\n\
.btn-danger:hover { opacity: 0.9; }\n\
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); }\n\
.btn-secondary:hover { background: var(--border); }\n\
.file-list {\n\
background: var(--bg-secondary); border-radius: 12px;\n\
border: 1px solid var(--border); overflow: hidden;\n\
}\n\
.file-item {\n\
display: flex; align-items: center; padding: 15px 20px;\n\
border-bottom: 1px solid var(--border); cursor: pointer;\n\
transition: background 0.2s;\n\
}\n\
.file-item:last-child { border-bottom: none; }\n\
.file-item:hover { background: var(--bg-tertiary); }\n\
.file-item.selected { background: rgba(99, 102, 241, 0.2); }\n\
.file-icon { font-size: 24px; margin-right: 15px; }\n\
.file-info { flex: 1; }\n\
.file-name { font-weight: 500; margin-bottom: 4px; }\n\
.file-meta { font-size: 12px; color: var(--text-secondary); }\n\
.file-actions { display: flex; gap: 8px; opacity: 0; transition: opacity 0.2s; }\n\
.file-item:hover .file-actions { opacity: 1; }\n\
.action-btn {\n\
width: 32px; height: 32px; border: none; border-radius: 6px;\n\
cursor: pointer; display: flex; align-items: center; justify-content: center;\n\
background: var(--bg-primary); color: var(--text-primary);\n\
transition: all 0.2s;\n\
}\n\
.action-btn:hover { background: var(--accent); }\n\
.action-btn.delete:hover { background: var(--danger); }\n\
.modal-overlay {\n\
position: fixed; top: 0; left: 0; right: 0; bottom: 0;\n\
background: rgba(0,0,0,0.7); display: flex;\n\
align-items: center; justify-content: center; z-index: 1000;\n\
}\n\
.modal {\n\
background: var(--bg-secondary); border-radius: 12px;\n\
padding: 25px; width: 90%; max-width: 500px;\n\
border: 1px solid var(--border);\n\
}\n\
.modal h2 { margin-bottom: 20px; font-size: 1.2rem; }\n\
.modal input, .modal textarea {\n\
width: 100%; padding: 12px; border-radius: 8px;\n\
border: 1px solid var(--border); background: var(--bg-primary);\n\
color: var(--text-primary); margin-bottom: 15px; font-size: 14px;\n\
}\n\
.modal textarea { min-height: 200px; resize: vertical; font-family: monospace; }\n\
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; }\n\
.empty-state {\n\
text-align: center; padding: 60px 20px; color: var(--text-secondary);\n\
}\n\
.empty-state::before { content: "📂"; font-size: 48px; display: block; margin-bottom: 15px; }\n\
.loading { text-align: center; padding: 40px; color: var(--text-secondary); }\n\
.toast {\n\
position: fixed; bottom: 20px; right: 20px; padding: 15px 25px;\n\
background: var(--success); color: white; border-radius: 8px;\n\
animation: slideIn 0.3s ease;\n\
}\n\
.toast.error { background: var(--danger); }\n\
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } }\n\
</style>\n\
</head>\n\
<body>\n\
<div class="container">\n\
<header>\n\
<h1>File Manager</h1>\n\
<div class="breadcrumb" id="breadcrumb"></div>\n\
</header>\n\
<div class="toolbar">\n\
<button class="btn btn-primary" onclick="showNewFolderModal()">📁 新建文件夹</button>\n\
<button class="btn btn-primary" onclick="showNewFileModal()">📄 新建文件</button>\n\
<button class="btn btn-secondary" onclick="refresh()">🔄 刷新</button>\n\
</div>\n\
<div class="file-list" id="fileList">\n\
<div class="loading">加载中...</div>\n\
</div>\n\
</div>\n\
<div id="modalContainer"></div>\n\
<script>\n\
var currentPath = "/";\n\
\n\
function init() { loadDirectory("/"); }\n\
\n\
function loadDirectory(path) {\n\
currentPath = path;\n\
updateBreadcrumb();\n\
document.getElementById("fileList").innerHTML = "<div class=\\"loading\\">加载中...</div>";\n\
\n\
fetch("/api/list?path=" + encodeURIComponent(path))\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (!data.success) { showToast(data.error, true); return; }\n\
renderFileList(data.data.entries);\n\
})\n\
.catch(function(e) { showToast("加载失败: " + e.message, true); });\n\
}\n\
\n\
function renderFileList(entries) {\n\
var list = document.getElementById("fileList");\n\
if (!entries || entries.length === 0) {\n\
list.innerHTML = "<div class=\\"empty-state\\">空文件夹</div>";\n\
return;\n\
}\n\
\n\
var html = "";\n\
if (currentPath !== "/") {\n\
html += "<div class=\\"file-item\\" onclick=\\"goUp()\\"><span class=\\"file-icon\\">⬆️</span><div class=\\"file-info\\"><div class=\\"file-name\\">..</div><div class=\\"file-meta\\">返回上级</div></div></div>";\n\
}\n\
\n\
for (var i = 0; i < entries.length; i++) {\n\
var e = entries[i];\n\
var icon = e.isDir ? "📁" : getFileIcon(e.name);\n\
var size = e.isDir ? "" : formatSize(e.size);\n\
var onclick = e.isDir ? "navigateTo(\\"" + e.name + "\\")" : "viewFile(\\"" + e.name + "\\")";\n\
\n\
html += "<div class=\\"file-item\\" onclick=\\""+ onclick +"\\">";\n\
html += "<span class=\\"file-icon\\">" + icon + "</span>";\n\
html += "<div class=\\"file-info\\"><div class=\\"file-name\\">" + escapeHtml(e.name) + "</div>";\n\
html += "<div class=\\"file-meta\\">" + size + "</div></div>";\n\
html += "<div class=\\"file-actions\\">";\n\
if (!e.isDir) {\n\
html += "<button class=\\"action-btn\\" onclick=\\"event.stopPropagation();downloadFile(\\'"+e.name+"\\')\\">⬇️</button>";\n\
}\n\
html += "<button class=\\"action-btn\\" onclick=\\"event.stopPropagation();renameItem(\\'"+e.name+"\\')\\">✏️</button>";\n\
html += "<button class=\\"action-btn delete\\" onclick=\\"event.stopPropagation();deleteItem(\\'"+e.name+"\\')\\">🗑️</button>";\n\
html += "</div></div>";\n\
}\n\
list.innerHTML = html;\n\
}\n\
\n\
function updateBreadcrumb() {\n\
var parts = currentPath.split("/").filter(function(p) { return p; });\n\
var html = "<a href=\\"#\\" onclick=\\"loadDirectory(\\'/\\');return false;\\">🏠 根目录</a>";\n\
var path = "";\n\
for (var i = 0; i < parts.length; i++) {\n\
path += "/" + parts[i];\n\
html += "<span>/</span><a href=\\"#\\" onclick=\\"loadDirectory(\\'"+path+"\\');return false;\\">" + escapeHtml(parts[i]) + "</a>";\n\
}\n\
document.getElementById("breadcrumb").innerHTML = html;\n\
}\n\
\n\
function navigateTo(name) { loadDirectory(joinPath(currentPath, name)); }\n\
function goUp() {\n\
var parts = currentPath.split("/").filter(function(p) { return p; });\n\
parts.pop();\n\
loadDirectory("/" + parts.join("/"));\n\
}\n\
function refresh() { loadDirectory(currentPath); }\n\
\n\
function joinPath(base, name) {\n\
if (base === "/") return "/" + name;\n\
return base + "/" + name;\n\
}\n\
\n\
function viewFile(name) {\n\
var path = joinPath(currentPath, name);\n\
fetch("/api/read", {\n\
method: "POST",\n\
headers: {"Content-Type": "application/json"},\n\
body: JSON.stringify({path: path})\n\
})\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (!data.success) { showToast(data.error, true); return; }\n\
showEditModal(name, data.data.content);\n\
});\n\
}\n\
\n\
function downloadFile(name) {\n\
var path = joinPath(currentPath, name);\n\
window.open("/api/download?path=" + encodeURIComponent(path));\n\
}\n\
\n\
function deleteItem(name) {\n\
if (!confirm("确定删除 " + name + "")) return;\n\
var path = joinPath(currentPath, name);\n\
fetch("/api/delete", {\n\
method: "POST",\n\
headers: {"Content-Type": "application/json"},\n\
body: JSON.stringify({path: path})\n\
})\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (data.success) { showToast("已删除"); refresh(); }\n\
else { showToast(data.error, true); }\n\
});\n\
}\n\
\n\
function renameItem(name) {\n\
var newName = prompt("输入新名称:", name);\n\
if (!newName || newName === name) return;\n\
var oldPath = joinPath(currentPath, name);\n\
var newPath = joinPath(currentPath, newName);\n\
fetch("/api/rename", {\n\
method: "POST",\n\
headers: {"Content-Type": "application/json"},\n\
body: JSON.stringify({path: oldPath, newPath: newPath})\n\
})\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (data.success) { showToast("已重命名"); refresh(); }\n\
else { showToast(data.error, true); }\n\
});\n\
}\n\
\n\
function showNewFolderModal() {\n\
var name = prompt("文件夹名称:");\n\
if (!name) return;\n\
fetch("/api/mkdir", {\n\
method: "POST",\n\
headers: {"Content-Type": "application/json"},\n\
body: JSON.stringify({path: joinPath(currentPath, name)})\n\
})\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (data.success) { showToast("已创建"); refresh(); }\n\
else { showToast(data.error, true); }\n\
});\n\
}\n\
\n\
function showNewFileModal() {\n\
var html = "<div class=\\"modal-overlay\\" onclick=\\"closeModal()\\">";\n\
html += "<div class=\\"modal\\" onclick=\\"event.stopPropagation()\\">";\n\
html += "<h2>📄 新建文件</h2>";\n\
html += "<input type=\\"text\\" id=\\"newFileName\\" placeholder=\\"文件名\\">";\n\
html += "<textarea id=\\"newFileContent\\" placeholder=\\"文件内容\\"></textarea>";\n\
html += "<div class=\\"modal-actions\\">";\n\
html += "<button class=\\"btn btn-secondary\\" onclick=\\"closeModal()\\">取消</button>";\n\
html += "<button class=\\"btn btn-primary\\" onclick=\\"createFile()\\">创建</button>";\n\
html += "</div></div></div>";\n\
document.getElementById("modalContainer").innerHTML = html;\n\
}\n\
\n\
function showEditModal(name, content) {\n\
var html = "<div class=\\"modal-overlay\\" onclick=\\"closeModal()\\">";\n\
html += "<div class=\\"modal\\" onclick=\\"event.stopPropagation()\\">";\n\
html += "<h2>📝 编辑: " + escapeHtml(name) + "</h2>";\n\
html += "<input type=\\"hidden\\" id=\\"editFileName\\" value=\\"" + escapeHtml(name) + "\\">";\n\
html += "<textarea id=\\"editFileContent\\">" + escapeHtml(content) + "</textarea>";\n\
html += "<div class=\\"modal-actions\\">";\n\
html += "<button class=\\"btn btn-secondary\\" onclick=\\"closeModal()\\">取消</button>";\n\
html += "<button class=\\"btn btn-primary\\" onclick=\\"saveFile()\\">保存</button>";\n\
html += "</div></div></div>";\n\
document.getElementById("modalContainer").innerHTML = html;\n\
}\n\
\n\
function createFile() {\n\
var name = document.getElementById("newFileName").value;\n\
var content = document.getElementById("newFileContent").value;\n\
if (!name) { showToast("请输入文件名", true); return; }\n\
fetch("/api/write", {\n\
method: "POST",\n\
headers: {"Content-Type": "application/json"},\n\
body: JSON.stringify({path: joinPath(currentPath, name), content: content})\n\
})\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (data.success) { closeModal(); showToast("已创建"); refresh(); }\n\
else { showToast(data.error, true); }\n\
});\n\
}\n\
\n\
function saveFile() {\n\
var name = document.getElementById("editFileName").value;\n\
var content = document.getElementById("editFileContent").value;\n\
fetch("/api/write", {\n\
method: "POST",\n\
headers: {"Content-Type": "application/json"},\n\
body: JSON.stringify({path: joinPath(currentPath, name), content: content})\n\
})\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (data.success) { closeModal(); showToast("已保存"); }\n\
else { showToast(data.error, true); }\n\
});\n\
}\n\
\n\
function closeModal() { document.getElementById("modalContainer").innerHTML = ""; }\n\
\n\
function showToast(msg, isError) {\n\
var toast = document.createElement("div");\n\
toast.className = "toast" + (isError ? " error" : "");\n\
toast.textContent = msg;\n\
document.body.appendChild(toast);\n\
setTimeout(function() { toast.remove(); }, 3000);\n\
}\n\
\n\
function formatSize(bytes) {\n\
if (bytes === 0) return "0 B";\n\
var k = 1024;\n\
var sizes = ["B", "KB", "MB", "GB"];\n\
var i = Math.floor(Math.log(bytes) / Math.log(k));\n\
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];\n\
}\n\
\n\
function getFileIcon(name) {\n\
var ext = name.split(".").pop().toLowerCase();\n\
var icons = {\n\
js: "📜", json: "📋", html: "🌐", css: "🎨", md: "📝",\n\
txt: "📄", log: "📃", py: "🐍", go: "🔵", java: "☕",\n\
png: "🖼️", jpg: "🖼️", gif: "🖼️", svg: "🖼️",\n\
mp3: "🎵", mp4: "🎬", pdf: "📕", zip: "📦", tar: "📦"\n\
};\n\
return icons[ext] || "📄";\n\
}\n\
\n\
function escapeHtml(str) {\n\
return String(str).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");\n\
}\n\
\n\
init();\n\
</script>\n\
</body>\n\
</html>';
}