tpanel/backend/file_manager.py
2026-06-01 10:15:58 +08:00

196 lines
No EOL
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
TPanel - 文件管理模块
"""
import os
import zipfile
import tarfile
import shutil
import subprocess
from datetime import datetime
def _run(cmd, timeout=30):
try:
if isinstance(cmd, str):
cmd = cmd.split()
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
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 list_directory(path, site_user=None):
"""列出目录内容,带安全和权限信息"""
# 安全检查:防止路径遍历
real_path = os.path.realpath(path)
allowed_base = ['/opt/tpanel/sites', '/opt/tpanel/backups']
if not any(real_path.startswith(base) for base in allowed_base):
return None, '路径不在允许范围内'
if not os.path.exists(path):
return None, '目录不存在'
items = []
try:
entries = os.listdir(path)
except PermissionError:
return None, '无权限访问'
for name in sorted(entries):
fp = os.path.join(path, name)
try:
stat = os.stat(fp)
is_dir = os.path.isdir(fp)
# 文件大小
if is_dir:
size = sum(os.path.getsize(os.path.join(dp, f))
for dp, dn, fn in os.walk(fp) for f in fn) if False else 0
else:
size = stat.st_size
items.append({
'name': name,
'type': 'dir' if is_dir else 'file',
'size': size,
'size_str': format_size(size),
'modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M'),
'permissions': stat.st_mode & 0o777,
'perm_str': format_permissions(stat.st_mode & 0o777),
'readable': os.access(fp, os.R_OK),
'writable': os.access(fp, os.W_OK),
})
except Exception:
continue
return items, None
def format_size(size):
if size < 1024:
return str(size) + ' B'
elif size < 1024 * 1024:
return f'{size / 1024:.1f} KB'
elif size < 1024 * 1024 * 1024:
return f'{size / (1024 * 1024):.1f} MB'
else:
return f'{size / (1024 * 1024 * 1024):.2f} GB'
def format_permissions(mode):
chars = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']
return chars[(mode >> 6) & 7] + chars[(mode >> 3) & 7] + chars[mode & 7]
def read_file(path, max_size=1024 * 1024):
"""读取文件内容(限制1MB)"""
if not os.path.exists(path):
return None, '文件不存在'
if os.path.getsize(path) > max_size:
return None, '文件超过 1MB 限制'
# 只允许读取配置文件和常见文本格式
allowed_ext = ['.php', '.html', '.htm', '.css', '.js', '.json', '.txt', '.md',
'.yaml', '.yml', '.xml', '.conf', '.ini', '.log', '.sql']
ext = os.path.splitext(path)[1].lower()
if ext not in allowed_ext and not any(path.endswith(x) for x in ['/config.php', '/.htaccess']):
return None, '文件类型不允许读取'
try:
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
return f.read(), None
except Exception as e:
return None, str(e)
def write_file(path, content):
"""写入文件(仅限站点目录)"""
real_path = os.path.realpath(path)
if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内'
try:
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
return True, '文件已保存'
except Exception as e:
return False, str(e)
def upload_file(upload_dir, file_obj, filename):
"""上传文件到站点目录"""
real_path = os.path.realpath(upload_dir)
if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内'
# 限制文件类型
allowed = ['.php', '.html', '.htm', '.css', '.js', '.json', '.txt', '.md',
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico',
'.zip', '.tar', '.gz', '.bz2',
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
'.woff', '.woff2', '.ttf', '.eot']
ext = os.path.splitext(filename)[1].lower()
if ext not in allowed:
return False, f'文件类型 {ext} 不允许上传'
dest = os.path.join(upload_dir, filename)
try:
file_obj.save(dest)
# 自动解压 zip/tar.gz
if filename.endswith('.zip'):
try:
with zipfile.ZipFile(dest, 'r') as zf:
zf.extractall(upload_dir)
return True, f'文件已上传并解压:{filename}'
except Exception:
return True, f'文件已上传(解压失败):{filename}'
elif filename.endswith(('.tar.gz', '.tgz')):
try:
with tarfile.open(dest, 'r:gz') as tf:
tf.extractall(upload_dir)
return True, f'文件已上传并解压:{filename}'
except Exception:
return True, f'文件已上传(解压失败):{filename}'
return True, f'文件已上传:{filename}'
except Exception as e:
return False, str(e)
def delete_file(path):
"""删除文件或目录"""
real_path = os.path.realpath(path)
if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内'
try:
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.remove(path)
return True, '已删除'
except Exception as e:
return False, str(e)
def chmod_file(path, mode):
"""修改文件权限(限制范围)"""
real_path = os.path.realpath(path)
if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内'
# 限制权限范围
if mode & 0o777 not in [0o755, 0o644, 0o600, 0o700, 0o775, 0o664]:
return False, '权限值不允许'
try:
os.chmod(path, mode & 0o777)
return True, f'权限已修改为 {oct(mode & 0o777)}'
except Exception as e:
return False, str(e)
def create_directory(path, dirname):
"""创建目录"""
real_path = os.path.realpath(path)
if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内'
new_path = os.path.join(path, dirname)
try:
os.makedirs(new_path, exist_ok=True)
return True, f'目录已创建:{dirname}'
except Exception as e:
return False, str(e)