File size: 17,842 Bytes
679468a
a9e0553
 
bb8feaa
 
 
 
ba99c6f
 
 
a9e0553
 
 
 
 
 
bb8feaa
ba99c6f
bb8feaa
ba99c6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb8feaa
 
 
 
a9e0553
 
 
 
bb8feaa
 
a9e0553
 
 
bb8feaa
 
 
 
 
a9e0553
bb8feaa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9e0553
 
 
bb8feaa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9e0553
 
 
bb8feaa
 
 
 
 
a9e0553
ba99c6f
a9e0553
 
bb8feaa
a9e0553
 
 
 
 
bb8feaa
a9e0553
 
 
ba99c6f
bb8feaa
 
ba99c6f
bb8feaa
 
 
 
 
ba99c6f
bb8feaa
 
 
ba99c6f
 
bb8feaa
 
ba99c6f
bb8feaa
ba99c6f
 
bb8feaa
 
 
ba99c6f
a9e0553
ba99c6f
 
bb8feaa
ba99c6f
a9e0553
ba99c6f
 
bb8feaa
 
ba99c6f
 
bb8feaa
 
ba99c6f
 
bb8feaa
 
 
ba99c6f
 
 
 
bb8feaa
ba99c6f
bb8feaa
 
ba99c6f
bb8feaa
ba99c6f
bb8feaa
 
a9e0553
 
 
 
80350e3
 
ba99c6f
80350e3
 
 
 
 
a9e0553
 
80350e3
31152dd
71b1ed8
80350e3
 
 
 
 
31152dd
bb8feaa
 
31152dd
80350e3
 
bb8feaa
 
80350e3
bb8feaa
 
80350e3
bb8feaa
ba99c6f
bb8feaa
 
ba99c6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9e0553
 
 
ba99c6f
 
 
 
 
 
a9e0553
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb8feaa
 
ba99c6f
bb8feaa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
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)}