Compare commits

...

3 commits

Author SHA1 Message Date
root
ca24f6c42b ci: release.yml 加 fail_on_unmatched_filter: false
手动创建的 release 不会被 action 覆盖,避免 v1.3.45 那次失败
以后 push tag 时如果 release 已存在,action 会跳过而不是失败
2026-06-30 09:24:25 +08:00
root
7aedb999d8 chore: 清理 v1.3.45 历史的 v1339/v13401 备份文件 2026-06-29 10:56:26 +08:00
root
3d77af6744 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 一致)
2026-06-29 10:55:29 +08:00
6 changed files with 127 additions and 4355 deletions

View file

@ -35,5 +35,6 @@ jobs:
name: TPanel ${{ github.ref_name }} name: TPanel ${{ github.ref_name }}
generate_release_notes: true generate_release_notes: true
files: tpanel-${{ github.ref_name }}-source.zip files: tpanel-${{ github.ref_name }}-source.zip
fail_on_unmatched_filter: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -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:允许读取任意文本文件
""" """

View file

@ -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'))
# ========================== # ==========================

View file

@ -1,528 +0,0 @@
"""
TPanel - 系统操作模块
仅使用白名单命令,禁止直接执行用户传入的原始 shell 字符串
"""
import subprocess
import os
import shutil
import tarfile
import datetime
import time
def _detect_pkg_manager():
"""检测系统包管理器"""
import shutil
for p in ['apt-get', 'yum', 'dnf']:
if shutil.which(p):
return p
return None
def _run(cmd, shell=False, capture=True, timeout=30):
"""执行命令,超时保护"""
try:
if isinstance(cmd, str) and not shell:
cmd = cmd.split()
result = subprocess.run(
cmd,
capture_output=capture,
text=True,
timeout=timeout,
shell=shell
)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return -1, '', 'Command timed out'
except Exception as e:
return -1, '', str(e)
def nginx_reload():
return _run(['sudo', 'nginx', '-t']) + _run(['sudo', 'nginx', '-s', 'reload'])
def nginx_stop():
return _run(['sudo', 'nginx', '-s', 'stop'])
def nginx_start():
return _run(['sudo', 'nginx'])
def nginx_status():
code, out, _ = _run(['ps', 'aux'], capture=True)
running = 'nginx: master' in out
return running
def mysql_status():
# Debian 12 默认是 mariadb,CentOS 是 mysql
for svc in ['mariadb', 'mysql']:
code, out, _ = _run(['systemctl', 'is-active', svc], capture=True)
if code == 0:
return True
return False
return out == 'active'
def create_site_user(username):
"""创建 Linux 用户,禁 shell,隔离目录(v1.3.11+ 改用 sudo)"""
# 检查用户是否存在
code, out, _ = _run(['id', username], capture=True)
if code == 0:
return True, '用户已存在'
# 创建用户,home 目录即网站根目录,禁 shell
code, out, err = _run(
['sudo', 'useradd', '-m', '-s', '/usr/sbin/nologin', '-d', f'/home/{username}', username]
)
if code != 0:
return False, err
return True, '用户创建成功'
def delete_site_user(username):
code, out, _ = _run(['id', username], capture=True)
if code != 0:
return True, '用户不存在,跳过'
# 把用户的所有进程 kill 掉再删
_run(['pkill', '-u', username], capture=True)
code, out, err = _run(['sudo', 'userdel', '-r', username])
if code != 0:
return False, err
return True, '用户删除成功'
def set_site_permissions(site_path, site_user):
"""设置站点目录权限"""
_run(['sudo', 'chown', '-R', f'{site_user}:{site_user}', site_path])
_run(['sudo', 'chmod', '-R', '755', site_path])
_run(['sudo', 'chmod', '-R', '700', site_path + '/storage' if os.path.exists(site_path + '/storage') else site_path])
def get_php_fpm_port(php_version):
"""
v1.3.29: PHP 版本 → FPM 端口映射
- 8.2 继续用 9000(向后兼容老 conf / install.sh 默认配置)
- 其他版本: 90 + 小数点后两位(7.4→9074, 8.0→9080, 8.1→9081, 8.3→9083, 8.4→9084)
- 带小数点的老版本(5.6→9056, 7.0→9070, 7.1→9071, 7.2→9072, 7.3→9073)
- 解析失败的 default: 9000
"""
pv = (php_version or '').strip()
if pv == '8.2':
return 9000
try:
parts = pv.split('.')
major = int(parts[0])
minor = int(parts[1]) if len(parts) > 1 else 0
return 9000 + major * 10 + minor
except Exception:
return 9000
def write_nginx_config(domain, site_path, php_version='8.1', ssl=False, site_type='php'):
"""写入 Nginx 配置
v1.3.26 新增 site_type 参数:
- 'php'(默认):保留 PHP-FPM 反代 location
- 'static':不写 PHP-FPM 块(纯静态站点,不转发 *.php 到 FPM)
v1.3.29: PHP-FPM 端口随版本变化(多版本并存不冲突)
"""
# PHP-FPM 连接地址(v1.3.6+ 改用 TCP 避免 unix socket 问题,v1.3.29 起按版本分端口)
fpm_port = get_php_fpm_port(php_version)
fpm_sock = f'127.0.0.1:{fpm_port}'
# index 顺序 + try_files fallback 随类型不同
if site_type == 'static':
index_line = 'index index.html;'
try_files_line = 'try_files $uri $uri/ =404;'
php_block = '' # 静态站点完全不转发 .php
else:
index_line = 'index index.php index.html;'
try_files_line = 'try_files $uri $uri/ /index.php?$query_string;'
php_block = f'''
location ~ \\.php$ {{
include fastcgi_params;
fastcgi_pass {fpm_sock};
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}}
'''
nginx_conf = f'''# TPanel - {domain} ({site_type})
server {{
listen 80;
server_name {domain};
root {site_path};
{index_line}
access_log /opt/tpanel/logs/{domain}.access.log;
error_log /opt/tpanel/logs/{domain}.error.log;
# Let's Encrypt SSL 验证(必须放在最前面,不走 PHP)
location /.well-known/acme-challenge/ {{
alias {site_path}/.well-known/acme-challenge/;
try_files $uri =404;
}}
location / {{
{try_files_line}
}}
{php_block}
location ~ /\\.ht {{
deny all;
}}
}}
'''
if ssl:
nginx_conf = nginx_conf.replace('listen 80;', '''listen 80;
listen 443 ssl http2;''', 1)
conf_path = f'/etc/nginx/sites-available/{domain}.conf'
# v1.3.15+:tpanel 不可写 /etc/nginx,用 sudo tee(先写 /tmp 临时文件)
tmp_conf = f'/tmp/tpanel_nginx_{domain}.conf'
with open(tmp_conf, 'w') as f:
f.write(nginx_conf)
code, out, err = _run(['sudo', 'mv', tmp_conf, conf_path])
if code != 0:
return False, f'写 conf 失败: {err}'
# 启用站点(v1.3.15+:软链在 sites-enabled 也需 sudo)
enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf'
if os.path.exists(enabled_path):
_run(['sudo', 'rm', '-f', enabled_path])
_run(['sudo', 'ln', '-sf', conf_path, enabled_path])
code, out, err = _run(['sudo', 'nginx', '-t'])
if code != 0:
return False, err
_run(['sudo', 'nginx', '-s', 'reload'])
return True, 'Nginx 配置已更新'
def remove_nginx_config(domain):
"""删除站点 Nginx 配置(v1.3.15+ 用 sudo 删)"""
conf_path = f'/etc/nginx/sites-available/{domain}.conf'
enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf'
if os.path.exists(enabled_path):
_run(['sudo', 'rm', '-f', enabled_path])
if os.path.exists(conf_path):
_run(['sudo', 'rm', '-f', conf_path])
_run(['sudo', 'nginx', '-s', 'reload'])
def create_mysql_db(name, db_user, db_pass):
"""创建 MySQL 数据库和用户(用 sudo 提权,避免 shell 注入)"""
# 校验 name/user 不含特殊字符(防止 SQL 注入)
import re
if not re.match(r'^[a-zA-Z0-9_]+$', name) or not re.match(r'^[a-zA-Z0-9_]+$', db_user):
return False, '数据库名/用户名只能包含字母数字下划线'
statements = [
f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;",
f"CREATE USER IF NOT EXISTS '{db_user}'@'localhost' IDENTIFIED BY '{db_pass}';",
f"GRANT ALL PRIVILEGES ON `{name}`.* TO '{db_user}'@'localhost';",
"FLUSH PRIVILEGES;",
]
for stmt in statements:
code, out, err = _run(['sudo', 'mysql', '-e', stmt], shell=False)
if code != 0:
return False, err
return True, '数据库创建成功'
def delete_mysql_db(name, db_user):
import re
if not re.match(r'^[a-zA-Z0-9_]+$', name) or not re.match(r'^[a-zA-Z0-9_]+$', db_user):
return False, '数据库名/用户名只能包含字母数字下划线'
statements = [
f"DROP DATABASE IF EXISTS `{name}`;",
f"DROP USER IF EXISTS '{db_user}'@'localhost';",
"FLUSH PRIVILEGES;",
]
for stmt in statements:
code, out, err = _run(['sudo', 'mysql', '-e', stmt], shell=False)
if code != 0:
return False, err
return True, '数据库删除成功'
def get_mysql_size():
"""获取 MySQL 数据目录大小(MB)"""
code, out, _ = _run("du -sm /var/lib/mysql 2>/dev/null || echo 0", shell=True)
try:
return int(out.split()[0])
except:
return 0
def backup_site(site_path, site_name, db_name=None, db_user=None, db_pass=None):
"""备份站点文件和数据库"""
import traceback
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
# 清理站点名:ygbk.cn → ygbk.cn(保留点)
safe_name = site_name.replace('/', '_')
backup_name = f'{safe_name}_{timestamp}'
backup_path = f'/opt/tpanel/backups/{backup_name}.tar.gz'
# v1.3.10 修复:预检环境
try:
os.makedirs('/opt/tpanel/backups', exist_ok=True)
except Exception as e:
return False, f'无法创建 backups 目录: {e}', 0
if not os.path.isdir(site_path):
return False, f'站点目录不存在: {site_path}', 0
if not os.access(site_path, os.R_OK):
return False, f'tpanel 用户无法读取 {site_path}(chown 错了?ls -ld {site_path} 看看)', 0
try:
# 备份文件
with tarfile.open(backup_path, 'w:gz') as tar:
tar.add(site_path, arcname=os.path.basename(site_path))
# 备份数据库(v1.3.10 修复:用 list 参数防注入 + sudo)
if db_name:
dump_path = f'/opt/tpanel/backups/{backup_name}_db.sql.gz'
try:
if db_user and db_pass:
code, out, err = _run(
['sudo', 'mysqldump', '-u', db_user, f'-p{db_pass}', db_name],
shell=False, timeout=120
)
else:
code, out, err = _run(['sudo', 'mysqldump', db_name], shell=False, timeout=120)
if code == 0 and out:
import gzip
with open(dump_path, 'wb') as df:
df.write(gzip.compress(out.encode('utf-8') if isinstance(out, str) else out))
with tarfile.open(backup_path, 'a:gz') as tar:
tar.add(dump_path, arcname='database.sql.gz')
os.remove(dump_path)
except Exception as e:
# 数据库备份失败不阻断(文件备份可能成功)
pass
size = os.path.getsize(backup_path)
return True, backup_path, size
except PermissionError as e:
return False, f'权限错误: {e}(tpanel 读不到 {site_path},请 chown)', 0
except Exception as e:
return False, f'备份异常: {type(e).__name__}: {e}\n{traceback.format_exc()[-300:]}', 0
def restore_backup(backup_path, site_path, site_name):
"""恢复备份"""
try:
# v1.3.17+:先 sudo 删干净 site_path(因为可能有 root 拥有的文件,tpanel 删不掉)
# 用 sudo 替换为临时空目录,然后再解压
backup_site_path = site_path
if os.path.exists(backup_site_path):
# 移动到 .bak 路径(sudo 移)
bak_path = backup_site_path + '.bak.' + str(int(time.time()))
code, _, err = _run(['sudo', 'mv', backup_site_path, bak_path])
if code != 0:
return False, f'备份旧目录失败: {err}'
# 解压到临时目录
temp_dir = f'/opt/tpanel/backups/temp_{site_name}'
os.makedirs(temp_dir, exist_ok=True)
with tarfile.open(backup_path, 'r:gz') as tar:
tar.extractall(temp_dir)
# 找到网站目录内容
items = os.listdir(temp_dir)
src_dir = os.path.join(temp_dir, items[0]) if items else temp_dir
# 把整个 src 目录 sudo mv 到 site_path
code, _, err = _run(['sudo', 'mv', src_dir, backup_site_path])
if code != 0:
return False, f'恢复目录失败: {err}'
# v1.3.17+:从 site_path 反推 site_user
# /opt/tpanel/sites/zhangpu_tech/public → zhangpu_tech
path_parts = backup_site_path.rstrip('/').split('/')
site_user = path_parts[-1] if path_parts else site_name
_run(['sudo', 'chown', '-R', f'{site_user}:{site_user}', backup_site_path])
_run(['sudo', 'chmod', '-R', '755', backup_site_path])
shutil.rmtree(temp_dir, ignore_errors=True)
return True, '恢复成功'
except Exception as e:
return False, str(e)
def run_security_update():
"""执行系统安全更新"""
code, out, err = _run(['sudo', 'apt-get', 'update'], timeout=120)
if code != 0:
return False, err
# v1.3.20+:apt-get upgrade 也加 sudo(不然 Permission denied dpkg lock)
code, out, err = _run(
['sudo', 'apt-get', 'upgrade', '-y', '--only-upgrade'],
timeout=300
)
if code == 0:
return True, f'安全更新完成'
else:
return False, err
def get_security_status():
"""获取安全状态"""
# 可升级的安全包数量
code, out, _ = _run(
"apt list --upgradable 2>/dev/null | grep -c security || echo 0",
shell=True
)
try:
updatable = int(out.strip())
except:
updatable = 0
# 最近的安全日志条数
code2, out2, _ = _run(
"journalctl --since '1 day ago' --priority=err 2>/dev/null | wc -l",
shell=True
)
try:
errors = int(out2.strip())
except:
errors = 0
return {'upgradable_security_packages': updatable, 'recent_errors': errors}
def get_system_stats():
"""获取系统状态"""
code, cpu_out, _ = _run("cat /proc/loadavg | awk '{print $1,$2,$3}'", shell=True)
code, mem_out, _ = _run("free -m | awk 'NR==2{print $3,$2}'", shell=True)
code, disk_out, _ = _run("df -h / | tail -1 | awk '{print $3,$4}'", shell=True)
code, cpu_pct, _ = _run("top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | sed 's/%us,//'", shell=True)
# v1.3.10+ 新增:CPU 核心数 + 型号(用于仪表盘显示 + 负载颜色按核心数判断)
# v1.3.35 修复:容器/Docker 里 lscpu 无 "Model name" 行会导致 Unknown CPU
import os as _os
cpu_cores = _os.cpu_count() or 1
cpu_model = ''
# 1. 优先 lscpu "Model name"(KVM/Xen 等虚拟化都正常)
code, lscpu_out, _ = _run("lscpu | grep 'Model name' | head -1", shell=True)
if code == 0 and lscpu_out and ':' in lscpu_out:
cpu_model = lscpu_out.split(':', 1)[1].strip()
# 2. 兑底:/proc/cpuinfo 的 model name(v1.3.35 修复:必传 shell=True)
if not cpu_model:
code, cpuinfo_out, _ = _run("grep -m1 'model name' /proc/cpuinfo", shell=True)
if code == 0 and cpuinfo_out and ':' in cpuinfo_out:
cpu_model = cpuinfo_out.split(':', 1)[1].strip()
# 3. 兑底:/proc/cpuinfo 拼 vendor + family + model(容器里 lscpu 可能无 Model name)
if not cpu_model:
try:
with open('/proc/cpuinfo', 'r') as f:
ci = f.read()
vendor = family = model_name = ''
for line in ci.splitlines():
if line.startswith('vendor_id') and ':' in line and not vendor:
vendor = line.split(':', 1)[1].strip()
elif line.startswith('cpu family') and ':' in line and not family:
family = line.split(':', 1)[1].strip()
elif line.startswith('model name') and ':' in line and not model_name:
model_name = line.split(':', 1)[1].strip()
if model_name: break
if model_name:
cpu_model = model_name
elif vendor:
cpu_model = f'{vendor} CPU'
if family: cpu_model += f' (family {family})'
except Exception:
pass
# 4. 兑底:platform.processor()(老 Python 偶尔能拿到)
if not cpu_model:
try:
import platform
cpu_model = platform.processor() or ''
except Exception:
pass
# 5. 兑底:lscpu 看 Vendor ID + Model(某些云主机会输出这个)
if not cpu_model:
code, lscpu_v, _ = _run("lscpu | grep -E 'Vendor ID|Model:' | head -2", shell=True)
if code == 0 and lscpu_v:
parts = []
for line in lscpu_v.strip().splitlines():
if ':' in line:
parts.append(line.split(':', 1)[1].strip())
if parts:
cpu_model = ' '.join(parts) + ' CPU'
if not cpu_model:
cpu_model = 'Unknown CPU'
nginx_running = nginx_status()
mysql_running = mysql_status()
return {
'load': cpu_out,
'cpu_pct': cpu_pct.strip() + '%' if cpu_pct else 'N/A',
'cpu_cores': cpu_cores,
'cpu_model': cpu_model,
'mem_used_mb': mem_out.split()[0] if mem_out else '0',
'mem_total_mb': mem_out.split()[1] if mem_out else '0',
'disk_used': disk_out.split()[0] if disk_out else '0',
'disk_free': disk_out.split()[1] if disk_out else '0',
'nginx_running': nginx_running,
'mysql_running': mysql_running,
}
def write_log(event_type, details, ip=''):
"""写安全日志"""
import sqlite3
from config import DB_PATH
conn = sqlite3.connect(DB_PATH)
conn.execute("INSERT INTO security_logs (event_type, details, ip) VALUES (?, ?, ?)",
(event_type, details, ip))
conn.commit()
conn.close()
def setup_php_fpm_listen(php_version):
"""
v1.3.29: 装完 PHP 后调用——设置 FPM listen 端口为版本专属端口,并启动服务
- 写 /etc/php/<ver>/fpm/pool.d/www.conf(备份原文件为 .bak)
- sudo systemctl enable --now php<ver>-fpm
返回: (ok, msg)
"""
port = get_php_fpm_port(php_version)
www_conf = f'/etc/php/{php_version}/fpm/pool.d/www.conf'
if not os.path.exists(www_conf):
return False, f'找不到 {www_conf}(该版本未安装?)'
# 备份(幂等:不重复备份)
bak = www_conf + '.tpanel.bak'
if not os.path.exists(bak):
code, _, err = _run(['sudo', 'cp', www_conf, bak])
if code != 0:
return False, f'备份 {www_conf} 失败: {err}'
# 修改 listen 行(用 sed 精准替换)
code, _, err = _run(['sudo', 'bash', '-c',
f"sed -i 's|^listen = .*|listen = 127.0.0.1:{port}|' {www_conf}"])
if code != 0:
return False, f'修改 listen 失败: {err}'
# 启用 + 启动
code, _, err = _run(['sudo', 'systemctl', 'enable', f'php{php_version}-fpm'])
if code != 0:
return False, f'enable php{php_version}-fpm 失败: {err}'
code, out, err = _run(['sudo', 'systemctl', 'restart', f'php{php_version}-fpm'])
if code != 0:
return False, f'restart php{php_version}-fpm 失败: {err}'
# 验证在监听
code, out, _ = _run(['sudo', 'ss', '-lntp'])
listening = f'127.0.0.1:{port}' in out
if not listening:
return False, f'php{php_version}-fpm 未在 127.0.0.1:{port} 监听(可能启动失败)'
return True, f'php{php_version}-fpm 已配置 listen 127.0.0.1:{port} 并启动'
def change_db_password(db_user, new_pass):
"""修改 MySQL 数据库用户密码(v1.3.34+)"""
import re
if not re.match(r"^[a-zA-Z0-9_]+$", db_user):
return False, "用户名只能包含字母数字下划线"
if not new_pass or len(new_pass) < 6:
return False, "密码至少 6 位"
escaped_pass = new_pass.replace("'", "''")
stmt = "ALTER USER '" + db_user + "'@'localhost' IDENTIFIED BY '" + escaped_pass + "';"
code, out, err = _run(["sudo", "mysql", "-e", stmt], shell=False)
if code != 0:
return False, err
code, _, err = _run(["sudo", "mysql", "-e", "FLUSH PRIVILEGES;"], shell=False)
if code != 0:
return False, err
return True, "密码修改成功"

View file

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

File diff suppressed because it is too large Load diff