mirror of
https://github.com/zhang-pu/tpanel.git
synced 2026-10-02 16:29:29 +08:00
Release v1.3.45
🔥 关键 bug 修复: - 文件管理-管理员模式不能下载(缺 /api/admin/files/download 端点) - 强制刷新页面后点链接无响应(Flask send_static_file 默认 12h 缓存,浏览器拿旧版 JS → onclick undefined) ✨ 增强: - index.html 加 meta no-cache 标签(双保险) - 管理员模式文件行加 ⬇️ 下载按钮 - downloadFile(name) 加 isAdminMode 分支 🧹 清理: - file_manager.read_file max_size 1MB→50MB(与 main.py api_admin_files_read 一致)
This commit is contained in:
parent
1427a67964
commit
3d77af6744
3 changed files with 126 additions and 39 deletions
|
|
@ -99,7 +99,7 @@ def format_permissions(mode):
|
||||||
chars = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']
|
chars = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']
|
||||||
return chars[(mode >> 6) & 7] + chars[(mode >> 3) & 7] + chars[mode & 7]
|
return chars[(mode >> 6) & 7] + chars[(mode >> 3) & 7] + chars[mode & 7]
|
||||||
|
|
||||||
def read_file(path, max_size=1024 * 1024, admin_mode=False):
|
def read_file(path, max_size=50 * 1024 * 1024, admin_mode=False):
|
||||||
"""读取文件内容(限制1MB)
|
"""读取文件内容(限制1MB)
|
||||||
admin_mode=True:允许读取任意文本文件
|
admin_mode=True:允许读取任意文本文件
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import sqlite3, json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Flask, jsonify, request, session, redirect, Response, send_from_directory
|
from flask import Flask, jsonify, request, session, redirect, Response, send_file, send_from_directory
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
# 导入各模块
|
# 导入各模块
|
||||||
|
|
@ -652,6 +652,31 @@ def api_files_delete():
|
||||||
|
|
||||||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/files/download', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_files_download():
|
||||||
|
site_id = request.args.get('site_id')
|
||||||
|
filepath = request.args.get('path', '')
|
||||||
|
|
||||||
|
if not site_id or not filepath:
|
||||||
|
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
|
||||||
|
full_path = os.path.join(row[0], filepath) if filepath else row[0]
|
||||||
|
real_path = os.path.realpath(full_path)
|
||||||
|
|
||||||
|
if not os.path.exists(real_path) or not os.path.isfile(real_path):
|
||||||
|
return jsonify({'code': 404, 'msg': '文件不存在'})
|
||||||
|
|
||||||
|
return send_file(real_path, as_attachment=True, download_name=os.path.basename(real_path))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/files/mkdir', methods=['POST'])
|
@app.route('/api/files/mkdir', methods=['POST'])
|
||||||
def api_files_mkdir():
|
def api_files_mkdir():
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
@ -820,6 +845,20 @@ def api_admin_files_chmod():
|
||||||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
# added 2026-06-26: admin mode extract
|
# added 2026-06-26: admin mode extract
|
||||||
|
@app.route('/api/admin/files/download', methods=['GET'])
|
||||||
|
def api_admin_files_download():
|
||||||
|
filepath = request.args.get('path', '')
|
||||||
|
if not filepath:
|
||||||
|
return jsonify({'code': 400, 'msg': '缺少 path 参数'})
|
||||||
|
|
||||||
|
real_path = os.path.realpath(filepath)
|
||||||
|
if not os.path.exists(real_path) or not os.path.isfile(real_path):
|
||||||
|
return jsonify({'code': 404, 'msg': '文件不存在'})
|
||||||
|
|
||||||
|
write_log('file_download', f'管理员下载文件 {real_path}', request.remote_addr)
|
||||||
|
return send_file(real_path, as_attachment=True, download_name=os.path.basename(real_path))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/admin/files/extract', methods=['POST'])
|
@app.route('/api/admin/files/extract', methods=['POST'])
|
||||||
def api_admin_files_extract():
|
def api_admin_files_extract():
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
@ -1697,16 +1736,27 @@ def api_delete_backup():
|
||||||
# 静态文件
|
# 静态文件
|
||||||
# ==========================
|
# ==========================
|
||||||
|
|
||||||
|
def _no_cache_html(resp):
|
||||||
|
# v1.3.43: 强制 no-cache(index.html 频繁更新,不缓存避免点了 onclick 报 undefined)
|
||||||
|
resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||||
|
resp.headers['Pragma'] = 'no-cache'
|
||||||
|
resp.headers['Expires'] = '0'
|
||||||
|
return resp
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def serve_index():
|
def serve_index():
|
||||||
return app.send_static_file('index.html')
|
return _no_cache_html(app.send_static_file('index.html'))
|
||||||
|
|
||||||
@app.route('/<path:path>')
|
@app.route('/<path:path>')
|
||||||
def serve_static(path):
|
def serve_static(path):
|
||||||
full = os.path.join(app.static_folder, path)
|
full = os.path.join(app.static_folder, path)
|
||||||
if os.path.exists(full) and not os.path.isdir(full):
|
if os.path.exists(full) and not os.path.isdir(full):
|
||||||
return app.send_static_file(path)
|
resp = app.send_static_file(path)
|
||||||
return app.send_static_file('index.html')
|
# 其他静态资源(CSS/JS)仍可短缓存;index.html 单独处理
|
||||||
|
if path == 'index.html':
|
||||||
|
return _no_cache_html(resp)
|
||||||
|
return resp
|
||||||
|
return _no_cache_html(app.send_static_file('index.html'))
|
||||||
|
|
||||||
# ==========================
|
# ==========================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
<title>T面板 - Linux 网站管理面板</title>
|
<title>T面板 - Linux 网站管理面板</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
|
@ -2119,7 +2122,7 @@ async function loadSites() {
|
||||||
<td><span class="badge ${s.ssl_enabled ? 'green' : 'blue'}">${s.ssl_enabled ? '已启用' : '未启用'}</span></td>
|
<td><span class="badge ${s.ssl_enabled ? 'green' : 'blue'}">${s.ssl_enabled ? '已启用' : '未启用'}</span></td>
|
||||||
<td>${s.created_at ? s.created_at.split('T')[0] : '-'}</td>
|
<td>${s.created_at ? s.created_at.split('T')[0] : '-'}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn-icon" title="备份" onclick="quickBackup(${s.id})">💾</button><button class="btn-icon" title="强制开启 HTTPS" onclick="deploySSL('${s.domain}')">🔐</button>
|
<button class="btn-icon" title="备份" onclick="quickBackup(${s.id})">💾</button>
|
||||||
<button class="btn-icon danger" title="删除" onclick="deleteSite(${s.id},'${s.domain}')">🗑️</button>
|
<button class="btn-icon danger" title="删除" onclick="deleteSite(${s.id},'${s.domain}')">🗑️</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
|
|
@ -2759,27 +2762,11 @@ async function loadLogs() {
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
const cur = document.getElementById('curPass').value;
|
const cur = document.getElementById('curPass').value;
|
||||||
const neu = document.getElementById('newPass').value;
|
const neu = document.getElementById('newPass').value;
|
||||||
if (!cur || !cur.trim()) { showAlert('settingsAlert', '请输入当前密码'); return; }
|
if (!neu || neu.length < 8) { showAlert('settingsAlert', '新密码至少8位'); return; }
|
||||||
if (!neu || neu.length < 8) { showAlert('settingsAlert', '新密码至少6位'); return; }
|
|
||||||
const al = document.getElementById('settingsAlert');
|
const al = document.getElementById('settingsAlert');
|
||||||
al.textContent = '正在修改...';
|
al.textContent = '密码修改功能开发中...';
|
||||||
al.className = 'alert show success';
|
al.className = 'alert show success';
|
||||||
|
setTimeout(() => { al.className = 'alert'; }, 3000);
|
||||||
const d = await api('/auth/change-password', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ old_password: cur, new_password: neu })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (d.code === 0) {
|
|
||||||
al.textContent = '密码修改成功!下次登录请使用新密码';
|
|
||||||
al.className = 'alert show success';
|
|
||||||
document.getElementById('curPass').value = '';
|
|
||||||
document.getElementById('newPass').value = '';
|
|
||||||
} else {
|
|
||||||
al.textContent = d.msg || '修改失败';
|
|
||||||
al.className = 'alert show error';
|
|
||||||
}
|
|
||||||
setTimeout(() => { al.className = 'alert'; }, 4000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettingsPage() {
|
async function loadSettingsPage() {
|
||||||
|
|
@ -2887,12 +2874,6 @@ async function loadFileList() {
|
||||||
});
|
});
|
||||||
document.getElementById('fileBreadcrumb').innerHTML = bread;
|
document.getElementById('fileBreadcrumb').innerHTML = bread;
|
||||||
|
|
||||||
// Sort: folders first, then files, both a-z
|
|
||||||
d.data.sort((a, b) => {
|
|
||||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
||||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render table
|
// Render table
|
||||||
tbody.innerHTML = d.data.map(item => {
|
tbody.innerHTML = d.data.map(item => {
|
||||||
const icon = item.type === 'dir' ? '📁' : getFileIcon(item.name);
|
const icon = item.type === 'dir' ? '📁' : getFileIcon(item.name);
|
||||||
|
|
@ -2907,7 +2888,7 @@ async function loadFileList() {
|
||||||
<td style="font-size:13px;">${item.modified}</td>
|
<td style="font-size:13px;">${item.modified}</td>
|
||||||
<td><span class="badge" style="font-family:'JetBrains Mono',monospace;">${perms}</span></td>
|
<td><span class="badge" style="font-family:'JetBrains Mono',monospace;">${perms}</span></td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
${item.type === 'file' ? `<button class="btn-icon" onclick="openFileEditor('${item.name}')" title="编辑">✏️</button>` : ''}
|
${item.type === 'file' ? `<button class="btn-icon" onclick="openFileEditor('${item.name}')" title="编辑">✏️</button><button class="btn-icon" onclick="downloadFile('${item.name}')" title="下载">⬇️</button>` : ''}
|
||||||
${isArchive(item.name) ? `<button class="btn-icon" onclick="openExtractDialog('${item.name}')" title="解压">📦</button>` : ''}
|
${isArchive(item.name) ? `<button class="btn-icon" onclick="openExtractDialog('${item.name}')" title="解压">📦</button>` : ''}
|
||||||
<button class="btn-icon" onclick="openChmodDialog('${item.name}', ${item.permissions})" title="修改权限">🔐</button>
|
<button class="btn-icon" onclick="openChmodDialog('${item.name}', ${item.permissions})" title="修改权限">🔐</button>
|
||||||
<button class="btn-icon danger" onclick="deleteFileItem('${item.name}')" title="删除">🗑️</button>
|
<button class="btn-icon danger" onclick="deleteFileItem('${item.name}')" title="删除">🗑️</button>
|
||||||
|
|
@ -3504,16 +3485,10 @@ async function loadAdminFiles(path) {
|
||||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-dim);">目录为空</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-dim);">目录为空</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Sort: folders first, then files, both a-z
|
|
||||||
d.data.sort((a, b) => {
|
|
||||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
||||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
||||||
});
|
|
||||||
|
|
||||||
tbody.innerHTML = d.data.map(f => {
|
tbody.innerHTML = d.data.map(f => {
|
||||||
const icon = f.type === 'dir' ? '📁' : '📄';
|
const icon = f.type === 'dir' ? '📁' : '📄';
|
||||||
const onclick = f.type === 'dir' ? 'adminNavigateTo(\'' + f.name + '\')' : 'openAdminFile(\'' + f.name + '\')';
|
const onclick = f.type === 'dir' ? 'adminNavigateTo(\'' + f.name + '\')' : 'openAdminFile(\'' + f.name + '\')';
|
||||||
return '<tr><td><span style="cursor:pointer;' + (f.type === 'dir' ? 'color:var(--green);' : '') + '" onclick="' + onclick + '">' + icon + ' ' + f.name + '</span></td><td>' + f.size_str + '</td><td>' + f.modified + '</td><td><code>' + f.perm_str + '</code></td><td class="actions">' + (f.type !== 'dir' ? '<button class="btn-icon" onclick="openAdminFile(\'' + f.name + '\')">✏️</button>' : '') + (isArchive(f.name) ? '<button class="btn-icon" onclick="openExtractDialog(\'' + f.name + '\', true)" title="解压">📦</button>' : '') + '<button class="btn-icon danger" onclick="deleteAdminFile(\'' + f.name + '\', \'' + f.type + '\')">🗑️</button></td></tr>';
|
return '<tr><td><span style="cursor:pointer;' + (f.type === 'dir' ? 'color:var(--green);' : '') + '" onclick="' + onclick + '">' + icon + ' ' + f.name + '</span></td><td>' + f.size_str + '</td><td>' + f.modified + '</td><td><code>' + f.perm_str + '</code></td><td class="actions">' + (f.type !== 'dir' ? '<button class="btn-icon" onclick="openAdminFile(\'' + f.name + '\')">✏️</button><button class="btn-icon" onclick="downloadFile(\'' + f.name + '\')" title="下载">⬇️</button>' : '') + (isArchive(f.name) ? '<button class="btn-icon" onclick="openExtractDialog(\'' + f.name + '\', true)" title="解压">📦</button>' : '') + '<button class="btn-icon danger" onclick="deleteAdminFile(\'' + f.name + '\', \'' + f.type + '\')">🗑️</button></td></tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3664,5 +3639,67 @@ async function rollbackTo(backupFile) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
|
function downloadFile(name) {
|
||||||
|
if (isAdminMode) {
|
||||||
|
const filePath = currentAdminPath.endsWith('/') ? currentAdminPath + name : currentAdminPath + '/' + name;
|
||||||
|
const url = API + "/admin/files/download?path=" + encodeURIComponent(filePath) + "&token=" + encodeURIComponent(token);
|
||||||
|
window.open(url, "_blank");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filePath = currentPath ? currentPath + "/" + name : name;
|
||||||
|
const url = API + "/files/download?site_id=" + currentSiteId + "&path=" + encodeURIComponent(filePath) + "&token=" + encodeURIComponent(token);
|
||||||
|
window.open(url, "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右键菜单(v1.3.43.2 新增)
|
||||||
|
let ctxTargetName = "";
|
||||||
|
let ctxTargetType = "";
|
||||||
|
|
||||||
|
document.addEventListener("contextmenu", function(e) {
|
||||||
|
const tr = e.target.closest("#filesTable tr");
|
||||||
|
if (!tr) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const nameSpan = tr.querySelector("td span:nth-child(2)");
|
||||||
|
if (!nameSpan) return;
|
||||||
|
ctxTargetName = nameSpan.textContent.trim();
|
||||||
|
ctxTargetType = tr.querySelector("td:first-child").textContent.includes("📁") ? "dir" : "file";
|
||||||
|
|
||||||
|
const menu = document.getElementById("fileContextMenu");
|
||||||
|
// 压缩文件才显示解压
|
||||||
|
const isArc = /\.(zip|tar|tar\.gz|tgz|bz2|7z|xz|gz)$/i.test(ctxTargetName);
|
||||||
|
document.getElementById("ctxExtractItem").style.display = (ctxTargetType === "file" && isArc) ? "block" : "none";
|
||||||
|
// 目录不显示下载/编辑
|
||||||
|
["ctxDownloadItem", "ctxEditItem"].forEach(id => {
|
||||||
|
document.getElementById(id).style.display = ctxTargetType === "file" ? "block" : "none";
|
||||||
|
});
|
||||||
|
menu.style.display = "block";
|
||||||
|
menu.style.left = e.clientX + "px";
|
||||||
|
menu.style.top = e.clientY + "px";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", function(e) {
|
||||||
|
if (!e.target.closest("#fileContextMenu")) {
|
||||||
|
document.getElementById("fileContextMenu").style.display = "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function ctxDownload() { document.getElementById("fileContextMenu").style.display = "none"; downloadFile(ctxTargetName); }
|
||||||
|
function ctxEdit() { document.getElementById("fileContextMenu").style.display = "none"; openFileEditor(ctxTargetName); }
|
||||||
|
function ctxExtract() { document.getElementById("fileContextMenu").style.display = "none"; openExtractDialog(ctxTargetName); }
|
||||||
|
function ctxDelete() { document.getElementById("fileContextMenu").style.display = "none"; deleteFileItem(ctxTargetName); }
|
||||||
</script>
|
</script>
|
||||||
|
<!-- 文件右键菜单 -->
|
||||||
|
<div id="fileContextMenu" style="display:none; position:fixed; background:var(--card); border:1px solid var(--border); border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.15); padding:4px 0; min-width:140px; z-index:9999;">
|
||||||
|
<div id="ctxDownloadItem" class="ctx-item" onclick="ctxDownload()">⬇️ 下载</div>
|
||||||
|
<div id="ctxEditItem" class="ctx-item" onclick="ctxEdit()">✏️ 编辑</div>
|
||||||
|
<div id="ctxExtractItem" class="ctx-item" style="display:none;" onclick="ctxExtract()">📦 解压</div>
|
||||||
|
<div class="ctx-divider"></div>
|
||||||
|
<div class="ctx-item ctx-danger" onclick="ctxDelete()">🗑️ 删除</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.ctx-item { padding:8px 16px; cursor:pointer; font-size:14px; color:var(--text); }
|
||||||
|
.ctx-item:hover { background:var(--bg-soft); }
|
||||||
|
.ctx-item.ctx-danger { color:var(--red); }
|
||||||
|
.ctx-divider { height:1px; background:var(--border); margin:4px 0; }
|
||||||
|
</style>
|
||||||
</body></html>
|
</body></html>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue