mirror of
https://github.com/zhang-pu/tpanel.git
synced 2026-10-02 16:29:29 +08:00
🔥 关键 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 一致)
1773 lines
68 KiB
Python
1773 lines
68 KiB
Python
"""
|
||
TPanel - T面板 主程序
|
||
"""
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import secrets
|
||
import bcrypt
|
||
import sqlite3, json
|
||
from datetime import datetime
|
||
from functools import wraps
|
||
|
||
from flask import Flask, jsonify, request, session, redirect, Response, send_file, send_from_directory
|
||
from flask_cors import CORS
|
||
|
||
# 导入各模块
|
||
from config import DB_PATH, load_config, save_config, set_setting, get_setting, get_panel_domain, SECRET_KEY
|
||
from system import (
|
||
nginx_status, nginx_reload, mysql_status,
|
||
create_site_user, delete_site_user,
|
||
write_nginx_config, remove_nginx_config,
|
||
create_mysql_db, delete_mysql_db,
|
||
backup_site, restore_backup,
|
||
run_security_update, get_security_status, get_system_stats, write_log,
|
||
setup_php_fpm_listen, # v1.3.29
|
||
change_db_password, # v1.3.34
|
||
get_firewall_status, firewall_enable, firewall_open_port, firewall_close_port,
|
||
get_panel_port, change_panel_port,
|
||
get_backup_settings, save_backup_settings, run_backup_now, list_backups, delete_backup,
|
||
get_current_version, check_latest_version, upgrade_from_zip, rollback_version, list_backup_versions, # v1.3.39 在线升级
|
||
get_server_info, # v1.3.43.x 服务器 IP/主机名/OS/内核/公网 IP/运行时间
|
||
)
|
||
from file_manager import list_directory, read_file, write_file, upload_file, delete_file, chmod_file, create_directory, extract_archive
|
||
from ssl_manager import get_all_certs, apply_letsencrypt, renew_cert, renew_all_expiring, deploy_ssl, check_certs_status, _get_real_site_path
|
||
from cron_manager import get_all_crons, create_cron, delete_cron, enable_cron, run_cron_now, validate_cron_expression
|
||
from remote_backup import run_remote_backup, sync_restore, test_rsync_connection, get_backup_stats
|
||
|
||
# v1.3.43+: 全局 SQLite busy_timeout 防锁死
|
||
import sqlite3 as _sqlite3
|
||
_original_connect = _sqlite3.connect
|
||
def _patched_connect(*args, **kwargs):
|
||
kwargs.setdefault("timeout", 30)
|
||
conn = _original_connect(*args, **kwargs)
|
||
try:
|
||
conn.execute("PRAGMA busy_timeout=30000")
|
||
except Exception:
|
||
pass
|
||
return conn
|
||
_sqlite3.connect = _patched_connect
|
||
app = Flask(__name__, static_folder='../frontend')
|
||
app.secret_key = secrets.token_hex(32)
|
||
CORS(app, supports_credentials=True)
|
||
|
||
# ==========================
|
||
# 域名绑定中间件
|
||
# ==========================
|
||
|
||
@app.before_request
|
||
def check_panel_domain():
|
||
"""如果面板绑定了域名,只允许该域名访问"""
|
||
allowed = get_panel_domain().strip()
|
||
import sys
|
||
print(f'[DEBUG panel_domain] allowed={allowed!r} path={request.path!r} host={request.host!r}', file=sys.stderr, flush=True)
|
||
if not allowed:
|
||
return # 未绑定,不限制
|
||
|
||
host = request.host.lower()
|
||
allowed_clean = allowed.lower().strip().split(':')[0]
|
||
host_clean = host.split(':')[0]
|
||
|
||
# v1.3.19+:安全白名单只放行 localhost 字面意思(开发用),所有 IP 走域名匹配
|
||
if host_clean in ('localhost', '::1'):
|
||
return
|
||
# 127.0.0.1 也允许(用 IP 访问后台运维用)
|
||
if host_clean == '127.0.0.1':
|
||
return
|
||
|
||
if host_clean != allowed_clean:
|
||
return jsonify({'code': 403, 'msg': f'面板已绑定域名 {allowed},请使用该域名访问'}), 403
|
||
|
||
# ==========================
|
||
# 装饰器
|
||
# ==========================
|
||
|
||
def require_auth(f):
|
||
@wraps(f)
|
||
def decorated(*args, **kwargs):
|
||
# v1.3.27 修复:SSE 走 query string 传 token(EventSource 不能自定义 header)
|
||
# 优先 header(常规 API),fallback ?token=(SSE)
|
||
token = request.headers.get('Authorization', '').replace('Bearer ', '').strip()
|
||
if not token:
|
||
token = request.args.get('token', '').strip()
|
||
expected = get_setting('api_token', '')
|
||
if not expected or token != expected:
|
||
# 也检查 session
|
||
if 'admin' not in session:
|
||
return jsonify({'code': 401, 'msg': '未授权'}), 401
|
||
return f(*args, **kwargs)
|
||
return decorated
|
||
|
||
# ==========================
|
||
# 认证 API
|
||
# ==========================
|
||
|
||
@app.route('/api/auth/login', methods=['POST'])
|
||
def api_login():
|
||
data = request.json or {}
|
||
username = data.get('username', '').strip()
|
||
password = data.get('password', '')
|
||
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT id, password_hash FROM admin WHERE username = ?", (username,))
|
||
row = cur.fetchone()
|
||
conn.close()
|
||
|
||
if not row:
|
||
return jsonify({'code': 401, 'msg': '用户名或密码错误'})
|
||
|
||
if bcrypt.checkpw(password.encode(), row[1].encode()):
|
||
session['admin'] = True
|
||
session['username'] = username
|
||
|
||
# 生成 API token
|
||
token = secrets.token_hex(32)
|
||
set_setting('api_token', token)
|
||
set_setting('last_login', datetime.now().isoformat())
|
||
|
||
write_log('login', f'用户 {username} 登录成功', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': '登录成功', 'token': token})
|
||
|
||
write_log('login_fail', f'用户 {username} 登录失败', request.remote_addr)
|
||
return jsonify({'code': 401, 'msg': '用户名或密码错误'})
|
||
@app.route('/api/auth/change-password', methods=['POST'])
|
||
@require_auth
|
||
def api_change_password():
|
||
"""v1.3.18 新增:改管理员密码"""
|
||
data = request.json or {}
|
||
old_password = data.get('old_password', '')
|
||
new_password = data.get('new_password', '')
|
||
|
||
if not old_password or not new_password:
|
||
return jsonify({'code': 400, 'msg': '请提供旧密码和新密码'})
|
||
|
||
if len(new_password) < 6:
|
||
return jsonify({'code': 400, 'msg': '新密码至少 6 位'})
|
||
|
||
username = session.get('username', 'admin')
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT password_hash FROM admin WHERE username = ?", (username,))
|
||
row = cur.fetchone()
|
||
if not row:
|
||
conn.close()
|
||
return jsonify({'code': 404, 'msg': '用户不存在'})
|
||
|
||
if not bcrypt.checkpw(old_password.encode(), row[0].encode()):
|
||
conn.close()
|
||
write_log('change_password_fail', f'用户 {username} 改密码失败(旧密码错)', request.remote_addr)
|
||
return jsonify({'code': 401, 'msg': '旧密码错误'})
|
||
|
||
new_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode()
|
||
conn.execute("UPDATE admin SET password_hash = ? WHERE username = ?", (new_hash, username))
|
||
conn.commit()
|
||
conn.close()
|
||
write_log('change_password', f'用户 {username} 改密码成功', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': '密码已修改,请重新登录'})
|
||
|
||
@app.route('/api/auth/check', methods=['GET'])
|
||
def api_check():
|
||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||
if token == get_setting('api_token', ''):
|
||
return jsonify({'code': 0, 'msg': '有效', 'username': session.get('username', 'admin')})
|
||
if 'admin' in session:
|
||
return jsonify({'code': 0, 'msg': '有效', 'username': session.get('username', 'admin')})
|
||
return jsonify({'code': 401, 'msg': '无效'}), 401
|
||
|
||
# ==========================
|
||
# 系统状态
|
||
# ==========================
|
||
|
||
@app.route('/api/system/stats', methods=['GET'])
|
||
def api_system_stats():
|
||
stats = get_system_stats()
|
||
security = get_security_status()
|
||
stats.update(security)
|
||
return jsonify({'code': 0, 'data': stats})
|
||
|
||
|
||
@app.route('/api/system/info', methods=['GET'])
|
||
def api_system_info():
|
||
info = get_server_info()
|
||
return jsonify({'code': 0, 'data': info})
|
||
|
||
# ==========================
|
||
# 站点管理
|
||
# ==========================
|
||
|
||
@app.route('/api/sites', methods=['GET'])
|
||
def api_sites_list():
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT * FROM sites ORDER BY id DESC")
|
||
cols = [d[0] for d in cur.description]
|
||
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||
conn.close()
|
||
return jsonify({'code': 0, 'data': rows})
|
||
|
||
@app.route('/api/sites', methods=['POST'])
|
||
def api_sites_create():
|
||
data = request.json or {}
|
||
domain = data.get('domain', '').strip().lower()
|
||
name = data.get('name', domain)
|
||
site_type = data.get('type', 'php') # v1.3.26 新增:'php' | 'static'
|
||
php_version = data.get('php_version', '8.2')
|
||
site_user = domain.replace('.', '_')
|
||
|
||
if not domain:
|
||
return jsonify({'code': 400, 'msg': '域名不能为空'})
|
||
|
||
if site_type not in ('php', 'static'):
|
||
return jsonify({'code': 400, 'msg': '站点类型必须是 php 或 static'})
|
||
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT id FROM sites WHERE domain = ?", (domain,))
|
||
if cur.fetchone():
|
||
conn.close()
|
||
return jsonify({'code': 400, 'msg': '站点已存在'})
|
||
|
||
site_path = f'/opt/tpanel/sites/{site_user}'
|
||
|
||
# 1. 创建 Linux 用户
|
||
ok, msg = create_site_user(site_user)
|
||
if not ok:
|
||
conn.close()
|
||
return jsonify({'code': 500, 'msg': f'创建系统用户失败: {msg}'})
|
||
|
||
# 2. 创建目录并写入默认首页
|
||
os.makedirs(site_path, exist_ok=True)
|
||
os.makedirs(f'{site_path}/public', exist_ok=True)
|
||
if site_type == 'php':
|
||
with open(f'{site_path}/public/index.php', 'w') as f:
|
||
f.write(f'<?php\n// Site: {domain}\n// Managed by TPanel\nphpinfo();\n')
|
||
else:
|
||
# v1.3.26: 静态站点生成一个可看的 index.html(不是空页)
|
||
static_html = f'''<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{domain} - 站点已就绪</title>
|
||
<style>
|
||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||
body {{ font-family: -apple-system, "Segoe UI", "PingFang SC", sans-serif;
|
||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||
color: #e2e8f0; }}
|
||
.card {{ background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
|
||
border-radius: 16px; padding: 48px 56px; max-width: 600px;
|
||
backdrop-filter: blur(10px); }}
|
||
h1 {{ font-size: 32px; margin-bottom: 12px; color: #22c55e; }}
|
||
p {{ line-height: 1.8; margin-bottom: 12px; }}
|
||
code {{ background: rgba(255,255,255,0.1); padding: 2px 8px; border-radius: 4px;
|
||
font-family: "SF Mono", monospace; font-size: 14px; color: #fbbf24; }}
|
||
.file-list {{ background: rgba(0,0,0,0.3); border-radius: 8px; padding: 16px;
|
||
margin-top: 20px; font-family: monospace; font-size: 14px; }}
|
||
.footer {{ margin-top: 24px; font-size: 13px; color: #94a3b8; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<h1>🌿 站点已就绪</h1>
|
||
<p>域名: <code>{domain}</code></p>
|
||
<p>类型: <code>静态页面</code>(不需要 PHP-FPM)</p>
|
||
<p>管理: <code>TPanel 面板</code></p>
|
||
<div class="file-list">
|
||
📁 public/<br>
|
||
📄 index.html ← 你看到的这个页面
|
||
</div>
|
||
<p class="footer">
|
||
上传你的 HTML / CSS / JS 文件到 <code>public/</code> 目录即可。<br>
|
||
Powered by <a href="https://tpanel.cn" style="color:#22c55e;text-decoration:none">TPanel</a>
|
||
</p>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
'''
|
||
with open(f'{site_path}/public/index.html', 'w') as f:
|
||
f.write(static_html)
|
||
os.makedirs(f'{site_path}/logs', exist_ok=True)
|
||
|
||
# 3. 写 Nginx 配置(v1.3.26 传 site_type 进去决定要不要 PHP-FPM 反代)
|
||
ok, msg = write_nginx_config(domain, f'{site_path}/public', php_version, ssl=False, site_type=site_type)
|
||
if not ok:
|
||
conn.close()
|
||
return jsonify({'code': 500, 'msg': f'Nginx 配置失败: {msg}'})
|
||
|
||
# 4. 写入数据库
|
||
cur.execute("""INSERT INTO sites (name, domain, site_user, site_path, php_version, status, site_type)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||
(name, domain, site_user, f'{site_path}/public', php_version, 'running', site_type))
|
||
conn.commit()
|
||
site_id = cur.lastrowid
|
||
conn.close()
|
||
|
||
write_log('site_create', f'创建站点 {domain}', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': '站点创建成功', 'data': {'id': site_id}})
|
||
|
||
@app.route('/api/sites/<int:site_id>', methods=['DELETE'])
|
||
def api_sites_delete(site_id):
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT domain, site_user, site_path FROM sites WHERE id = ?", (site_id,))
|
||
row = cur.fetchone()
|
||
if not row:
|
||
conn.close()
|
||
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||
|
||
domain, site_user, site_path = row
|
||
|
||
# 1. 删除 Nginx 配置
|
||
remove_nginx_config(domain)
|
||
|
||
# 2. 删除系统用户和目录
|
||
delete_site_user(site_user)
|
||
import shutil
|
||
parent_path = os.path.dirname(site_path)
|
||
if os.path.exists(os.path.join(parent_path, site_user)):
|
||
shutil.rmtree(os.path.join(parent_path, site_user), ignore_errors=True)
|
||
|
||
# 3. 删除数据库
|
||
cur2 = conn.execute("SELECT name, db_user FROM databases WHERE site_id = ?", (site_id,))
|
||
for db_row in cur2.fetchall():
|
||
delete_mysql_db(db_row[0], db_row[1])
|
||
|
||
# 4. 删除站点记录
|
||
conn.execute("DELETE FROM sites WHERE id = ?", (site_id,))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
write_log('site_delete', f'删除站点 {domain}', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': '站点已删除'})
|
||
|
||
@app.route('/api/sites/<int:site_id>/start', methods=['POST'])
|
||
def api_sites_start(site_id):
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT domain FROM sites WHERE id = ?", (site_id,))
|
||
row = cur.fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||
_, msg = nginx_reload()
|
||
return jsonify({'code': 0, 'msg': msg or '已启动'})
|
||
|
||
@app.route('/api/sites/<int:site_id>/stop', methods=['POST'])
|
||
def api_sites_stop(site_id):
|
||
return jsonify({'code': 0, 'msg': '停止站点需要 reload nginx,建议通过 Nginx 命令管理'})
|
||
|
||
@app.route('/api/sites/<int:site_id>/ssl', methods=['POST'])
|
||
def api_sites_ssl(site_id):
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT domain, site_path FROM sites WHERE id = ?", (site_id,))
|
||
row = cur.fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||
domain, site_path = row
|
||
return jsonify({'code': 0, 'msg': 'SSL 功能开发中,请手动配置 certbot'})
|
||
|
||
# ==========================
|
||
# 数据库
|
||
# ==========================
|
||
|
||
@app.route('/api/databases', methods=['GET'])
|
||
def api_databases_list():
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT * FROM databases ORDER BY id DESC")
|
||
cols = [d[0] for d in cur.description]
|
||
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||
conn.close()
|
||
return jsonify({'code': 0, 'data': rows})
|
||
|
||
@app.route('/api/databases', methods=['POST'])
|
||
def api_databases_create():
|
||
data = request.json or {}
|
||
site_id = data.get('site_id')
|
||
db_name = data.get('name', '').strip()
|
||
db_user = data.get('user', '').strip()
|
||
db_pass = data.get('pass', '')
|
||
|
||
if not all([site_id, db_name, db_user, db_pass]):
|
||
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||
|
||
ok, msg = create_mysql_db(db_name, db_user, db_pass)
|
||
if not ok:
|
||
return jsonify({'code': 500, 'msg': msg})
|
||
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("""INSERT INTO databases (site_id, name, db_user, db_pass) VALUES (?, ?, ?, ?)""",
|
||
(site_id, db_name, db_user, db_pass))
|
||
conn.commit()
|
||
db_id = cur.lastrowid
|
||
conn.close()
|
||
|
||
write_log('db_create', f'创建数据库 {db_name}', request.remote_addr)
|
||
_sync_pma_bridge(db_id, db_name, db_pass)
|
||
return jsonify({'code': 0, 'msg': '数据库创建成功', 'data': {'id': db_id}})
|
||
|
||
@app.route('/api/databases/<int:db_id>', methods=['DELETE'])
|
||
def api_databases_delete(db_id):
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT name, db_user FROM databases WHERE id = ?", (db_id,))
|
||
row = cur.fetchone()
|
||
if not row:
|
||
conn.close()
|
||
return jsonify({'code': 404, 'msg': '数据库不存在'})
|
||
delete_mysql_db(row[0], row[1])
|
||
conn.execute("DELETE FROM databases WHERE id = ?", (db_id,))
|
||
conn.commit()
|
||
conn.close()
|
||
write_log('db_delete', f'删除数据库 {row[0]}', request.remote_addr)
|
||
_sync_pma_bridge(db_id, row[0], row[1])
|
||
return jsonify({'code': 0, 'msg': '数据库已删除'})
|
||
|
||
# ==========================
|
||
# 备份
|
||
# ==========================
|
||
|
||
@app.route('/api/backups', methods=['GET'])
|
||
def api_backups_list():
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("""SELECT b.*, s.domain FROM backups b
|
||
LEFT JOIN sites s ON b.site_id = s.id
|
||
ORDER BY b.id DESC LIMIT 50""")
|
||
cols = [d[0] for d in cur.description]
|
||
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||
conn.close()
|
||
return jsonify({'code': 0, 'data': rows})
|
||
|
||
@app.route('/api/backups', methods=['POST'])
|
||
def api_backups_create():
|
||
data = request.json or {}
|
||
site_id = data.get('site_id')
|
||
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT site_path, domain FROM sites WHERE id = ?", (site_id,))
|
||
row = cur.fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||
|
||
site_path, domain = row
|
||
|
||
# 查找该站点的数据库
|
||
conn2 = sqlite3.connect(DB_PATH)
|
||
cur2 = conn2.execute("SELECT name, db_user, db_pass FROM databases WHERE site_id = ?", (site_id,))
|
||
db_row = cur2.fetchone()
|
||
conn2.close()
|
||
|
||
db_name, db_user, db_pass = (db_row if db_row else (None, None, None))
|
||
|
||
ok, path, size = backup_site(site_path, domain, db_name, db_user, db_pass)
|
||
if not ok:
|
||
return jsonify({'code': 500, 'msg': f'备份失败: {path}'})
|
||
|
||
conn3 = sqlite3.connect(DB_PATH)
|
||
cur3 = conn3.execute("INSERT INTO backups (site_id, type, file_path, size, status) VALUES (?, ?, ?, ?, ?)",
|
||
(site_id, 'local', path, size, 'success'))
|
||
conn3.commit()
|
||
backup_id = cur3.lastrowid
|
||
conn3.close()
|
||
|
||
write_log('backup', f'备份站点 {domain}', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': '备份成功', 'data': {'id': backup_id, 'path': path, 'size': size}})
|
||
|
||
@app.route('/api/backups/<int:backup_id>/restore', methods=['POST'])
|
||
def api_backups_restore(backup_id):
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT file_path, site_id FROM backups WHERE id = ?", (backup_id,))
|
||
row = cur.fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return jsonify({'code': 404, 'msg': '备份不存在'})
|
||
|
||
file_path, site_id = row
|
||
|
||
conn2 = sqlite3.connect(DB_PATH)
|
||
cur2 = conn2.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,))
|
||
site_row = cur2.fetchone()
|
||
conn2.close()
|
||
|
||
if not site_row:
|
||
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||
|
||
ok, msg = restore_backup(file_path, site_row[0], str(site_id))
|
||
if not ok:
|
||
return jsonify({'code': 500, 'msg': msg})
|
||
|
||
write_log('restore', f'恢复备份 {file_path}', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': '恢复成功'})
|
||
|
||
@app.route('/api/backups/<int:backup_id>/download')
|
||
def api_backups_download(backup_id):
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT file_path FROM backups WHERE id = ?", (backup_id,))
|
||
row = cur.fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return jsonify({'code': 404, 'msg': '备份不存在'}), 404
|
||
|
||
file_path = row[0]
|
||
if not os.path.exists(file_path):
|
||
return jsonify({'code': 404, 'msg': '备份文件不存在'}), 404
|
||
|
||
write_log('backup', f'下载备份 {file_path}', request.remote_addr)
|
||
return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path), as_attachment=True)
|
||
|
||
# ==========================
|
||
# 安全
|
||
# ==========================
|
||
|
||
@app.route('/api/security/logs', methods=['GET'])
|
||
def api_security_logs():
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT * FROM security_logs ORDER BY id DESC LIMIT 100")
|
||
cols = [d[0] for d in cur.description]
|
||
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||
conn.close()
|
||
return jsonify({'code': 0, 'data': rows})
|
||
|
||
@app.route('/api/security/status', methods=['GET'])
|
||
def api_security_status():
|
||
status = get_security_status()
|
||
return jsonify({'code': 0, 'data': status})
|
||
|
||
# ==========================
|
||
# 文件管理
|
||
# ==========================
|
||
|
||
@app.route('/api/files/list', methods=['GET'])
|
||
def api_files_list():
|
||
site_id = request.args.get('site_id', type=int)
|
||
path = request.args.get('path', '')
|
||
|
||
if not site_id:
|
||
return jsonify({'code': 400, 'msg': '缺少 site_id'})
|
||
|
||
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': '站点不存在'})
|
||
|
||
base_path = row[0]
|
||
if path:
|
||
target_path = os.path.join(base_path, path)
|
||
else:
|
||
target_path = base_path
|
||
|
||
items, err = list_directory(target_path)
|
||
if err:
|
||
return jsonify({'code': 400, 'msg': err})
|
||
|
||
return jsonify({'code': 0, 'data': items, 'base_path': base_path})
|
||
|
||
@app.route('/api/files/read', methods=['GET'])
|
||
def api_files_read():
|
||
site_id = request.args.get('site_id', type=int)
|
||
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)
|
||
content, err = read_file(full_path)
|
||
if err:
|
||
return jsonify({'code': 400, 'msg': err})
|
||
|
||
return jsonify({'code': 0, 'data': content})
|
||
|
||
@app.route('/api/files/write', methods=['POST'])
|
||
def api_files_write():
|
||
data = request.json or {}
|
||
site_id = data.get('site_id')
|
||
filepath = data.get('path', '')
|
||
content = data.get('content', '')
|
||
|
||
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)
|
||
ok, msg = write_file(full_path, content)
|
||
if ok:
|
||
write_log('file_edit', f'编辑文件 {filepath}', request.remote_addr)
|
||
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/files/upload', methods=['POST'])
|
||
def api_files_upload():
|
||
site_id = request.form.get('site_id', type=int)
|
||
path = request.form.get('path', '')
|
||
file = request.files.get('file')
|
||
|
||
if not site_id or not file:
|
||
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': '站点不存在'})
|
||
|
||
upload_dir = os.path.join(row[0], path) if path else row[0]
|
||
ok, msg = upload_file(upload_dir, file, file.filename)
|
||
if ok:
|
||
write_log('file_upload', f'上传文件 {file.filename}', request.remote_addr)
|
||
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/files/delete', methods=['POST'])
|
||
def api_files_delete():
|
||
data = request.json or {}
|
||
site_id = data.get('site_id')
|
||
filepath = data.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)
|
||
ok, msg = delete_file(full_path)
|
||
if ok:
|
||
write_log('file_delete', f'删除文件 {filepath}', request.remote_addr)
|
||
|
||
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'])
|
||
def api_files_mkdir():
|
||
data = request.json or {}
|
||
site_id = data.get('site_id')
|
||
dirpath = data.get('path', '')
|
||
dirname = data.get('name', '')
|
||
|
||
if not site_id or not dirname:
|
||
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], dirpath) if dirpath else row[0]
|
||
ok, msg = create_directory(full_path, dirname)
|
||
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/files/chmod', methods=['POST'])
|
||
def api_files_chmod():
|
||
data = request.json or {}
|
||
site_id = data.get('site_id')
|
||
filepath = data.get('path', '')
|
||
mode = data.get('mode', 0)
|
||
|
||
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)
|
||
ok, msg = chmod_file(full_path, mode)
|
||
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
# ==========================
|
||
# v1.3.41+ 文件解压
|
||
# ==========================
|
||
@app.route('/api/files/extract', methods=['POST'])
|
||
def api_files_extract():
|
||
data = request.json or {}
|
||
site_id = data.get('site_id')
|
||
archive_path = data.get('path', '') # 压缩包相对站点根的路径
|
||
delete_after = data.get('delete_after', False)
|
||
|
||
if not site_id or not archive_path:
|
||
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_archive = os.path.join(row[0], archive_path)
|
||
target_dir = os.path.dirname(full_archive) # 解压到压缩包所在目录
|
||
|
||
ok, msg = extract_archive(full_archive, target_dir, delete_after)
|
||
if ok:
|
||
write_log('file_extract', f'解压 {archive_path}', request.remote_addr)
|
||
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
# ==========================
|
||
# v1.3.39+ 任意目录浏览(管理员模式)
|
||
# ==========================
|
||
|
||
@app.route('/api/admin/files/list', methods=['GET'])
|
||
def api_admin_files_list():
|
||
path = request.args.get('path', '/')
|
||
# 安全检查:规范化路径
|
||
real_path = os.path.realpath(path)
|
||
if not real_path.startswith('/'):
|
||
return jsonify({'code': 400, 'msg': '路径格式错误'})
|
||
|
||
items, err = list_directory(real_path, admin_mode=True)
|
||
if err:
|
||
return jsonify({'code': 400, 'msg': err})
|
||
|
||
return jsonify({'code': 0, 'data': items, 'current_path': real_path})
|
||
|
||
@app.route('/api/admin/files/read', methods=['GET'])
|
||
def api_admin_files_read():
|
||
filepath = request.args.get('path', '')
|
||
if not filepath:
|
||
return jsonify({'code': 400, 'msg': '缺少 path 参数'})
|
||
|
||
real_path = os.path.realpath(filepath)
|
||
content, err = read_file(real_path, admin_mode=True)
|
||
if err:
|
||
return jsonify({'code': 400, 'msg': err})
|
||
|
||
return jsonify({'code': 0, 'data': content, 'path': real_path})
|
||
|
||
@app.route('/api/admin/files/write', methods=['POST'])
|
||
def api_admin_files_write():
|
||
data = request.json or {}
|
||
filepath = data.get('path', '')
|
||
content = data.get('content', '')
|
||
|
||
if not filepath:
|
||
return jsonify({'code': 400, 'msg': '缺少 path 参数'})
|
||
|
||
real_path = os.path.realpath(filepath)
|
||
ok, msg = write_file(real_path, content, admin_mode=True)
|
||
if ok:
|
||
write_log('file_edit', f'管理员编辑文件 {real_path}', request.remote_addr)
|
||
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/admin/files/delete', methods=['POST'])
|
||
def api_admin_files_delete():
|
||
data = request.json or {}
|
||
filepath = data.get('path', '')
|
||
|
||
if not filepath:
|
||
return jsonify({'code': 400, 'msg': '缺少 path 参数'})
|
||
|
||
real_path = os.path.realpath(filepath)
|
||
ok, msg = delete_file(real_path, admin_mode=True)
|
||
if ok:
|
||
write_log('file_delete', f'管理员删除文件 {real_path}', request.remote_addr)
|
||
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/admin/files/mkdir', methods=['POST'])
|
||
def api_admin_files_mkdir():
|
||
data = request.json or {}
|
||
path = data.get('path', '')
|
||
dirname = data.get('name', '')
|
||
|
||
if not path or not dirname:
|
||
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||
|
||
real_path = os.path.realpath(path)
|
||
ok, msg = create_directory(real_path, dirname, admin_mode=True)
|
||
if ok:
|
||
write_log('file_mkdir', f'管理员创建目录 {real_path}/{dirname}', request.remote_addr)
|
||
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/admin/files/chmod', methods=['POST'])
|
||
def api_admin_files_chmod():
|
||
data = request.json or {}
|
||
filepath = data.get('path', '')
|
||
mode = data.get('mode', 0)
|
||
|
||
if not filepath:
|
||
return jsonify({'code': 400, 'msg': '缺少 path 参数'})
|
||
|
||
real_path = os.path.realpath(filepath)
|
||
ok, msg = chmod_file(real_path, mode, admin_mode=True)
|
||
if ok:
|
||
write_log('file_chmod', f'管理员修改权限 {real_path} → {mode}', request.remote_addr)
|
||
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
# 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'])
|
||
def api_admin_files_extract():
|
||
data = request.json or {}
|
||
archive_path = data.get('path', '')
|
||
target_dir = data.get('target_dir', '') # 可选,默认解压到压缩包所在目录
|
||
delete_after = data.get('delete_after', False)
|
||
|
||
if not archive_path:
|
||
return jsonify({'code': 400, 'msg': '缺少 path 参数'})
|
||
|
||
real_archive = os.path.realpath(archive_path)
|
||
real_target = os.path.realpath(target_dir) if target_dir else os.path.dirname(real_archive)
|
||
|
||
ok, msg = extract_archive(real_archive, real_target, delete_after, admin_mode=True)
|
||
if ok:
|
||
write_log('file_extract', f'管理员解压 {real_archive} -> {real_target}', request.remote_addr)
|
||
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
# ==========================
|
||
# v1.3.39+ 在线升级
|
||
# ==========================
|
||
|
||
@app.route('/api/upgrade/version', methods=['GET'])
|
||
def api_upgrade_version():
|
||
info = check_latest_version()
|
||
backups = list_backup_versions()
|
||
return jsonify({'code': 0, 'data': info, 'backups': backups})
|
||
|
||
@app.route('/api/upgrade/upload', methods=['POST'])
|
||
def api_upgrade_upload():
|
||
"""上传升级包"""
|
||
if 'file' not in request.files:
|
||
return jsonify({'code': 400, 'msg': '未上传文件'})
|
||
|
||
f = request.files['file']
|
||
if not f.filename.endswith('.zip'):
|
||
return jsonify({'code': 400, 'msg': '只支持 zip 格式安装包'})
|
||
|
||
upload_path = '/tmp/tpanel_upgrade.zip'
|
||
f.save(upload_path)
|
||
|
||
# 验证包格式
|
||
import zipfile
|
||
try:
|
||
with zipfile.ZipFile(upload_path, 'r') as zf:
|
||
names = zf.namelist()
|
||
has_backend = any('backend/' in n for n in names)
|
||
has_frontend = any('frontend/' in n for n in names)
|
||
if not has_backend or not has_frontend:
|
||
os.remove(upload_path)
|
||
return jsonify({'code': 400, 'msg': '安装包格式错误:缺少 backend 或 frontend 目录'})
|
||
except Exception as e:
|
||
os.remove(upload_path)
|
||
return jsonify({'code': 400, 'msg': f'安装包损坏: {str(e)}'})
|
||
|
||
write_log('upgrade', f'上传升级包: {f.filename}', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': '安装包已上传,可以开始升级', 'path': upload_path})
|
||
|
||
@app.route('/api/upgrade/run', methods=['POST'])
|
||
def api_upgrade_run():
|
||
"""执行升级"""
|
||
zip_path = '/tmp/tpanel_upgrade.zip'
|
||
if not os.path.exists(zip_path):
|
||
return jsonify({'code': 400, 'msg': '请先上传安装包'})
|
||
|
||
write_log('upgrade', '开始执行升级...', request.remote_addr)
|
||
ok, msg = upgrade_from_zip(zip_path)
|
||
|
||
if ok:
|
||
write_log('upgrade', f'升级成功: {msg}', request.remote_addr)
|
||
# 升级成功后延迟重启(让API先返回结果)
|
||
_run(['sudo', 'systemctl', 'restart', 'tpanel'], timeout=10)
|
||
else:
|
||
write_log('upgrade', f'升级失败: {msg}', request.remote_addr)
|
||
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/upgrade/rollback', methods=['POST'])
|
||
def api_upgrade_rollback():
|
||
"""回滚到指定备份版本"""
|
||
data = request.json or {}
|
||
backup_file = data.get('backup_file', '')
|
||
|
||
if not backup_file or not os.path.exists(backup_file):
|
||
return jsonify({'code': 400, 'msg': '备份文件不存在'})
|
||
|
||
write_log('upgrade', f'开始回滚到: {backup_file}', request.remote_addr)
|
||
ok, msg = rollback_version(backup_file)
|
||
|
||
if ok:
|
||
write_log('upgrade', f'回滚成功: {msg}', request.remote_addr)
|
||
_run(['sudo', 'systemctl', 'restart', 'tpanel'], timeout=10)
|
||
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
# ==========================
|
||
# SSL 证书
|
||
# ==========================
|
||
|
||
@app.route('/api/ssl/certs', methods=['GET'])
|
||
def api_ssl_list():
|
||
certs = get_all_certs()
|
||
status = check_certs_status()
|
||
return jsonify({'code': 0, 'data': certs, 'status': status})
|
||
|
||
@app.route('/api/ssl/apply', methods=['POST'])
|
||
def api_ssl_apply():
|
||
"""
|
||
v1.3.25 改:申请 SSL 走任务流(不再同步等 certbot,可能耗时 1-3 分钟)
|
||
任务类型: ssl_apply,target: <domain>
|
||
完成后用 on_complete 钩子自动部署 SSL
|
||
"""
|
||
data = request.json or {}
|
||
site_id = data.get('site_id')
|
||
domain = data.get('domain', '').strip()
|
||
|
||
# v1.3.10 修复:原 bug 是 if not site_id: skip → domain 永远空字符串
|
||
if not domain and site_id:
|
||
# 通过 site_id 反查域名
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT domain FROM sites WHERE id = ?", (site_id,))
|
||
row = cur.fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||
domain = row[0]
|
||
elif not domain and not site_id:
|
||
return jsonify({'code': 400, 'msg': 'site_id 和 domain 至少传一个'})
|
||
|
||
if not domain:
|
||
return jsonify({'code': 400, 'msg': '域名不能为空'})
|
||
|
||
# 查重:同一个域名不能同时跑两个任务
|
||
existing = get_running_task_by_type('ssl_apply', domain)
|
||
if existing:
|
||
return jsonify({'code': 400, 'msg': f'该域名证书申请正在进行中(任务 ID {existing})', 'data': {'task_id': existing}})
|
||
|
||
# 构造 certbot 命令 + 后续 deploy 命令(连入一个 shell 脚本,由任务流串行执行)
|
||
# 这样任务失败也能看到 deploy 不会跑的日志
|
||
real_site_path = None
|
||
try:
|
||
real_site_path = _get_real_site_path(domain, site_id)
|
||
except Exception as e:
|
||
print(f'[ssl_apply] _get_real_site_path error: {e}', flush=True)
|
||
|
||
if not real_site_path:
|
||
return jsonify({'code': 400, 'msg': f'找不到站点 {domain} 的真实路径,请确认站点已创建'})
|
||
|
||
# 任务命令: certbot 申请 → 如果成功调 deploy_ssl
|
||
cmd = [
|
||
'bash', '-c',
|
||
f'''
|
||
set -e
|
||
echo "[1/3] 准备 .well-known 验证目录..."
|
||
mkdir -p {real_site_path}/.well-known/acme-challenge
|
||
|
||
echo "[2/3] 调用 certbot 申请证书(以 webroot 模式,需要 30-60s)..."
|
||
sudo certbot certonly --webroot -w {real_site_path} -d {domain} --agree-tos --non-interactive --email admin@{domain}
|
||
|
||
echo "[3/3] 证书生成成功,部署到 nginx..."
|
||
'''
|
||
]
|
||
|
||
def _on_ssl_done(task_id, status):
|
||
# v1.3.34 修复:on_complete 钩子在后台线程跑,没有 Flask request context
|
||
# 不能用 request.remote_addr,传 None 给 write_log
|
||
if status == 'success':
|
||
try:
|
||
ok, msg = deploy_ssl(domain)
|
||
if ok:
|
||
write_log('ssl_apply', f'申请+部署 SSL 证书 {domain} 成功', None)
|
||
else:
|
||
write_log('ssl_apply', f'证书已申请但部署 nginx 失败 {domain}: {msg}', None)
|
||
except Exception as e:
|
||
write_log('ssl_apply', f'证书部署异常 {domain}: {e}', None)
|
||
|
||
task_id = create_task('ssl_apply', domain, cmd, on_complete=_on_ssl_done)
|
||
write_log('ssl_apply', f'启动 SSL 申请任务 {domain}(task_id={task_id})', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': f'证书申请任务已启动(task_id={task_id}),请查看进度', 'data': {'task_id': task_id}})
|
||
|
||
@app.route('/api/ssl/renew/<int:cert_id>', methods=['POST'])
|
||
def api_ssl_renew(cert_id):
|
||
ok, msg = renew_cert(cert_id=cert_id)
|
||
if ok:
|
||
write_log('ssl_renew', f'续期证书 ID {cert_id}', request.remote_addr)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/ssl/renew-all', methods=['POST'])
|
||
def api_ssl_renew_all():
|
||
success, fail_count, fail_list = renew_all_expiring(days_before=30)
|
||
msg = f'续期完成:成功 {success} 个'
|
||
if fail_count > 0:
|
||
msg += f',失败 {fail_count} 个:{"; ".join(fail_list)}'
|
||
write_log('ssl_renew_all', msg, request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': msg, 'success': success, 'failed': fail_count})
|
||
|
||
@app.route('/api/ssl/deploy/<domain>', methods=['POST'])
|
||
def api_ssl_deploy(domain):
|
||
ok, msg = deploy_ssl(domain)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
# ==========================
|
||
# 定时任务(安全自动更新 + SSL 续期)
|
||
# ==========================
|
||
|
||
@app.route('/api/cron/run', methods=['POST'])
|
||
def api_cron_run():
|
||
"""v1.3.20+:手动触发定时任务走任务流(不阻塞 HTTP)"""
|
||
# 检查是否已在跑
|
||
existing = get_running_task_by_type('security_update')
|
||
if existing:
|
||
return jsonify({'code': 400, 'msg': f'更新任务正在进行(ID {existing})', 'data': {'task_id': existing}})
|
||
|
||
# 安全更新走任务流(跟 /api/security/update 一样)
|
||
cmd = ['sudo', 'apt-get', 'update', '-y', '-q']
|
||
task_id = create_task('security_update', 'system', cmd)
|
||
write_log('cron_run', f'启动手动安全更新任务(task_id={task_id})', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': f'安全更新任务已启动(task_id={task_id}),请去任务流查看进度', 'data': {'task_id': task_id}})
|
||
|
||
|
||
|
||
# ==========================
|
||
# 定时任务 API
|
||
# ==========================
|
||
|
||
@app.route('/api/cron/jobs', methods=['GET'])
|
||
def api_cron_list():
|
||
jobs = get_all_crons()
|
||
return jsonify({'code': 0, 'data': jobs})
|
||
|
||
@app.route('/api/cron/jobs', methods=['POST'])
|
||
def api_cron_create():
|
||
data = request.json or {}
|
||
site_id = data.get('site_id')
|
||
name = data.get('name', '').strip()
|
||
schedule = data.get('schedule', '').strip()
|
||
command = data.get('command', '').strip()
|
||
|
||
if not all([site_id, name, schedule, command]):
|
||
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||
|
||
# 验证 cron 表达式
|
||
ok, err = validate_cron_expression(schedule)
|
||
if not ok:
|
||
return jsonify({'code': 400, 'msg': f'Cron 格式错误: {err}'})
|
||
|
||
cron_id, msg = create_cron(site_id, name, schedule, command)
|
||
if cron_id:
|
||
write_log('cron_create', f'创建定时任务 {name} ({schedule})', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': msg, 'data': {'id': cron_id}})
|
||
else:
|
||
return jsonify({'code': 400, 'msg': msg})
|
||
|
||
@app.route('/api/cron/jobs/<int:cron_id>', methods=['DELETE'])
|
||
def api_cron_delete(cron_id):
|
||
ok, msg = delete_cron(cron_id)
|
||
if ok:
|
||
write_log('cron_delete', f'删除定时任务 ID {cron_id}', request.remote_addr)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/cron/jobs/<int:cron_id>/toggle', methods=['POST'])
|
||
def api_cron_toggle(cron_id):
|
||
data = request.json or {}
|
||
enabled = data.get('enabled', True)
|
||
ok, msg = enable_cron(cron_id, enabled)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/cron/jobs/<int:cron_id>/run', methods=['POST'])
|
||
def api_cron_run_now(cron_id):
|
||
ok, msg = run_cron_now(cron_id)
|
||
if ok:
|
||
write_log('cron_run_now', f'手动执行定时任务 ID {cron_id}', request.remote_addr)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
# ==========================
|
||
# 远程备份 API
|
||
# ==========================
|
||
|
||
@app.route('/api/backups/remote', methods=['POST'])
|
||
def api_remote_backup():
|
||
data = request.json or {}
|
||
site_id = data.get('site_id')
|
||
remote_host = data.get('remote_host', '').strip()
|
||
remote_user = data.get('remote_user', '').strip()
|
||
remote_port = data.get('remote_port', 22)
|
||
remote_path = data.get('remote_path', '').strip()
|
||
key_path = data.get('key_path', '').strip()
|
||
|
||
if not all([site_id, remote_host, remote_user, remote_path]):
|
||
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||
|
||
ok, msg = run_remote_backup(
|
||
site_id, remote_host, remote_user, remote_port,
|
||
remote_path, key_path=key_path if key_path else None
|
||
)
|
||
if ok:
|
||
return jsonify({'code': 0, 'msg': msg})
|
||
else:
|
||
return jsonify({'code': 400, 'msg': msg})
|
||
|
||
@app.route('/api/backups/remote/restore/<int:backup_id>', methods=['POST'])
|
||
def api_remote_restore(backup_id):
|
||
data = request.json or {}
|
||
remote_host = data.get('remote_host', '').strip()
|
||
remote_user = data.get('remote_user', '').strip()
|
||
remote_port = data.get('remote_port', 22)
|
||
remote_path = data.get('remote_path', '').strip()
|
||
key_path = data.get('key_path', '').strip()
|
||
|
||
if not all([remote_host, remote_user, remote_path]):
|
||
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||
|
||
ok, msg = sync_restore(
|
||
backup_id, remote_host, remote_user, remote_port,
|
||
remote_path, key_path=key_path if key_path else None
|
||
)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/backups/remote/test', methods=['POST'])
|
||
def api_test_rsync():
|
||
data = request.json or {}
|
||
host = data.get('host', '').strip()
|
||
port = data.get('port', 22)
|
||
user = data.get('user', '').strip()
|
||
key_path = data.get('key_path', '').strip()
|
||
|
||
if not host or not user:
|
||
return jsonify({'code': 400, 'msg': '主机和用户名不能为空'})
|
||
|
||
ok, msg = test_rsync_connection(host, port, user, key_path if key_path else None)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/backups/stats', methods=['GET'])
|
||
def api_backup_stats():
|
||
stats = get_backup_stats()
|
||
return jsonify({'code': 0, 'data': stats})
|
||
|
||
# ==========================
|
||
# 设置
|
||
# ==========================
|
||
|
||
|
||
@app.route('/api/settings', methods=['GET'])
|
||
def api_settings_get():
|
||
cfg = load_config()
|
||
return jsonify({'code': 0, 'data': cfg})
|
||
|
||
@app.route('/api/settings', methods=['PUT'])
|
||
def api_settings_put():
|
||
"""v1.3.19+:修双重存储 bug——既写 tpanel.conf 又同步 sqlite settings"""
|
||
data = request.json or {}
|
||
allowed_keys = [
|
||
'panel_domain', 'default_php', 'backup_retention_days',
|
||
'auto_ssl_renew', 'security_auto_update', 'firewall_enabled'
|
||
]
|
||
cfg = load_config()
|
||
cfg.update({k: v for k, v in data.items() if k in allowed_keys})
|
||
save_config(cfg)
|
||
|
||
# 关键修复:把要进 check_panel_domain / get_panel_domain 的字段也写 sqlite settings
|
||
# 否则 get_panel_domain() 永远从 sqlite 读到旧值
|
||
if 'panel_domain' in data:
|
||
set_setting('panel_domain', data['panel_domain'] or '')
|
||
# 其他字段也同步(保证 sqlite 跟 conf 一致)
|
||
for k in allowed_keys:
|
||
if k in data:
|
||
v = data[k]
|
||
if isinstance(v, bool):
|
||
v = '1' if v else '0'
|
||
elif v is None:
|
||
v = ''
|
||
set_setting(k, str(v))
|
||
|
||
return jsonify({'code': 0, 'msg': '设置已保存'})
|
||
|
||
# ==========================
|
||
# 软件市场 + 任务管理(v1.3.10 新增)
|
||
# ==========================
|
||
from task_manager import (
|
||
list_software, get_software, get_apt_packages,
|
||
create_task, get_task, get_running_task_by_type,
|
||
init_software_table, get_apt_cmd,
|
||
setup_phpmyadmin_nginx
|
||
)
|
||
import json
|
||
import time
|
||
|
||
@app.route('/api/software/list', methods=['GET'])
|
||
def api_software_list():
|
||
# 支持 ?refresh=1 强制刷新,安装/卸载后调用
|
||
force_refresh = request.args.get('refresh') == '1'
|
||
return jsonify({'code': 0, 'data': list_software(force_refresh=force_refresh)})
|
||
|
||
@app.route('/api/software/install/<name>', methods=['POST'])
|
||
def api_software_install(name):
|
||
sw = get_software(name)
|
||
if not sw:
|
||
return jsonify({'code': 404, 'msg': '软件不在白名单'})
|
||
if sw['installed']:
|
||
return jsonify({'code': 400, 'msg': f'{sw["display_name"]} 已经安装了'})
|
||
# 防止并发装同一个
|
||
existing = get_running_task_by_type('software_install', name)
|
||
if existing:
|
||
return jsonify({'code': 400, 'msg': f'该软件正在安装中(任务 ID {existing})', 'data': {'task_id': existing}})
|
||
pkgs = get_apt_packages(name)
|
||
if not pkgs:
|
||
return jsonify({'code': 500, 'msg': '未找到该软件包名'})
|
||
pkg_list = pkgs.split(',')
|
||
try:
|
||
cmd = get_apt_cmd() + ['install'] + pkg_list
|
||
except Exception as e:
|
||
return jsonify({'code': 500, 'msg': str(e)})
|
||
# v1.3.29: 装 PHP 走 on_complete 钩子自动配 FPM listen 端口
|
||
on_complete = None
|
||
if name.startswith('php') and name[3:].replace('.', '').isdigit():
|
||
# name = 'php7.4' / 'php8.3' → php_version = '7.4' / '8.3'
|
||
php_version = name[3:]
|
||
|
||
def _on_php_installed(task_id, status, pv=php_version):
|
||
"""on_complete 钩子:装完后改 FPM listen 端口 + enable + restart"""
|
||
if status != 'success':
|
||
return
|
||
ok, msg = setup_php_fpm_listen(pv)
|
||
if ok:
|
||
write_log('php_install', f'PHP {pv} FPM 已配置 {msg}', request.remote_addr)
|
||
else:
|
||
write_log('php_install', f'PHP {pv} FPM 配置失败: {msg}', request.remote_addr)
|
||
on_complete = _on_php_installed
|
||
task_id = create_task('software_install', name, cmd, on_complete=on_complete)
|
||
write_log('software_install', f'开始安装 {name}', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': '安装任务已启动', 'data': {'task_id': task_id}})
|
||
|
||
# v1.3.29: 批量重写所有站点 nginx conf(按照 db 的 php_version 字段用对应端口)
|
||
# 用途:手动统一切换所有站点到某个 PHP 版本
|
||
@app.route('/api/system/rewrite-all-nginx', methods=['POST'])
|
||
def api_rewrite_all_nginx():
|
||
conn = sqlite3.connect(DB_PATH)
|
||
rows = conn.execute("SELECT id, domain, site_path, php_version, ssl_enabled FROM sites").fetchall()
|
||
conn.close()
|
||
rewritten = 0
|
||
failed = []
|
||
for sid, domain, site_path, php_v, ssl in rows:
|
||
# 跳过静态站点
|
||
try:
|
||
conn2 = sqlite3.connect(DB_PATH)
|
||
st = conn2.execute("SELECT site_type FROM sites WHERE id=?", (sid,)).fetchone()
|
||
conn2.close()
|
||
site_type = st[0] if st and st[0] else 'php'
|
||
except Exception:
|
||
site_type = 'php'
|
||
ok, msg = write_nginx_config(domain, site_path, php_v, ssl=bool(ssl), site_type=site_type)
|
||
if ok:
|
||
rewritten += 1
|
||
else:
|
||
failed.append((domain, msg))
|
||
# reload nginx
|
||
code, out, err = _run(['sudo', 'nginx', '-t'])
|
||
if code == 0:
|
||
_run(['sudo', 'nginx', '-s', 'reload'])
|
||
return jsonify({'code': 0, 'msg': f'重写 {rewritten} 个站点 conf,失败 {len(failed)} 个', 'data': {'rewritten': rewritten, 'failed': failed}})
|
||
|
||
@app.route('/api/software/uninstall/<name>', methods=['POST'])
|
||
def api_software_uninstall(name):
|
||
sw = get_software(name)
|
||
if not sw:
|
||
return jsonify({'code': 404, 'msg': '软件不在白名单'})
|
||
pkgs = get_apt_packages(name)
|
||
if not pkgs:
|
||
return jsonify({'code': 500, 'msg': '未找到该软件包名'})
|
||
pkg_list = pkgs.split(',')
|
||
try:
|
||
cmd = get_apt_cmd() + ['remove'] + pkg_list
|
||
except Exception as e:
|
||
return jsonify({'code': 500, 'msg': str(e)})
|
||
existing = get_running_task_by_type('software_uninstall', name)
|
||
if existing:
|
||
return jsonify({'code': 400, 'msg': f'正在卸载中(任务 ID {existing})'})
|
||
task_id = create_task('software_uninstall', name, cmd)
|
||
write_log('software_uninstall', f'开始卸载 {name}', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': '卸载任务已启动', 'data': {'task_id': task_id}})
|
||
|
||
@app.route('/api/tasks/<int:task_id>', methods=['GET'])
|
||
def api_task_get(task_id):
|
||
t = get_task(task_id)
|
||
if not t:
|
||
return jsonify({'code': 404, 'msg': '任务不存在'})
|
||
return jsonify({'code': 0, 'data': t})
|
||
|
||
@app.route('/api/tasks/<int:task_id>/stream', methods=['GET'])
|
||
def api_task_stream(task_id):
|
||
"""SSE 流,推送任务日志和状态"""
|
||
def generate():
|
||
last_log_len = 0
|
||
# 先发当前状态
|
||
t = get_task(task_id)
|
||
if not t:
|
||
yield f"data: {json.dumps({'type': 'error', 'msg': '任务不存在'})}\n\n"
|
||
return
|
||
yield f"data: {json.dumps({'type': 'status', 'status': t['status'], 'log': t['log']})}\n\n"
|
||
last_log_len = len(t['log'])
|
||
# 轮询直到完成
|
||
for _ in range(1800): # 最多 30 分钟
|
||
time.sleep(1)
|
||
t = get_task(task_id)
|
||
if not t:
|
||
yield f"data: {json.dumps({'type': 'error', 'msg': '任务丢失'})}\n\n"
|
||
return
|
||
if len(t['log']) != last_log_len:
|
||
yield f"data: {json.dumps({'type': 'log', 'log': t['log'][last_log_len:]})}\n\n"
|
||
last_log_len = len(t['log'])
|
||
if t['status'] in ('success', 'failed'):
|
||
yield f"data: {json.dumps({'type': 'done', 'status': t['status'], 'exit_code': t['exit_code']})}\n\n"
|
||
return
|
||
yield f"data: {json.dumps({'type': 'error', 'msg': 'SSE 超时'})}\n\n"
|
||
return Response(generate(), mimetype='text/event-stream',
|
||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||
|
||
# ==========================
|
||
# phpMyAdmin(v1.3.10 新增 - 装完自动配 Nginx 8443)
|
||
# ==========================
|
||
@app.route('/api/phpmyadmin/status', methods=['GET'])
|
||
def api_phpmyadmin_status():
|
||
sw = get_software('phpmyadmin')
|
||
if not sw:
|
||
return jsonify({'code': 0, 'data': {'installed': False, 'url': ''}})
|
||
# v1.3.23 修复:phpMyAdmin URL 构造不再用 hostname 查到的 127.0.0.1
|
||
# 优先顺序:面板域名 > 用户当前访问的 host > 本机获取的 IP
|
||
import socket
|
||
panel_dom = get_panel_domain().strip()
|
||
if panel_dom:
|
||
host = panel_dom
|
||
else:
|
||
# 从 request.host 拆出 hostname(去掉端口)
|
||
try:
|
||
host = request.host.split(':')[0] if request.host else ''
|
||
except Exception:
|
||
host = ''
|
||
if not host or host in ('127.0.0.1', 'localhost'):
|
||
# fallback: 拿第一个非 loopback 的网卡 IP
|
||
try:
|
||
ip_fallback = socket.gethostbyname(socket.gethostname())
|
||
if ip_fallback and not ip_fallback.startswith('127.'):
|
||
host = ip_fallback
|
||
else:
|
||
host = '127.0.0.1'
|
||
except Exception:
|
||
host = '127.0.0.1'
|
||
# 三个条件都满足才算真装好:
|
||
# 1. software 表标记 installed
|
||
# 2. phpMyAdmin 文件实际存在(防 sw 状态滞后)
|
||
# 3. Nginx 8443 反代配置已写入
|
||
files_exist = (
|
||
os.path.isdir('/usr/share/phpmyadmin')
|
||
and os.path.exists('/usr/share/phpmyadmin/index.php')
|
||
)
|
||
nginx_ok = os.path.exists('/etc/nginx/sites-enabled/phpmyadmin.conf')
|
||
really_installed = bool(sw['installed']) and files_exist and nginx_ok
|
||
url = f'http://{host}:8443/' if really_installed else ''
|
||
return jsonify({'code': 0, 'data': {
|
||
'installed': really_installed,
|
||
'url': url,
|
||
'files_exist': files_exist,
|
||
'nginx_ok': nginx_ok,
|
||
'sw_installed': bool(sw['installed'])
|
||
}})
|
||
|
||
@app.route('/api/phpmyadmin/install', methods=['POST'])
|
||
def api_phpmyadmin_install():
|
||
existing = get_running_task_by_type('software_install', 'phpmyadmin')
|
||
if existing:
|
||
return jsonify({'code': 400, 'msg': f'phpMyAdmin 正在安装(任务 ID {existing})', 'data': {'task_id': existing}})
|
||
pkgs = get_apt_packages('phpmyadmin')
|
||
if not pkgs:
|
||
return jsonify({'code': 500, 'msg': '未找到包名'})
|
||
# 装 phpmyadmin(-q 避免交互)
|
||
try:
|
||
cmd = ['sudo', 'apt-get', '-y', '-q', '-o', 'Dpkg::Options::=--force-confdef', '-o', 'Dpkg::Options::=--force-confnew', 'install'] + pkgs.split(',')
|
||
except Exception:
|
||
cmd = ['sudo', 'apt-get', '-y', 'install'] + pkgs.split(',')
|
||
# v1.3.10:装完后用 on_complete 钩子自动写 Nginx 8443 反代
|
||
def _on_pma_installed(task_id, status):
|
||
if status == 'success':
|
||
setup_phpmyadmin_nginx(task_id=task_id)
|
||
task_id = create_task('software_install', 'phpmyadmin', cmd, on_complete=_on_pma_installed)
|
||
return jsonify({'code': 0, 'msg': 'phpMyAdmin 安装任务已启动(装完会自动配置 Nginx 8443)', 'data': {'task_id': task_id}})
|
||
|
||
# ==========================
|
||
# v1.3.34 数据库改密 + phpMyAdmin 真自动登录
|
||
# ==========================
|
||
|
||
# 数据库改密(更新 MySQL + sqlite 都改)
|
||
@app.route('/api/databases/<int:db_id>/password', methods=['POST'])
|
||
def api_change_db_password(db_id):
|
||
data = request.json or {}
|
||
new_pass = data.get('new_pass', '')
|
||
if not new_pass or len(new_pass) < 6:
|
||
return jsonify({'code': 400, 'msg': '新密码至少 6 位'})
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT name, db_user FROM databases WHERE id = ?", (db_id,))
|
||
row = cur.fetchone()
|
||
if not row:
|
||
conn.close()
|
||
return jsonify({'code': 404, 'msg': '数据库不存在'})
|
||
db_name, db_user = row[0], row[1]
|
||
ok, msg = change_db_password(db_user, new_pass)
|
||
if not ok:
|
||
conn.close()
|
||
return jsonify({'code': 500, 'msg': msg})
|
||
# 同步更新 sqlite(备份、phpMyAdmin 自动填、面板显示都用这个)
|
||
conn.execute("UPDATE databases SET db_pass = ? WHERE id = ?", (new_pass, db_id))
|
||
conn.commit()
|
||
conn.close()
|
||
write_log('db_change_pass', f'修改数据库 {db_name} 密码', request.remote_addr)
|
||
_sync_pma_bridge(db_id, row[0], row[1])
|
||
return jsonify({'code': 0, 'msg': '密码修改成功'})
|
||
|
||
# v1.3.34: 数据库改密/增删后同步 phpMyAdmin bridge.json
|
||
def _sync_pma_bridge():
|
||
import subprocess
|
||
try:
|
||
r = subprocess.run(
|
||
['sudo', '-u', 'tpanel', 'python3', '/opt/tpanel/backend/sync_pma_bridge.py'],
|
||
capture_output=True, text=True, timeout=10
|
||
)
|
||
if r.returncode != 0:
|
||
write_log('pma_bridge_sync_fail', r.stderr, request.remote_addr)
|
||
except Exception as e:
|
||
write_log('pma_bridge_sync_err', str(e), request.remote_addr)
|
||
|
||
|
||
# 生成临时 token(5 分钟有效,用于 phpMyAdmin 自动登录跳转)
|
||
# payload: db_id + exp, HMAC-SHA256 签名
|
||
import hmac, hashlib, base64, json, time
|
||
|
||
def _sign_token(db_id, ttl=300):
|
||
payload = {'db_id': db_id, 'exp': int(time.time()) + ttl}
|
||
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
|
||
sig = hmac.new(SECRET_KEY.encode(), payload_b64.encode(), hashlib.sha256).digest()
|
||
sig_b64 = base64.urlsafe_b64encode(sig).decode()
|
||
return f'{payload_b64}.{sig_b64}'
|
||
|
||
def _verify_token(token):
|
||
try:
|
||
payload_b64, sig_b64 = token.split('.')
|
||
# v1.3.34: sign 时已带 padding,不再补
|
||
sig = base64.urlsafe_b64decode(sig_b64)
|
||
expected = hmac.new(SECRET_KEY.encode(), payload_b64.encode(), hashlib.sha256).digest()
|
||
if not hmac.compare_digest(sig, expected):
|
||
return None
|
||
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
|
||
if payload.get('exp', 0) < int(time.time()):
|
||
return None
|
||
return payload
|
||
except Exception:
|
||
return None
|
||
|
||
@app.route('/api/phpmyadmin/token/<int:db_id>', methods=['GET'])
|
||
def api_phpmyadmin_token(db_id):
|
||
"""签发一次性 token(5 分钟有效),前端拼 URL 跳 phpMyAdmin
|
||
v1.3.40.1: 签 token 时同步 bridge.json,避免新建 db 后未同步
|
||
"""
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT name, db_user, db_pass FROM databases WHERE id = ?", (db_id,))
|
||
row = cur.fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return jsonify({'code': 404, 'msg': '数据库不存在'})
|
||
try:
|
||
_sync_pma_bridge(db_id, row[1], row[2])
|
||
except Exception:
|
||
pass
|
||
token = _sign_token(db_id)
|
||
return jsonify({'code': 0, 'data': {'token': token, 'expires_in': 300}})
|
||
|
||
|
||
def _sync_pma_bridge(db_id, db_name, db_pass):
|
||
"""v1.3.40.1: 把指定 db 凭证写到 /usr/share/phpmyadmin/bridge.json
|
||
bridge.php 启动时读此文件,自动填 PMA_single_signon_*
|
||
"""
|
||
import json as _json
|
||
path = '/usr/share/phpmyadmin/bridge.json'
|
||
data = {
|
||
'db_id': db_id,
|
||
'db_user': db_name,
|
||
'db_pass': db_pass,
|
||
'ts': int(time.time())
|
||
}
|
||
tmp = '/tmp/tpanel_bridge_sync.json.tmp'
|
||
with open(tmp, 'w') as f:
|
||
_json.dump(data, f)
|
||
os.chmod(tmp, 0o644)
|
||
subprocess.run(['sudo', 'mv', tmp, path], check=False)
|
||
subprocess.run(['sudo', 'chmod', '644', path], check=False)
|
||
subprocess.run(['sudo', 'chown', 'www-data:www-data', path], check=False)
|
||
|
||
# phpMyAdmin Signon 端点(无 auth — SignonURL 走 GET,phpMyAdmin 自动跳过来)
|
||
@app.route('/api/phpmyadmin/signon', methods=['GET'])
|
||
def api_phpmyadmin_signon():
|
||
"""v1.3.34 phpMyAdmin Signon 端点:验 token → 302 到 PHP bridge.php
|
||
bridge.php 启动 PHP session + 写 PMA_single_signon_* + 302 回 phpMyAdmin
|
||
"""
|
||
token = request.args.get('token', '')
|
||
db_id = request.args.get('db', '')
|
||
if not token or not db_id:
|
||
return redirect('http://127.0.0.1:8888/login?msg=missing_token')
|
||
# v1.3.40.1 修复:动态查 db 所属 site 域名,不再硬编码 zhangpu.tech
|
||
try:
|
||
conn = sqlite3.connect(DB_PATH)
|
||
row = conn.execute(
|
||
"SELECT s.domain, d.db_user, d.db_pass FROM databases d JOIN sites s ON d.site_id = s.id WHERE d.id = ?",
|
||
(db_id,)
|
||
).fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return redirect('http://127.0.0.1:8888/login?msg=db_not_found')
|
||
site_domain, db_user, db_pass = row[0], row[1], row[2]
|
||
except Exception:
|
||
return redirect('http://127.0.0.1:8888/login?msg=db_lookup_error')
|
||
try:
|
||
_sync_pma_bridge(db_id, db_user, db_pass)
|
||
except Exception:
|
||
pass
|
||
return redirect('https://' + site_domain + '/pma/tpanel-bridge.php?token=' + token + '&db=' + db_id)
|
||
|
||
# phpMyAdmin 退出(清除 cookie + 重定向回面板)
|
||
@app.route('/api/phpmyadmin/logout', methods=['GET'])
|
||
def api_phpmyadmin_logout():
|
||
resp = redirect('http://127.0.0.1:8888/dashboard')
|
||
resp.delete_cookie('TPanelSignon', domain='127.0.0.1', path='/')
|
||
return resp
|
||
|
||
# ==========================
|
||
# 安全更新(v1.3.10 改为走任务流)
|
||
# ==========================
|
||
@app.route('/api/security/update', methods=['POST'])
|
||
def api_security_update():
|
||
existing = get_running_task_by_type('security_update')
|
||
if existing:
|
||
return jsonify({'code': 400, 'msg': f'更新任务正在进行(ID {existing})', 'data': {'task_id': existing}})
|
||
try:
|
||
# apt-get update + upgrade -y
|
||
cmd = ['sudo', 'apt-get', 'update', '-y', '-q']
|
||
except Exception:
|
||
cmd = ['sudo', 'apt-get', 'update', '-y']
|
||
task_id = create_task('security_update', 'system', cmd)
|
||
write_log('security_update', '启动系统安全更新', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': '更新任务已启动', 'data': {'task_id': task_id}})
|
||
|
||
# ==========================
|
||
# v1.3.28: 添加 Sury PHP 第三方源
|
||
# 让 Debian 12 也能装 PHP 5.6 / 7.0 / 7.4 / 8.0 / 8.1 / 8.3 / 8.4
|
||
# 走任务流,因为 apt update 可能耗时 1-2 分钟
|
||
# ==========================
|
||
@app.route('/api/system/add-sury-php', methods=['POST'])
|
||
def api_add_sury_php():
|
||
# 只支持 Debian/Ubuntu 系
|
||
if not os.path.exists('/etc/debian_version') and not os.path.exists('/etc/lsb-release'):
|
||
return jsonify({'code': 400, 'msg': 'Sury PHP 源仅支持 Debian/Ubuntu 系统'})
|
||
|
||
# 检查是否已添加
|
||
if os.path.exists('/etc/apt/sources.list.d/php.list'):
|
||
return jsonify({'code': 400, 'msg': 'Sury PHP 源已添加,无需重复操作'})
|
||
|
||
# 查重:同类型任务
|
||
existing = get_running_task_by_type('sury_php', 'sury_php')
|
||
if existing:
|
||
return jsonify({'code': 400, 'msg': f'Sury 源添加任务正在进行(ID {existing})', 'data': {'task_id': existing}})
|
||
|
||
# 任务命令:装 lsb-release + ca-certificates + curl → 下载 GPG keyring → 配置源 → apt update
|
||
cmd = [
|
||
'bash', '-c',
|
||
'''
|
||
set -e
|
||
echo "[1/5] 装 lsb-release / ca-certificates / curl..."
|
||
sudo -n DEBIAN_FRONTEND=noninteractive apt-get install -y -q lsb-release ca-certificates curl 2>&1
|
||
echo "[2/5] 下载 Sury GPG keyring..."
|
||
sudo -n curl -sSLo /tmp/debsuryorg-archive-keyring.deb https://packages.sury.org/debsuryorg-archive-keyring.deb
|
||
echo "[3/5] 装 keyring..."
|
||
sudo -n dpkg -i /tmp/debsuryorg-archive-keyring.deb
|
||
echo "[4/5] 写 /etc/apt/sources.list.d/php.list..."
|
||
DISTRO=$(lsb_release -sc)
|
||
sudo -n sh -c "echo 'deb [signed-by=/usr/share/keyrings/debsuryorg-archive-keyring.gpg] https://packages.sury.org/php/ ${DISTRO} main' > /etc/apt/sources.list.d/php.list"
|
||
echo "[5/5] apt update(可能要 30-90s)..."
|
||
sudo -n DEBIAN_FRONTEND=noninteractive apt-get update -q 2>&1
|
||
echo "===Sury PHP 源添加完成==="
|
||
'''
|
||
]
|
||
task_id = create_task('sury_php', 'sury_php', cmd)
|
||
write_log('sury_php', '启动添加 Sury PHP 源任务', request.remote_addr)
|
||
return jsonify({'code': 0, 'msg': f'Sury PHP 源添加任务已启动(task_id={task_id}),请查看进度', 'data': {'task_id': task_id}})
|
||
|
||
# ==========================
|
||
# v1.3.37+ 生产增强 API
|
||
# ==========================
|
||
|
||
# -------------------------- 防火墙管理 --------------------------
|
||
@app.route('/api/firewall/status', methods=['GET'])
|
||
def api_firewall_status():
|
||
status = get_firewall_status()
|
||
return jsonify({'code': 0, 'msg': 'ok', 'data': status})
|
||
|
||
@app.route('/api/firewall/enable', methods=['POST'])
|
||
def api_firewall_enable():
|
||
ok, msg = firewall_enable()
|
||
write_log('firewall', '启用防火墙', request.remote_addr)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/firewall/port', methods=['POST'])
|
||
def api_firewall_port():
|
||
data = request.get_json() or {}
|
||
port = data.get('port', '').strip()
|
||
proto = data.get('proto', 'tcp').strip()
|
||
action = data.get('action', 'open').strip()
|
||
if action == 'open':
|
||
ok, msg = firewall_open_port(port, proto)
|
||
else:
|
||
ok, msg = firewall_close_port(port, proto)
|
||
write_log('firewall', f'{action} 端口 {port}/{proto}', request.remote_addr)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
# -------------------------- 面板端口修改 --------------------------
|
||
@app.route('/api/panel/port', methods=['GET'])
|
||
def api_panel_port():
|
||
port = get_panel_port()
|
||
return jsonify({'code': 0, 'msg': 'ok', 'data': {'port': port}})
|
||
|
||
@app.route('/api/panel/port', methods=['POST'])
|
||
def api_change_panel_port():
|
||
data = request.get_json() or {}
|
||
new_port = data.get('port')
|
||
if not new_port:
|
||
return jsonify({'code': 400, 'msg': '请输入新端口'})
|
||
ok, msg = change_panel_port(new_port)
|
||
write_log('panel_port', f'修改面板端口为 {new_port}', request.remote_addr)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
# -------------------------- 自动备份 --------------------------
|
||
@app.route('/api/backup/settings', methods=['GET'])
|
||
def api_backup_settings():
|
||
settings = get_backup_settings()
|
||
return jsonify({'code': 0, 'msg': 'ok', 'data': settings})
|
||
|
||
@app.route('/api/backup/settings', methods=['POST'])
|
||
def api_save_backup_settings():
|
||
data = request.get_json() or {}
|
||
enabled = data.get('enabled', False)
|
||
schedule = data.get('schedule', 'daily')
|
||
keep_days = int(data.get('keep_days', 7))
|
||
backup_dir = data.get('backup_dir', '/backup')
|
||
ok, msg = save_backup_settings(enabled, schedule, keep_days, backup_dir)
|
||
write_log('backup', f'配置自动备份: enabled={enabled}, schedule={schedule}', request.remote_addr)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/backup/run', methods=['POST'])
|
||
def api_run_backup():
|
||
ok, msg = run_backup_now()
|
||
write_log('backup', '手动执行备份', request.remote_addr)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
@app.route('/api/backup/list', methods=['GET'])
|
||
def api_list_backups():
|
||
backups = list_backups()
|
||
return jsonify({'code': 0, 'msg': 'ok', 'data': backups})
|
||
|
||
@app.route('/api/backup/delete', methods=['POST'])
|
||
def api_delete_backup():
|
||
data = request.get_json() or {}
|
||
backup_type = data.get('type')
|
||
filename = data.get('filename')
|
||
ok, msg = delete_backup(backup_type, filename)
|
||
write_log('backup', f'删除备份: {backup_type}/{filename}', request.remote_addr)
|
||
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||
|
||
# ==========================
|
||
# 静态文件
|
||
# ==========================
|
||
|
||
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('/')
|
||
def serve_index():
|
||
return _no_cache_html(app.send_static_file('index.html'))
|
||
|
||
@app.route('/<path:path>')
|
||
def serve_static(path):
|
||
full = os.path.join(app.static_folder, path)
|
||
if os.path.exists(full) and not os.path.isdir(full):
|
||
resp = app.send_static_file(path)
|
||
# 其他静态资源(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'))
|
||
|
||
# ==========================
|
||
|
||
if __name__ == '__main__':
|
||
import sys
|
||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8848
|
||
# 初始化数据库
|
||
from db_init import init_db
|
||
init_db()
|
||
# 生成初始 API token
|
||
if not get_setting('api_token'):
|
||
set_setting('api_token', secrets.token_hex(32))
|
||
print(f"[TPanel] 启动于端口 {port}")
|
||
app.run(host='127.0.0.1', port=port, debug=False)
|