QQBot / app.py
ffzeroHua's picture
Update app.py
ba99c6f verified
Raw
History Blame Contribute Delete
17.8 kB
import os
import pty
import asyncio
import re
import secrets
import shutil
from pydantic import BaseModel
# 引入 Cookie, Form 和 RedirectResponse
from fastapi import FastAPI, WebSocket, UploadFile, Request, Response, Depends, HTTPException, status, Form, Cookie
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
import uvicorn
app = FastAPI()
PASSWORD = os.getenv("PASSWORD", "123456")
# ==========================================
# 权限校验 (改用 Cookie 校验,避开 Header 冲突)
# ==========================================
def verify_auth(auth_token: str = Cookie(default=None)):
if not auth_token or not secrets.compare_digest(auth_token, PASSWORD):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无权访问,请先登录")
return auth_token
# ==========================================
# 自定义暗黑风登录页面
# ==========================================
LOGIN_PAGE = """
<!DOCTYPE html>
<html>
<head><title>Admin Login</title></head>
<body style="background:#1e1e1e; color:#fff; display:flex; justify-content:center; align-items:center; height:100vh; font-family:sans-serif;">
<form method="post" action="/login" style="background:#2d2d2d; padding:40px; border-radius:8px; text-align:center; box-shadow: 0 4px 15px rgba(0,0,0,0.5);">
<h2 style="margin-top:0;">Web Shell Login</h2>
<input type="password" name="password" placeholder="请输入终端密码" style="padding:10px; margin-bottom:20px; width:220px; border:1px solid #555; background:#1e1e1e; color:#fff; border-radius:4px;"><br>
<button type="submit" style="padding:10px 30px; background:#007acc; color:white; border:none; border-radius:4px; cursor:pointer; font-size:16px;">登 入</button>
</form>
</body>
</html>
"""
# ==========================================
# 前端 HTML (内置三栏布局、CodeMirror、文件管理器)
# ==========================================
HTML_PAGE = """
<!DOCTYPE html>
<html>
<head>
<title>HF Web Shell (Pro)</title>
<!-- Xterm.js -->
<link rel="stylesheet" href="https://unpkg.com/xterm/css/xterm.css" />
<script src="https://unpkg.com/xterm/lib/xterm.js"></script>
<script src="https://unpkg.com/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
<!-- CodeMirror (自带行号和侧边栏) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/theme/darcula.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.js"></script>
<style>
body { margin: 0; padding: 0; background: #1e1e1e; color: #ccc; font-family: sans-serif; display: flex; height: 100vh; overflow: hidden; }
#sidebar { width: 280px; min-width: 280px; background: #252526; display: flex; flex-direction: column; border-right: 1px solid #444; }
.sidebar-header { padding: 10px; background: #333; display: flex; flex-direction: column; gap: 5px; }
.path-display { font-size: 12px; background: #1e1e1e; padding: 5px; border-radius: 3px; word-break: break-all; }
.toolbar { display: flex; gap: 5px; }
.toolbar button { flex: 1; padding: 5px; background: #007acc; color: white; border: none; cursor: pointer; border-radius: 2px; font-size: 12px; }
.toolbar button:hover { background: #005f9e; }
#file-list { flex: 1; overflow-y: auto; list-style: none; padding: 0; margin: 0; }
.file-item { padding: 6px 10px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #333; user-select: none; }
.file-item:hover { background: #2a2d2e; }
.file-item.selected { background: #37373d; color: white; }
#main-area { flex: 1; display: flex; flex-direction: column; position: relative; }
#terminal-container { flex: 1; padding: 10px; }
#editor-panel { position: absolute; right: 0; top: 0; bottom: 0; width: 50%; background: #2b2b2b; border-left: 1px solid #444; display: none; flex-direction: column; z-index: 10; box-shadow: -5px 0 15px rgba(0,0,0,0.5); }
.editor-header { padding: 10px; background: #3c3f41; display: flex; justify-content: space-between; align-items: center; }
.editor-header span { font-size: 14px; font-weight: bold; color: #fff; }
.editor-header button { padding: 5px 15px; background: #4caf50; color: white; border: none; cursor: pointer; border-radius: 3px; }
.editor-header button.close { background: #f44336; }
#code-editor { flex: 1; overflow: hidden; }
.CodeMirror { height: 100%; font-family: monospace; font-size: 14px; }
#fileUpload { display: none; }
</style>
</head>
<body>
<div id="sidebar">
<div class="sidebar-header">
<div class="path-display" id="current-path">/app</div>
<div class="toolbar">
<button onclick="goUp()">⬆️ 上级</button>
<button onclick="document.getElementById('fileUpload').click()">📤 上传</button>
<button onclick="downloadSelected()">⬇️ 下载</button>
</div>
<div class="toolbar">
<button onclick="actionCopy()" id="btn-copy">📋 复制</button>
<button onclick="actionPaste()" id="btn-paste" style="display:none; background:#d84315;">📋 粘贴</button>
<button onclick="actionDelete()" style="background:#c62828;">🗑️ 删除</button>
</div>
<input type="file" id="fileUpload" multiple onchange="uploadFiles()">
</div>
<ul id="file-list"></ul>
</div>
<div id="main-area">
<div id="terminal-container"></div>
<div id="editor-panel">
<div class="editor-header">
<span id="editor-title">Editing...</span>
<div>
<button onclick="saveFile()">💾 保存 (Ctrl+S)</button>
<button class="close" onclick="closeEditor()">❌ 退出</button>
</div>
</div>
<div id="code-editor"></div>
</div>
</div>
<script>
let currentPath = "/app";
let selectedFile = null;
let clipboard = { action: null, path: null };
let cmEditor = null;
let currentEditPath = null;
const term = new Terminal({ cursorBlink: true, convertEol: true, theme: { background: '#1e1e1e' } });
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal-container'));
fitAddon.fit();
window.addEventListener('resize', () => fitAddon.fit());
const wsProtocol = location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(wsProtocol + location.host + '/ws');
ws.onopen = () => term.writeln('\\x1b[32m[+] 容器终端连接成功!\\x1b[0m\\r\\n');
ws.onmessage = (evt) => term.write(evt.data);
term.onData((data) => ws.send(data));
cmEditor = CodeMirror(document.getElementById('code-editor'), { theme: 'darcula', lineNumbers: true, lineWrapping: true, indentUnit: 4 });
document.addEventListener('keydown', e => {
if (e.ctrlKey && e.key === 's' && document.getElementById('editor-panel').style.display === 'flex') {
e.preventDefault(); saveFile();
}
});
async function loadDir(path) {
const res = await fetch(`/api/fs/list?path=${encodeURIComponent(path)}`);
if (!res.ok) return alert("身份验证失效,请刷新页面重新登录");
const data = await res.json();
if (data.status !== "ok") return alert(data.msg);
currentPath = data.path; document.getElementById('current-path').innerText = currentPath; selectedFile = null;
const ul = document.getElementById('file-list'); ul.innerHTML = '';
data.items.forEach(item => {
const li = document.createElement('li'); li.className = 'file-item';
li.innerHTML = `${item.is_dir ? '📁' : '📄'} ${item.name}`;
li.onclick = () => { document.querySelectorAll('.file-item').forEach(el => el.classList.remove('selected')); li.classList.add('selected'); selectedFile = `${currentPath}/${item.name}`.replace('//', '/'); };
li.ondblclick = () => { const target = `${currentPath}/${item.name}`.replace('//', '/'); item.is_dir ? loadDir(target) : openEditor(target); };
ul.appendChild(li);
});
}
function goUp() { if (currentPath === '/') return; const parts = currentPath.split('/').filter(Boolean); parts.pop(); loadDir('/' + (parts.join('/') || '')); }
async function uploadFiles() {
const files = document.getElementById('fileUpload').files; if(!files.length) return;
const fd = new FormData(); for(let f of files) fd.append('files', f);
const res = await fetch(`/upload?path=${encodeURIComponent(currentPath)}`, { method: 'POST', body: fd });
if(res.ok) loadDir(currentPath); else alert('上传失败');
}
function downloadSelected() { if (selectedFile) window.open('/download?path=' + encodeURIComponent(selectedFile)); else alert("请先选中文件"); }
function actionCopy() { if (!selectedFile) return; clipboard = { action: 'copy', path: selectedFile }; document.getElementById('btn-paste').style.display = 'block'; document.getElementById('btn-copy').innerText = "已复制"; setTimeout(() => document.getElementById('btn-copy').innerText = "📋 复制", 2000); }
async function actionPaste() {
if (!clipboard.path) return;
const res = await fetch('/api/fs/action', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ action: clipboard.action, src: clipboard.path, dest: `${currentPath}/${clipboard.path.split('/').pop()}`.replace('//', '/') }) });
const data = await res.json(); data.status === 'ok' ? loadDir(currentPath) : alert(data.msg);
}
async function actionDelete() {
if (!selectedFile || !confirm(`确认删除 ${selectedFile} 吗?`)) return;
await fetch('/api/fs/action', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ action: 'delete', src: selectedFile, dest: '' }) });
loadDir(currentPath);
}
async function openEditor(path) {
const res = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`); const data = await res.json();
if (data.status !== 'ok') return alert(data.msg || "无法读取");
currentEditPath = path; document.getElementById('editor-title').innerText = path.split('/').pop(); document.getElementById('editor-panel').style.display = 'flex';
cmEditor.setValue(data.content); setTimeout(() => cmEditor.refresh(), 100);
}
function closeEditor() { document.getElementById('editor-panel').style.display = 'none'; }
async function saveFile() {
if (!currentEditPath) return;
const res = await fetch('/api/fs/write', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ path: currentEditPath, content: cmEditor.getValue() }) });
const data = await res.json();
if(data.status === 'ok') { const title = document.getElementById('editor-title'); title.innerText = "✅ 保存成功!"; setTimeout(() => title.innerText = currentEditPath.split('/').pop(), 2000); } else alert("保存失败: " + data.msg);
}
loadDir('/app');
</script>
</body>
</html>
"""
# ==========================================
# 公共路由:复盘报告服务器相关配置
# ==========================================
MEMORY_CACHE = {}
INDEX_FILE = "/app/index.html"
REPORT_DIR = "/app/reviewreports"
os.makedirs(REPORT_DIR, exist_ok=True)
@app.get("/")
async def index(request: Request):
host = request.headers.get("x-proxy-host") or request.headers.get("host", "")
if "online.4z.autos" in host:
if 'index' not in MEMORY_CACHE:
if os.path.exists(INDEX_FILE):
with open(INDEX_FILE, 'rb') as f:
MEMORY_CACHE['index'] = f.read()
else:
return Response("Index File Not Found", status_code=404)
return Response(content=MEMORY_CACHE['index'], media_type="text/html", headers={'Cache-Control': 'public, max-age=3600'})
return Response("Access Denied", status_code=403)
@app.get("/report/{filename}")
async def get_report(filename: str):
if filename.endswith(".json"): filename = filename[:-5]
if not re.match(r'^[a-zA-Z0-9]+$', filename): return Response("Bad Request", status_code=400)
filepath = os.path.join(REPORT_DIR, f"{filename}.json")
if not os.path.exists(filepath): return Response("File Not Found", status_code=404)
return FileResponse(filepath, media_type='application/json', headers={'Access-Control-Allow-Origin': '*','Cache-Control': 'public, max-age=31536000, immutable'})
# ==========================================
# 私密路由:后台登录与验证体系
# ==========================================
@app.get("/admin")
async def admin_shell(request: Request):
# 检查 Cookie 是否有效
token = request.cookies.get("auth_token")
if token and secrets.compare_digest(token, PASSWORD):
return HTMLResponse(HTML_PAGE)
# 无效则展示我们自己写的登录页面
return HTMLResponse(LOGIN_PAGE)
@app.post("/login")
async def do_login(password: str = Form(...)):
# 校验表单密码
if secrets.compare_digest(password, PASSWORD):
# 密码正确,重定向回 /admin,并种下有效时间为 1 天的 Cookie
response = RedirectResponse(url="/admin", status_code=302)
response.set_cookie(key="auth_token", value=PASSWORD, httponly=True, max_age=86400)
return response
# 密码错误
return HTMLResponse("<h2 style='color:red; text-align:center;'>密码错误,请返回重试</h2>", status_code=401)
@app.websocket("/ws")
async def ws_endpoint(websocket: WebSocket):
# WebSocket 拦截校验 Cookie
token = websocket.cookies.get("auth_token")
if not token or not secrets.compare_digest(token, PASSWORD):
await websocket.close(code=1008)
return
await websocket.accept()
pid, fd = pty.fork()
if pid == 0:
os.environ["TERM"] = "xterm-256color"
os.chdir("/app")
os.execvp("bash", ["bash"])
else:
loop = asyncio.get_running_loop()
async def read_pty():
while True:
try:
data = await loop.run_in_executor(None, os.read, fd, 1024)
if not data: break
await websocket.send_text(data.decode('utf-8', 'replace'))
except Exception:
break
async def read_ws():
while True:
try:
data = await websocket.receive_text()
os.write(fd, data.encode('utf-8'))
except Exception:
break
await asyncio.gather(read_pty(), read_ws())
# --- 以下所有 API 接口均受到 Depends(verify_auth) 保护 ---
class FsAction(BaseModel):
action: str
src: str
dest: str
class FileContent(BaseModel):
path: str
content: str
@app.get("/api/fs/list")
def fs_list(path: str = "/app", username: str = Depends(verify_auth)):
try:
items = []
for f in os.scandir(path):
items.append({"name": f.name, "is_dir": f.is_dir(), "size": f.stat().st_size})
items.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
return {"status": "ok", "path": path, "items": items}
except Exception as e:
return {"status": "error", "msg": str(e)}
@app.post("/api/fs/action")
def fs_action(req: FsAction, username: str = Depends(verify_auth)):
try:
if req.action == 'delete':
if os.path.isdir(req.src): shutil.rmtree(req.src)
else: os.remove(req.src)
elif req.action == 'copy':
if os.path.isdir(req.src): shutil.copytree(req.src, req.dest)
else: shutil.copy2(req.src, req.dest)
return {"status": "ok"}
except Exception as e:
return {"status": "error", "msg": str(e)}
@app.post("/upload")
async def upload(path: str, files: list[UploadFile], username: str = Depends(verify_auth)):
for file in files:
file_path = os.path.join(path, file.filename)
with open(file_path, "wb") as f:
f.write(await file.read())
return {"status": "ok"}
@app.get("/download")
def download(path: str, username: str = Depends(verify_auth)):
if os.path.exists(path): return FileResponse(path)
return {"error": "File not found"}
@app.get("/api/fs/read")
def fs_read(path: str, username: str = Depends(verify_auth)):
try:
with open(path, 'r', encoding='utf-8') as f:
return {"status": "ok", "content": f.read()}
except UnicodeDecodeError:
return {"status": "error", "msg": "不是文本文件,无法编辑"}
except Exception as e:
return {"status": "error", "msg": str(e)}
@app.post("/api/fs/write")
def fs_write(req: FileContent, username: str = Depends(verify_auth)):
try:
with open(req.path, 'w', encoding='utf-8') as f:
f.write(req.content)
return {"status": "ok"}
except Exception as e:
return {"status": "error", "msg": str(e)}