gitinstall 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitinstall/__init__.py +61 -0
- gitinstall/_sdk.py +541 -0
- gitinstall/academic.py +831 -0
- gitinstall/admin.html +327 -0
- gitinstall/auto_update.py +384 -0
- gitinstall/autopilot.py +349 -0
- gitinstall/badge.py +476 -0
- gitinstall/checkpoint.py +330 -0
- gitinstall/cicd.py +499 -0
- gitinstall/clawhub.html +718 -0
- gitinstall/config_schema.py +353 -0
- gitinstall/db.py +984 -0
- gitinstall/db_backend.py +445 -0
- gitinstall/dep_chain.py +337 -0
- gitinstall/dependency_audit.py +1153 -0
- gitinstall/detector.py +542 -0
- gitinstall/doctor.py +493 -0
- gitinstall/education.py +869 -0
- gitinstall/enterprise.py +802 -0
- gitinstall/error_fixer.py +953 -0
- gitinstall/event_bus.py +251 -0
- gitinstall/executor.py +577 -0
- gitinstall/feature_flags.py +138 -0
- gitinstall/fetcher.py +921 -0
- gitinstall/huggingface.py +922 -0
- gitinstall/hw_detect.py +988 -0
- gitinstall/i18n.py +664 -0
- gitinstall/installer_registry.py +362 -0
- gitinstall/knowledge_base.py +379 -0
- gitinstall/license_check.py +605 -0
- gitinstall/llm.py +569 -0
- gitinstall/log.py +236 -0
- gitinstall/main.py +1408 -0
- gitinstall/mcp_agent.py +841 -0
- gitinstall/mcp_server.py +386 -0
- gitinstall/monorepo.py +810 -0
- gitinstall/multi_source.py +425 -0
- gitinstall/onboard.py +276 -0
- gitinstall/planner.py +222 -0
- gitinstall/planner_helpers.py +323 -0
- gitinstall/planner_known_projects.py +1010 -0
- gitinstall/planner_templates.py +996 -0
- gitinstall/remote_gpu.py +633 -0
- gitinstall/resilience.py +608 -0
- gitinstall/run_tests.py +572 -0
- gitinstall/skills.py +476 -0
- gitinstall/tool_schemas.py +324 -0
- gitinstall/trending.py +279 -0
- gitinstall/uninstaller.py +415 -0
- gitinstall/validate_top100.py +607 -0
- gitinstall/watchdog.py +180 -0
- gitinstall/web.py +1277 -0
- gitinstall/web_ui.html +2277 -0
- gitinstall-1.1.0.dist-info/METADATA +275 -0
- gitinstall-1.1.0.dist-info/RECORD +59 -0
- gitinstall-1.1.0.dist-info/WHEEL +5 -0
- gitinstall-1.1.0.dist-info/entry_points.txt +3 -0
- gitinstall-1.1.0.dist-info/licenses/LICENSE +21 -0
- gitinstall-1.1.0.dist-info/top_level.txt +1 -0
gitinstall/web.py
ADDED
|
@@ -0,0 +1,1277 @@
|
|
|
1
|
+
"""
|
|
2
|
+
web.py - gitinstall Web UI 服务器
|
|
3
|
+
=================================
|
|
4
|
+
|
|
5
|
+
在浏览器中交互式安装 GitHub 开源项目。
|
|
6
|
+
|
|
7
|
+
启动方式:
|
|
8
|
+
python tools/main.py web # 默认 8080 端口
|
|
9
|
+
python tools/main.py web --port 9090 # 指定端口
|
|
10
|
+
python tools/web.py # 直接运行
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac as _hmac
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import secrets
|
|
22
|
+
import ssl
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
28
|
+
from collections import defaultdict
|
|
29
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
30
|
+
from io import StringIO
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from socketserver import ThreadingMixIn
|
|
33
|
+
|
|
34
|
+
# ── 确保 tools 目录可导入 ──
|
|
35
|
+
_THIS_DIR = Path(__file__).resolve().parent
|
|
36
|
+
if str(_THIS_DIR) not in sys.path:
|
|
37
|
+
sys.path.insert(0, str(_THIS_DIR))
|
|
38
|
+
|
|
39
|
+
import db as _db
|
|
40
|
+
from log import get_logger
|
|
41
|
+
from i18n import t
|
|
42
|
+
|
|
43
|
+
logger = get_logger(__name__)
|
|
44
|
+
|
|
45
|
+
# ── 请求体大小限制(1 MB) ──
|
|
46
|
+
MAX_BODY_SIZE = 1 * 1024 * 1024
|
|
47
|
+
|
|
48
|
+
# ── Rate Limiting(内存,按 IP) ──
|
|
49
|
+
_rate_limits: dict[str, list[float]] = defaultdict(list)
|
|
50
|
+
_rate_lock = __import__("threading").Lock()
|
|
51
|
+
|
|
52
|
+
# 单位:秒窗口内最大请求数 {路由前缀: (窗口秒数, 最大次数)}
|
|
53
|
+
_RATE_RULES: dict[str, tuple[int, int]] = {
|
|
54
|
+
"/api/login": (60, 10), # 登录:60s 内最多 10 次
|
|
55
|
+
"/api/register": (60, 5), # 注册:60s 内最多 5 次
|
|
56
|
+
"/api/forgot-password": (60, 3), # 忘记密码:60s 内最多 3 次
|
|
57
|
+
"/api/reset-password": (60, 5), # 重置密码:60s 内最多 5 次
|
|
58
|
+
"/api/admin/set": (60, 3), # 管理员设置:60s 内最多 3 次
|
|
59
|
+
"/api/plan": (60, 15), # 生成计划:60s 内最多 15 次
|
|
60
|
+
"/api/install": (60, 10), # 安装:60s 内最多 10 次
|
|
61
|
+
"/api/search": (60, 30), # 搜索:60s 内最多 30 次
|
|
62
|
+
"/api/audit": (60, 15), # 审计:60s 内最多 15 次
|
|
63
|
+
"/api/license": (60, 15), # 许可证:60s 内最多 15 次
|
|
64
|
+
"/api/updates": (60, 20), # 更新检查:60s 内最多 20 次
|
|
65
|
+
"/api/uninstall": (60, 5), # 卸载:60s 内最多 5 次
|
|
66
|
+
"/api/chain": (60, 10), # 依赖链:60s 内最多 10 次
|
|
67
|
+
"/api/kb/search": (60, 20), # 知识库搜索:60s 内最多 20 次
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _check_rate_limit(ip: str, path: str) -> bool:
|
|
72
|
+
"""检查是否超出频率限制。返回 True 表示被限制。"""
|
|
73
|
+
rule = _RATE_RULES.get(path)
|
|
74
|
+
if not rule:
|
|
75
|
+
return False
|
|
76
|
+
window, max_count = rule
|
|
77
|
+
key = f"{ip}:{path}"
|
|
78
|
+
now = time.time()
|
|
79
|
+
with _rate_lock:
|
|
80
|
+
hits = _rate_limits[key]
|
|
81
|
+
# 清除过期记录
|
|
82
|
+
cutoff = now - window
|
|
83
|
+
_rate_limits[key] = [t_ for t_ in hits if t_ > cutoff]
|
|
84
|
+
if len(_rate_limits[key]) >= max_count:
|
|
85
|
+
return True
|
|
86
|
+
_rate_limits[key].append(now)
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── CSRF Token 管理 ──
|
|
91
|
+
_csrf_tokens: dict[str, float] = {} # {token: timestamp}
|
|
92
|
+
_csrf_lock = __import__("threading").Lock()
|
|
93
|
+
_CSRF_TTL = 3600 # 1 小时有效
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _generate_csrf_token() -> str:
|
|
97
|
+
"""生成 CSRF token"""
|
|
98
|
+
token = secrets.token_urlsafe(32)
|
|
99
|
+
now = time.time()
|
|
100
|
+
with _csrf_lock:
|
|
101
|
+
# 清理过期 token
|
|
102
|
+
expired = [k for k, ts in _csrf_tokens.items() if now - ts > _CSRF_TTL]
|
|
103
|
+
for k in expired:
|
|
104
|
+
del _csrf_tokens[k]
|
|
105
|
+
_csrf_tokens[token] = now
|
|
106
|
+
return token
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _validate_csrf_token(token: str) -> bool:
|
|
110
|
+
"""验证 CSRF token"""
|
|
111
|
+
if not token:
|
|
112
|
+
return False
|
|
113
|
+
with _csrf_lock:
|
|
114
|
+
ts = _csrf_tokens.get(token)
|
|
115
|
+
if ts is None:
|
|
116
|
+
return False
|
|
117
|
+
if time.time() - ts > _CSRF_TTL:
|
|
118
|
+
del _csrf_tokens[token]
|
|
119
|
+
return False
|
|
120
|
+
# 一次性使用:验证后删除
|
|
121
|
+
del _csrf_tokens[token]
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── 计划缓存(内存,最多保留 20 条,5 分钟过期) ──
|
|
126
|
+
_plan_cache: dict[str, tuple[float, dict]] = {} # {plan_id: (timestamp, result)}
|
|
127
|
+
_PLAN_TTL = 300 # 5 分钟
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _make_plan_id() -> str:
|
|
131
|
+
"""生成高熵 plan_id(256-bit 安全随机)"""
|
|
132
|
+
return secrets.token_urlsafe(32)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cache_plan(plan_id: str, result: dict) -> None:
|
|
136
|
+
now = time.time()
|
|
137
|
+
# 先清理过期条目
|
|
138
|
+
expired = [k for k, (ts, _) in _plan_cache.items() if now - ts > _PLAN_TTL]
|
|
139
|
+
for k in expired:
|
|
140
|
+
del _plan_cache[k]
|
|
141
|
+
_plan_cache[plan_id] = (now, result)
|
|
142
|
+
if len(_plan_cache) > 20:
|
|
143
|
+
oldest_key = min(_plan_cache, key=lambda k: _plan_cache[k][0])
|
|
144
|
+
del _plan_cache[oldest_key]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _pop_plan(plan_id: str) -> dict | None:
|
|
148
|
+
"""取出并移除缓存的计划,同时检查是否过期。"""
|
|
149
|
+
entry = _plan_cache.pop(plan_id, None)
|
|
150
|
+
if entry is None:
|
|
151
|
+
return None
|
|
152
|
+
ts, result = entry
|
|
153
|
+
if time.time() - ts > _PLAN_TTL:
|
|
154
|
+
return None # 已过期
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── SSE 并发连接限制(防资源耗尽 DoS) ──
|
|
159
|
+
_active_installs: dict[str, int] = defaultdict(int)
|
|
160
|
+
_active_installs_lock = __import__("threading").Lock()
|
|
161
|
+
MAX_CONCURRENT_INSTALLS_PER_IP = 3
|
|
162
|
+
|
|
163
|
+
# ── 全局并发 plan 生成限制 ──
|
|
164
|
+
_active_plans = 0
|
|
165
|
+
_plan_lock = __import__("threading").Lock()
|
|
166
|
+
MAX_CONCURRENT_PLANS = 3
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ─────────────────────────────────────────────
|
|
170
|
+
# HTTP 服务器
|
|
171
|
+
# ─────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
class _ThreadedServer(ThreadingMixIn, HTTPServer):
|
|
174
|
+
daemon_threads = True
|
|
175
|
+
allow_reuse_address = True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
179
|
+
"""gitinstall Web UI 请求处理器"""
|
|
180
|
+
|
|
181
|
+
# 隐藏服务器版本信息
|
|
182
|
+
server_version = "gitinstall"
|
|
183
|
+
sys_version = ""
|
|
184
|
+
|
|
185
|
+
# 抑制默认日志(使用 logging 替代)
|
|
186
|
+
def log_message(self, fmt, *args):
|
|
187
|
+
logger.debug(fmt, *args)
|
|
188
|
+
|
|
189
|
+
# ── 安全响应头 ────────────────────────────
|
|
190
|
+
|
|
191
|
+
def _add_security_headers(self):
|
|
192
|
+
"""为所有响应添加安全头"""
|
|
193
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
194
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
195
|
+
self.send_header("X-XSS-Protection", "1; mode=block")
|
|
196
|
+
self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
197
|
+
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.loli.net; font-src 'self' https://fonts.gstatic.com https://gstatic.loli.net; img-src 'self' data:; connect-src 'self'")
|
|
198
|
+
|
|
199
|
+
# ── Rate Limiting 检查 ────────────────────
|
|
200
|
+
|
|
201
|
+
def _rate_limited(self, path: str) -> bool:
|
|
202
|
+
"""如果被限制则返回 429 并返回 True"""
|
|
203
|
+
ip = self._client_ip()
|
|
204
|
+
if _check_rate_limit(ip, path):
|
|
205
|
+
body = json.dumps({"status": "error", "message": t("api.rate_limited")}, ensure_ascii=False).encode()
|
|
206
|
+
self.send_response(429)
|
|
207
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
208
|
+
self.send_header("Content-Length", str(len(body)))
|
|
209
|
+
self.send_header("Retry-After", "60")
|
|
210
|
+
self._add_security_headers()
|
|
211
|
+
self.end_headers()
|
|
212
|
+
self.wfile.write(body)
|
|
213
|
+
return True
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
def _check_csrf(self) -> bool:
|
|
217
|
+
"""检查 POST 请求的 CSRF token。返回 True 表示通过。"""
|
|
218
|
+
# Bearer token API 调用免 CSRF(非浏览器场景)
|
|
219
|
+
auth = self.headers.get("Authorization", "")
|
|
220
|
+
if auth.startswith("Bearer "):
|
|
221
|
+
return True
|
|
222
|
+
# 检查 X-CSRF-Token header
|
|
223
|
+
csrf_token = self.headers.get("X-CSRF-Token", "")
|
|
224
|
+
if _validate_csrf_token(csrf_token):
|
|
225
|
+
return True
|
|
226
|
+
# CSRF 验证失败
|
|
227
|
+
body = json.dumps({"status": "error", "message": "CSRF token invalid or missing"}, ensure_ascii=False).encode()
|
|
228
|
+
self.send_response(403)
|
|
229
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
230
|
+
self.send_header("Content-Length", str(len(body)))
|
|
231
|
+
self._add_security_headers()
|
|
232
|
+
self.end_headers()
|
|
233
|
+
self.wfile.write(body)
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
# ── 路由 ──────────────────────────────────
|
|
237
|
+
|
|
238
|
+
def do_GET(self):
|
|
239
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
240
|
+
path = parsed.path
|
|
241
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
242
|
+
|
|
243
|
+
# ── API v1 路由别名(向前兼容)──
|
|
244
|
+
if path.startswith("/api/v1/"):
|
|
245
|
+
path = "/api/" + path[8:] # strip /api/v1/ → /api/
|
|
246
|
+
|
|
247
|
+
routes = {
|
|
248
|
+
"/": self._serve_ui,
|
|
249
|
+
"/admin": self._serve_admin,
|
|
250
|
+
"/clawhub": self._serve_clawhub,
|
|
251
|
+
"/health": self._health_check,
|
|
252
|
+
"/readiness": self._readiness_check,
|
|
253
|
+
"/api/csrf-token": self._api_csrf_token,
|
|
254
|
+
"/api/detect": self._api_detect,
|
|
255
|
+
"/api/doctor": self._api_doctor,
|
|
256
|
+
"/api/platforms": self._api_platforms,
|
|
257
|
+
"/api/trending": self._api_trending,
|
|
258
|
+
"/api/trending/refresh": self._api_trending_refresh,
|
|
259
|
+
"/api/stats": self._api_stats,
|
|
260
|
+
"/api/user": self._api_user,
|
|
261
|
+
"/api/audit": self._api_audit,
|
|
262
|
+
"/api/license": self._api_license,
|
|
263
|
+
"/api/updates": self._api_updates,
|
|
264
|
+
"/api/flags": self._api_flags,
|
|
265
|
+
"/api/registry": self._api_registry,
|
|
266
|
+
"/api/events": self._api_events,
|
|
267
|
+
"/api/kb/stats": self._api_kb_stats,
|
|
268
|
+
}
|
|
269
|
+
if path == "/api/install":
|
|
270
|
+
if self._rate_limited(path):
|
|
271
|
+
return
|
|
272
|
+
return self._api_install_stream(qs)
|
|
273
|
+
if path == "/api/search":
|
|
274
|
+
if self._rate_limited(path):
|
|
275
|
+
return
|
|
276
|
+
return self._api_search(qs)
|
|
277
|
+
handler = routes.get(path)
|
|
278
|
+
if handler:
|
|
279
|
+
handler()
|
|
280
|
+
else:
|
|
281
|
+
self.send_error(404)
|
|
282
|
+
|
|
283
|
+
def do_POST(self):
|
|
284
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
285
|
+
path = parsed.path
|
|
286
|
+
|
|
287
|
+
# ── API v1 路由别名 ──
|
|
288
|
+
if path.startswith("/api/v1/"):
|
|
289
|
+
path = "/api/" + path[8:]
|
|
290
|
+
|
|
291
|
+
if self._rate_limited(path):
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
# CSRF 保护:所有 POST 请求需要 CSRF token 或 Bearer auth
|
|
295
|
+
if not self._check_csrf():
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
post_routes = {
|
|
299
|
+
"/api/plan": self._api_plan,
|
|
300
|
+
"/api/register": self._api_register,
|
|
301
|
+
"/api/login": self._api_login,
|
|
302
|
+
"/api/forgot-password": self._api_forgot_password,
|
|
303
|
+
"/api/reset-password": self._api_reset_password,
|
|
304
|
+
"/api/admin/set": self._api_admin_set,
|
|
305
|
+
"/api/uninstall": self._api_uninstall,
|
|
306
|
+
"/api/chain": self._api_chain,
|
|
307
|
+
"/api/kb/search": self._api_kb_search,
|
|
308
|
+
}
|
|
309
|
+
handler = post_routes.get(path)
|
|
310
|
+
if handler:
|
|
311
|
+
handler()
|
|
312
|
+
else:
|
|
313
|
+
self.send_error(404)
|
|
314
|
+
|
|
315
|
+
# ── 工具:获取客户端 IP ─────────────────
|
|
316
|
+
|
|
317
|
+
def _client_ip(self) -> str:
|
|
318
|
+
# 仅在反向代理场景信任 X-Forwarded-For(由 nginx 设置)
|
|
319
|
+
# 直接暴露时应只用 client_address
|
|
320
|
+
xff = self.headers.get("X-Forwarded-For")
|
|
321
|
+
if xff and self.client_address[0] in ("127.0.0.1", "::1"):
|
|
322
|
+
return xff.split(",")[0].strip()
|
|
323
|
+
return self.client_address[0]
|
|
324
|
+
|
|
325
|
+
# ── 页面 ──────────────────────────────────
|
|
326
|
+
|
|
327
|
+
def _serve_ui(self):
|
|
328
|
+
html_path = _THIS_DIR / "web_ui.html"
|
|
329
|
+
if not html_path.exists():
|
|
330
|
+
self.send_error(500, "web_ui.html not found")
|
|
331
|
+
return
|
|
332
|
+
content = html_path.read_bytes()
|
|
333
|
+
self.send_response(200)
|
|
334
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
335
|
+
self.send_header("Content-Length", str(len(content)))
|
|
336
|
+
self._add_security_headers()
|
|
337
|
+
self.end_headers()
|
|
338
|
+
self.wfile.write(content)
|
|
339
|
+
try:
|
|
340
|
+
_db.record_event("page_view", ip=self._client_ip())
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
def _serve_admin(self):
|
|
345
|
+
html_path = _THIS_DIR / "admin.html"
|
|
346
|
+
if not html_path.exists():
|
|
347
|
+
self.send_error(500, "admin.html not found")
|
|
348
|
+
return
|
|
349
|
+
content = html_path.read_bytes()
|
|
350
|
+
self.send_response(200)
|
|
351
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
352
|
+
self.send_header("Content-Length", str(len(content)))
|
|
353
|
+
self._add_security_headers()
|
|
354
|
+
self.end_headers()
|
|
355
|
+
self.wfile.write(content)
|
|
356
|
+
|
|
357
|
+
def _serve_clawhub(self):
|
|
358
|
+
html_path = _THIS_DIR / "clawhub.html"
|
|
359
|
+
if not html_path.exists():
|
|
360
|
+
self.send_error(500, "clawhub.html not found")
|
|
361
|
+
return
|
|
362
|
+
content = html_path.read_bytes()
|
|
363
|
+
self.send_response(200)
|
|
364
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
365
|
+
self.send_header("Content-Length", str(len(content)))
|
|
366
|
+
self._add_security_headers()
|
|
367
|
+
self.end_headers()
|
|
368
|
+
self.wfile.write(content)
|
|
369
|
+
|
|
370
|
+
# ── Health Check / Readiness ─────────────
|
|
371
|
+
|
|
372
|
+
def _health_check(self):
|
|
373
|
+
"""健康检查端点(用于负载均衡/Kubernetes 探针)"""
|
|
374
|
+
checks = {"status": "ok", "timestamp": time.time()}
|
|
375
|
+
# 检查数据库连接
|
|
376
|
+
try:
|
|
377
|
+
_db.init_db()
|
|
378
|
+
conn = _db._get_conn()
|
|
379
|
+
conn.execute("SELECT 1").fetchone()
|
|
380
|
+
checks["db"] = "ok"
|
|
381
|
+
except Exception:
|
|
382
|
+
checks["db"] = "error"
|
|
383
|
+
checks["status"] = "degraded"
|
|
384
|
+
self._json(checks, 200 if checks["status"] == "ok" else 503)
|
|
385
|
+
|
|
386
|
+
def _readiness_check(self):
|
|
387
|
+
"""就绪检查端点"""
|
|
388
|
+
self._json({"status": "ok", "ready": True, "version": "1.0.0"})
|
|
389
|
+
|
|
390
|
+
# ── CSRF Token 端点 ──────────────────────
|
|
391
|
+
|
|
392
|
+
def _api_csrf_token(self):
|
|
393
|
+
"""获取 CSRF token(GET 请求,浏览器调用后附加到 POST 请求头中)"""
|
|
394
|
+
token = _generate_csrf_token()
|
|
395
|
+
self._json({"csrf_token": token})
|
|
396
|
+
|
|
397
|
+
# ── API: 环境检测 ─────────────────────────
|
|
398
|
+
|
|
399
|
+
def _api_detect(self):
|
|
400
|
+
from detector import EnvironmentDetector
|
|
401
|
+
env = EnvironmentDetector().detect()
|
|
402
|
+
# 只返回必要信息,不暴露完整系统指纹
|
|
403
|
+
safe_env = {
|
|
404
|
+
"os": env.get("os", {}).get("type", ""),
|
|
405
|
+
"arch": env.get("os", {}).get("arch", ""),
|
|
406
|
+
"gpu": env.get("gpu", {}).get("type", "none"),
|
|
407
|
+
"has_python": bool(env.get("runtimes", {}).get("python")),
|
|
408
|
+
"has_node": bool(env.get("runtimes", {}).get("node")),
|
|
409
|
+
"has_docker": bool(env.get("runtimes", {}).get("docker")),
|
|
410
|
+
}
|
|
411
|
+
self._json({"status": "ok", "env": safe_env})
|
|
412
|
+
|
|
413
|
+
# ── API: 环境诊断 ─────────────────────────
|
|
414
|
+
|
|
415
|
+
def _api_doctor(self):
|
|
416
|
+
try:
|
|
417
|
+
from doctor import run_doctor, doctor_to_dict
|
|
418
|
+
report = run_doctor()
|
|
419
|
+
self._json(doctor_to_dict(report))
|
|
420
|
+
except Exception as e:
|
|
421
|
+
logger.warning("doctor failed: %s", e)
|
|
422
|
+
self._json({"status": "error", "message": t("api.query_failed")}, 502)
|
|
423
|
+
|
|
424
|
+
# ── API: 支持平台 ─────────────────────────
|
|
425
|
+
|
|
426
|
+
def _api_platforms(self):
|
|
427
|
+
try:
|
|
428
|
+
from multi_source import get_supported_platforms
|
|
429
|
+
platforms = get_supported_platforms()
|
|
430
|
+
self._json({"status": "ok", "platforms": platforms})
|
|
431
|
+
except Exception as e:
|
|
432
|
+
logger.warning("platforms query failed: %s", e)
|
|
433
|
+
self._json({"status": "error", "message": t("api.query_failed")}, 502)
|
|
434
|
+
|
|
435
|
+
# ── API: 热门项目 ─────────────────────────
|
|
436
|
+
|
|
437
|
+
def _api_trending(self):
|
|
438
|
+
"""返回当前热门开源项目列表(动态爬取 + 缓存)"""
|
|
439
|
+
from trending import get_trending
|
|
440
|
+
projects = get_trending()
|
|
441
|
+
self._json({"status": "ok", "projects": projects})
|
|
442
|
+
try:
|
|
443
|
+
_db.record_event("trending_view", ip=self._client_ip())
|
|
444
|
+
except Exception:
|
|
445
|
+
pass
|
|
446
|
+
|
|
447
|
+
def _api_trending_refresh(self):
|
|
448
|
+
"""强制刷新热门项目缓存(仅管理员)"""
|
|
449
|
+
token = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
|
450
|
+
if not _db.is_admin(token):
|
|
451
|
+
return self._json({"status": "error", "message": t("auth.admin_required")}, 403)
|
|
452
|
+
from trending import get_trending
|
|
453
|
+
projects = get_trending(force_refresh=True)
|
|
454
|
+
self._json({"status": "ok", "projects": projects, "refreshed": True})
|
|
455
|
+
|
|
456
|
+
# ── API: GitHub 搜索 ──────────────────────
|
|
457
|
+
|
|
458
|
+
def _api_search(self, qs: dict):
|
|
459
|
+
"""搜索 GitHub 仓库"""
|
|
460
|
+
query = qs.get("q", [""])[0].strip()
|
|
461
|
+
if not query:
|
|
462
|
+
return self._json({"status": "error", "message": t("api.search_keyword_required")}, 400)
|
|
463
|
+
|
|
464
|
+
url = "https://api.github.com/search/repositories?" + urllib.parse.urlencode({
|
|
465
|
+
"q": query, "sort": "stars", "per_page": "12",
|
|
466
|
+
})
|
|
467
|
+
req = urllib.request.Request(url, headers={
|
|
468
|
+
"Accept": "application/vnd.github.v3+json",
|
|
469
|
+
"User-Agent": "gitinstall/1.0",
|
|
470
|
+
})
|
|
471
|
+
try:
|
|
472
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
473
|
+
data = json.loads(resp.read())
|
|
474
|
+
results = []
|
|
475
|
+
for item in data.get("items", []):
|
|
476
|
+
results.append({
|
|
477
|
+
"repo": item["full_name"],
|
|
478
|
+
"name": item["name"],
|
|
479
|
+
"desc": (item.get("description") or "")[:120],
|
|
480
|
+
"stars": item.get("stargazers_count", 0),
|
|
481
|
+
"lang": item.get("language") or "",
|
|
482
|
+
})
|
|
483
|
+
self._json({"status": "ok", "results": results, "total": data.get("total_count", 0)})
|
|
484
|
+
try:
|
|
485
|
+
_db.record_event("search", project=query, ip=self._client_ip())
|
|
486
|
+
except Exception:
|
|
487
|
+
pass
|
|
488
|
+
except Exception as e:
|
|
489
|
+
logger.warning("搜索失败: %s", e)
|
|
490
|
+
self._json({"status": "error", "message": t("api.search_failed")}, 502)
|
|
491
|
+
|
|
492
|
+
# ── API: 生成计划 ─────────────────────────
|
|
493
|
+
|
|
494
|
+
def _api_plan(self):
|
|
495
|
+
global _active_plans
|
|
496
|
+
# 全局并发 plan 限制(防资源耗尽)
|
|
497
|
+
with _plan_lock:
|
|
498
|
+
if _active_plans >= MAX_CONCURRENT_PLANS:
|
|
499
|
+
return self._json({"status": "error", "message": t("api.system_busy")}, 503)
|
|
500
|
+
_active_plans += 1
|
|
501
|
+
try:
|
|
502
|
+
self._do_api_plan()
|
|
503
|
+
finally:
|
|
504
|
+
with _plan_lock:
|
|
505
|
+
_active_plans -= 1
|
|
506
|
+
|
|
507
|
+
def _do_api_plan(self):
|
|
508
|
+
body = self._read_body()
|
|
509
|
+
try:
|
|
510
|
+
data = json.loads(body)
|
|
511
|
+
except json.JSONDecodeError:
|
|
512
|
+
return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
|
|
513
|
+
|
|
514
|
+
project = data.get("project", "").strip()
|
|
515
|
+
if not project:
|
|
516
|
+
return self._json({"status": "error", "message": t("api.project_required")}, 400)
|
|
517
|
+
|
|
518
|
+
llm = data.get("llm")
|
|
519
|
+
local_mode = data.get("local", False)
|
|
520
|
+
|
|
521
|
+
# 抑制 cmd_plan 的 stderr 输出(进度信息)
|
|
522
|
+
old_stderr = sys.stderr
|
|
523
|
+
sys.stderr = StringIO()
|
|
524
|
+
try:
|
|
525
|
+
from main import cmd_plan
|
|
526
|
+
result = cmd_plan(project, llm_force=llm, use_local=local_mode)
|
|
527
|
+
finally:
|
|
528
|
+
log = sys.stderr.getvalue()
|
|
529
|
+
sys.stderr = old_stderr
|
|
530
|
+
|
|
531
|
+
result["_log"] = log
|
|
532
|
+
|
|
533
|
+
# 缓存计划供 install 使用
|
|
534
|
+
if result.get("status") == "ok":
|
|
535
|
+
plan_id = _make_plan_id()
|
|
536
|
+
_cache_plan(plan_id, result)
|
|
537
|
+
result["plan_id"] = plan_id
|
|
538
|
+
|
|
539
|
+
# 记录事件 + 保存历史
|
|
540
|
+
try:
|
|
541
|
+
plan_data = result.get("plan", {})
|
|
542
|
+
_db.record_event(
|
|
543
|
+
"plan_generated",
|
|
544
|
+
project=project,
|
|
545
|
+
detail={"strategy": result.get("strategy"), "confidence": result.get("confidence")},
|
|
546
|
+
ip=self._client_ip(),
|
|
547
|
+
)
|
|
548
|
+
_db.save_plan_history(
|
|
549
|
+
project=project,
|
|
550
|
+
strategy=result.get("strategy"),
|
|
551
|
+
confidence=result.get("confidence"),
|
|
552
|
+
steps=plan_data.get("steps"),
|
|
553
|
+
)
|
|
554
|
+
except Exception:
|
|
555
|
+
pass
|
|
556
|
+
|
|
557
|
+
# 保护核心技术:不向客户端暴露内部策略/调试信息
|
|
558
|
+
for _k in ("_log", "strategy", "confidence", "_stderr"):
|
|
559
|
+
result.pop(_k, None)
|
|
560
|
+
|
|
561
|
+
self._json(result)
|
|
562
|
+
|
|
563
|
+
# ── API: 安装(SSE 流) ──────────────────
|
|
564
|
+
|
|
565
|
+
def _api_install_stream(self, qs: dict):
|
|
566
|
+
plan_id = qs.get("plan_id", [""])[0]
|
|
567
|
+
project = qs.get("project", [""])[0]
|
|
568
|
+
install_dir = qs.get("install_dir", [""])[0]
|
|
569
|
+
|
|
570
|
+
if not plan_id and not project:
|
|
571
|
+
self.send_error(400, "Missing plan_id or project")
|
|
572
|
+
return
|
|
573
|
+
|
|
574
|
+
# 并发安装限制(防资源耗尽)
|
|
575
|
+
ip = self._client_ip()
|
|
576
|
+
with _active_installs_lock:
|
|
577
|
+
if _active_installs[ip] >= MAX_CONCURRENT_INSTALLS_PER_IP:
|
|
578
|
+
body = json.dumps({"status": "error", "message": t("api.too_many_installs")}, ensure_ascii=False).encode()
|
|
579
|
+
self.send_response(429)
|
|
580
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
581
|
+
self.send_header("Content-Length", str(len(body)))
|
|
582
|
+
self._add_security_headers()
|
|
583
|
+
self.end_headers()
|
|
584
|
+
self.wfile.write(body)
|
|
585
|
+
return
|
|
586
|
+
_active_installs[ip] += 1
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
self._do_install_stream(qs, plan_id, project, install_dir)
|
|
590
|
+
finally:
|
|
591
|
+
with _active_installs_lock:
|
|
592
|
+
_active_installs[ip] = max(0, _active_installs[ip] - 1)
|
|
593
|
+
|
|
594
|
+
def _do_install_stream(self, qs: dict, plan_id: str, project: str, install_dir: str):
|
|
595
|
+
|
|
596
|
+
# SSE headers
|
|
597
|
+
self.send_response(200)
|
|
598
|
+
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
599
|
+
self.send_header("Cache-Control", "no-cache")
|
|
600
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
601
|
+
self._add_security_headers()
|
|
602
|
+
self.end_headers()
|
|
603
|
+
|
|
604
|
+
alive = True
|
|
605
|
+
|
|
606
|
+
def sse(event: str, data: dict) -> bool:
|
|
607
|
+
nonlocal alive
|
|
608
|
+
if not alive:
|
|
609
|
+
return False
|
|
610
|
+
msg = f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
611
|
+
try:
|
|
612
|
+
self.wfile.write(msg.encode())
|
|
613
|
+
self.wfile.flush()
|
|
614
|
+
return True
|
|
615
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
616
|
+
alive = False
|
|
617
|
+
return False
|
|
618
|
+
|
|
619
|
+
# 1. 取计划(优先用缓存)
|
|
620
|
+
plan_result = _pop_plan(plan_id) if plan_id else None
|
|
621
|
+
|
|
622
|
+
if not plan_result:
|
|
623
|
+
if not project:
|
|
624
|
+
sse("step_error", {"message": t("install.plan_expired")})
|
|
625
|
+
sse("done", {"success": False, "message": t("install.plan_expired")})
|
|
626
|
+
return
|
|
627
|
+
sse("phase", {"name": "plan", "message": t("install.regenerating_plan")})
|
|
628
|
+
old_stderr = sys.stderr
|
|
629
|
+
sys.stderr = StringIO()
|
|
630
|
+
try:
|
|
631
|
+
from main import cmd_plan
|
|
632
|
+
plan_result = cmd_plan(project)
|
|
633
|
+
finally:
|
|
634
|
+
sys.stderr = old_stderr
|
|
635
|
+
if plan_result.get("status") != "ok":
|
|
636
|
+
sse("step_error", {"message": plan_result.get("message", t("install.plan_failed"))})
|
|
637
|
+
sse("done", {"success": False, "message": t("install.plan_failed")})
|
|
638
|
+
return
|
|
639
|
+
|
|
640
|
+
plan = plan_result["plan"]
|
|
641
|
+
steps = plan.get("steps", [])
|
|
642
|
+
|
|
643
|
+
if not steps:
|
|
644
|
+
sse("step_error", {"message": t("install.no_steps")})
|
|
645
|
+
sse("done", {"success": False, "message": t("install.no_steps")})
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
sse("plan", {
|
|
649
|
+
"project": plan_result.get("project", project),
|
|
650
|
+
"steps": steps,
|
|
651
|
+
"launch_command": plan.get("launch_command", ""),
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
# 2. 逐步执行
|
|
655
|
+
home = os.path.realpath(os.path.expanduser("~"))
|
|
656
|
+
if install_dir:
|
|
657
|
+
work_dir = os.path.realpath(os.path.expanduser(install_dir))
|
|
658
|
+
# 安全检查:只允许在用户家目录下安装
|
|
659
|
+
if not work_dir.startswith(home + os.sep) and work_dir != home:
|
|
660
|
+
sse("step_error", {"message": t("install.dir_security")})
|
|
661
|
+
sse("done", {"success": False, "message": t("install.dir_invalid")})
|
|
662
|
+
return
|
|
663
|
+
os.makedirs(work_dir, exist_ok=True)
|
|
664
|
+
# TOCTOU 防护:mkdir 后再次验证路径
|
|
665
|
+
work_dir = os.path.realpath(work_dir)
|
|
666
|
+
if not work_dir.startswith(home + os.sep) and work_dir != home:
|
|
667
|
+
sse("step_error", {"message": t("install.dir_path_changed")})
|
|
668
|
+
sse("done", {"success": False, "message": t("install.dir_invalid")})
|
|
669
|
+
return
|
|
670
|
+
else:
|
|
671
|
+
work_dir = home
|
|
672
|
+
all_ok = True
|
|
673
|
+
t_total = time.time()
|
|
674
|
+
|
|
675
|
+
from executor import check_command_safety
|
|
676
|
+
|
|
677
|
+
for i, step in enumerate(steps):
|
|
678
|
+
cmd = step.get("command", "").strip()
|
|
679
|
+
desc = step.get("description", "")
|
|
680
|
+
|
|
681
|
+
if not sse("step_start", {
|
|
682
|
+
"index": i, "total": len(steps),
|
|
683
|
+
"description": desc, "command": cmd,
|
|
684
|
+
}):
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
if not cmd:
|
|
688
|
+
sse("step_done", {"index": i, "success": True, "duration": 0})
|
|
689
|
+
continue
|
|
690
|
+
|
|
691
|
+
# 命令安全检查
|
|
692
|
+
is_safe, safety_msg = check_command_safety(cmd)
|
|
693
|
+
if not is_safe:
|
|
694
|
+
sse("step_error", {"index": i, "message": safety_msg})
|
|
695
|
+
sse("done", {"success": False, "message": t("install.unsafe_command")})
|
|
696
|
+
return
|
|
697
|
+
|
|
698
|
+
t0 = time.time()
|
|
699
|
+
try:
|
|
700
|
+
proc = subprocess.Popen(
|
|
701
|
+
cmd, shell=True,
|
|
702
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
703
|
+
cwd=work_dir, text=True, bufsize=1,
|
|
704
|
+
env={**os.environ, "PYTHONUNBUFFERED": "1"},
|
|
705
|
+
)
|
|
706
|
+
output_lines = 0
|
|
707
|
+
MAX_OUTPUT_LINES = 10000
|
|
708
|
+
for line in proc.stdout:
|
|
709
|
+
output_lines += 1
|
|
710
|
+
if output_lines > MAX_OUTPUT_LINES:
|
|
711
|
+
if output_lines == MAX_OUTPUT_LINES + 1:
|
|
712
|
+
sse("output", {"index": i, "line": t("install.output_truncated")})
|
|
713
|
+
continue
|
|
714
|
+
if not sse("output", {"index": i, "line": line.rstrip("\n")}):
|
|
715
|
+
proc.kill()
|
|
716
|
+
return
|
|
717
|
+
proc.wait(timeout=600)
|
|
718
|
+
dur = round(time.time() - t0, 1)
|
|
719
|
+
ok = proc.returncode == 0
|
|
720
|
+
|
|
721
|
+
sse("step_done", {
|
|
722
|
+
"index": i, "success": ok,
|
|
723
|
+
"exit_code": proc.returncode, "duration": dur,
|
|
724
|
+
})
|
|
725
|
+
if not ok:
|
|
726
|
+
all_ok = False
|
|
727
|
+
sse("step_error", {
|
|
728
|
+
"index": i,
|
|
729
|
+
"message": t("install.cmd_exit_code", code=proc.returncode),
|
|
730
|
+
})
|
|
731
|
+
break
|
|
732
|
+
|
|
733
|
+
except subprocess.TimeoutExpired:
|
|
734
|
+
proc.kill()
|
|
735
|
+
all_ok = False
|
|
736
|
+
sse("step_done", {"index": i, "success": False, "duration": 600})
|
|
737
|
+
sse("step_error", {"index": i, "message": t("install.cmd_timeout", minutes=10)})
|
|
738
|
+
break
|
|
739
|
+
except Exception as e:
|
|
740
|
+
all_ok = False
|
|
741
|
+
dur = round(time.time() - t0, 1)
|
|
742
|
+
sse("step_done", {"index": i, "success": False, "duration": dur})
|
|
743
|
+
sse("step_error", {"index": i, "message": str(e)})
|
|
744
|
+
break
|
|
745
|
+
|
|
746
|
+
total_dur = round(time.time() - t_total, 1)
|
|
747
|
+
launch = plan.get("launch_command", "")
|
|
748
|
+
sse("done", {
|
|
749
|
+
"success": all_ok,
|
|
750
|
+
"message": t("install.complete") if all_ok else t("install.not_complete"),
|
|
751
|
+
"launch_command": launch,
|
|
752
|
+
"total_duration": total_dur,
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
# 记录安装结果
|
|
756
|
+
try:
|
|
757
|
+
evt = "install_done" if all_ok else "install_failed"
|
|
758
|
+
proj = plan_result.get("project", project)
|
|
759
|
+
_db.record_event(evt, project=proj, detail={"duration": total_dur}, ip=self._client_ip())
|
|
760
|
+
except Exception:
|
|
761
|
+
pass
|
|
762
|
+
|
|
763
|
+
# ── API: 统计信息 ─────────────────
|
|
764
|
+
|
|
765
|
+
def _api_stats(self):
|
|
766
|
+
"""返回统计信息(仅管理员)"""
|
|
767
|
+
token = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
|
768
|
+
if not _db.is_admin(token):
|
|
769
|
+
return self._json({"status": "error", "message": t("auth.admin_required")}, 403)
|
|
770
|
+
try:
|
|
771
|
+
stats = _db.get_stats()
|
|
772
|
+
stats["recent_installs"] = _db.get_recent_installs(10)
|
|
773
|
+
self._json({"status": "ok", **stats})
|
|
774
|
+
except Exception:
|
|
775
|
+
logger.exception("获取统计信息失败")
|
|
776
|
+
self._json({"status": "error", "message": t("server.stats_error")}, 500)
|
|
777
|
+
|
|
778
|
+
# ── API: 设置管理员 ───────────────
|
|
779
|
+
|
|
780
|
+
def _api_admin_set(self):
|
|
781
|
+
"""
|
|
782
|
+
首次设置管理员:需要环境变量 GITINSTALL_ADMIN_SECRET 匹配。
|
|
783
|
+
后续可由已有管理员提升他人。
|
|
784
|
+
"""
|
|
785
|
+
body = self._read_body()
|
|
786
|
+
try:
|
|
787
|
+
data = json.loads(body)
|
|
788
|
+
except json.JSONDecodeError:
|
|
789
|
+
return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
|
|
790
|
+
|
|
791
|
+
target_user_id = data.get("user_id")
|
|
792
|
+
if not target_user_id:
|
|
793
|
+
return self._json({"status": "error", "message": t("api.missing_user_id")}, 400)
|
|
794
|
+
|
|
795
|
+
# 方式 1:通过 admin_secret(首次设置管理员)
|
|
796
|
+
admin_secret = data.get("admin_secret", "")
|
|
797
|
+
env_secret = os.environ.get("GITINSTALL_ADMIN_SECRET", "")
|
|
798
|
+
if env_secret and admin_secret == env_secret:
|
|
799
|
+
_db.set_admin(target_user_id, True)
|
|
800
|
+
return self._json({"status": "ok", "message": t("auth.admin_set_ok")})
|
|
801
|
+
|
|
802
|
+
# 方式 2:已有管理员授权
|
|
803
|
+
token = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
|
804
|
+
if _db.is_admin(token):
|
|
805
|
+
_db.set_admin(target_user_id, True)
|
|
806
|
+
return self._json({"status": "ok", "message": t("auth.admin_set_ok")})
|
|
807
|
+
|
|
808
|
+
self._json({"status": "error", "message": t("auth.no_permission")}, 403)
|
|
809
|
+
|
|
810
|
+
# ── API: 用户信息 ─────────────────
|
|
811
|
+
|
|
812
|
+
def _api_user(self):
|
|
813
|
+
"""获取当前用户信息 + 配额"""
|
|
814
|
+
token = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
|
815
|
+
user = _db.validate_token(token) if token else None
|
|
816
|
+
quota = _db.check_quota(
|
|
817
|
+
user_id=user["id"] if user else None,
|
|
818
|
+
ip=self._client_ip(),
|
|
819
|
+
)
|
|
820
|
+
result = {"status": "ok", "quota": quota}
|
|
821
|
+
if user:
|
|
822
|
+
result["user"] = {"id": user["id"], "username": user["username"], "tier": user["tier"]}
|
|
823
|
+
self._json(result)
|
|
824
|
+
|
|
825
|
+
# ── API: 注册 ─────────────────────
|
|
826
|
+
|
|
827
|
+
def _api_register(self):
|
|
828
|
+
body = self._read_body()
|
|
829
|
+
try:
|
|
830
|
+
data = json.loads(body)
|
|
831
|
+
except json.JSONDecodeError:
|
|
832
|
+
return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
|
|
833
|
+
result = _db.register_user(
|
|
834
|
+
username=data.get("username", ""),
|
|
835
|
+
email=data.get("email", ""),
|
|
836
|
+
password=data.get("password", ""),
|
|
837
|
+
)
|
|
838
|
+
code = 200 if result["status"] == "ok" else 400
|
|
839
|
+
# 注册成功后发送欢迎邮件(后台发送,不阻塞响应)
|
|
840
|
+
if result["status"] == "ok":
|
|
841
|
+
try:
|
|
842
|
+
import threading as _th
|
|
843
|
+
_th.Thread(
|
|
844
|
+
target=_db.send_welcome_email,
|
|
845
|
+
args=(data.get("email", ""), data.get("username", "")),
|
|
846
|
+
daemon=True,
|
|
847
|
+
).start()
|
|
848
|
+
except Exception:
|
|
849
|
+
pass
|
|
850
|
+
self._json(result, code)
|
|
851
|
+
|
|
852
|
+
# ── API: 登录 ─────────────────────
|
|
853
|
+
|
|
854
|
+
def _api_login(self):
|
|
855
|
+
body = self._read_body()
|
|
856
|
+
try:
|
|
857
|
+
data = json.loads(body)
|
|
858
|
+
except json.JSONDecodeError:
|
|
859
|
+
return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
|
|
860
|
+
result = _db.login_user(
|
|
861
|
+
email=data.get("email", ""),
|
|
862
|
+
password=data.get("password", ""),
|
|
863
|
+
)
|
|
864
|
+
code = 200 if result["status"] == "ok" else 401
|
|
865
|
+
self._json(result, code)
|
|
866
|
+
|
|
867
|
+
# ── API: 忘记密码 ─────────────────
|
|
868
|
+
|
|
869
|
+
def _api_forgot_password(self):
|
|
870
|
+
body = self._read_body()
|
|
871
|
+
try:
|
|
872
|
+
data = json.loads(body)
|
|
873
|
+
except json.JSONDecodeError:
|
|
874
|
+
return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
|
|
875
|
+
email = data.get("email", "").strip()
|
|
876
|
+
if not email:
|
|
877
|
+
return self._json({"status": "error", "message": t("auth.enter_email")}, 400)
|
|
878
|
+
|
|
879
|
+
result = _db.create_reset_token(email)
|
|
880
|
+
if result["token"]:
|
|
881
|
+
# 后台发送重置邮件
|
|
882
|
+
try:
|
|
883
|
+
import threading as _th
|
|
884
|
+
_th.Thread(
|
|
885
|
+
target=_db.send_reset_email,
|
|
886
|
+
args=(email, result["username"], result["token"]),
|
|
887
|
+
daemon=True,
|
|
888
|
+
).start()
|
|
889
|
+
except Exception:
|
|
890
|
+
pass
|
|
891
|
+
# 无论邮箱是否存在,统一返回成功(防止枚举邮箱)
|
|
892
|
+
self._json({"status": "ok", "message": t("auth.reset_email_sent")})
|
|
893
|
+
|
|
894
|
+
# ── API: 重置密码 ─────────────────
|
|
895
|
+
|
|
896
|
+
def _api_reset_password(self):
|
|
897
|
+
body = self._read_body()
|
|
898
|
+
try:
|
|
899
|
+
data = json.loads(body)
|
|
900
|
+
except json.JSONDecodeError:
|
|
901
|
+
return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
|
|
902
|
+
token = data.get("token", "")
|
|
903
|
+
new_password = data.get("password", "")
|
|
904
|
+
if not token or not new_password:
|
|
905
|
+
return self._json({"status": "error", "message": t("auth.params_incomplete")}, 400)
|
|
906
|
+
result = _db.reset_password(token, new_password)
|
|
907
|
+
code = 200 if result["status"] == "ok" else 400
|
|
908
|
+
self._json(result, code)
|
|
909
|
+
|
|
910
|
+
# ── API: 依赖安全审计 ─────────────────────
|
|
911
|
+
|
|
912
|
+
def _api_audit(self):
|
|
913
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
914
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
915
|
+
project = qs.get("project", [""])[0].strip()
|
|
916
|
+
if not project:
|
|
917
|
+
return self._json({"status": "error", "message": t("api.missing_param", param="project")}, 400)
|
|
918
|
+
if not re.match(r'^[\w\-\.]+/[\w\-\.]+$', project):
|
|
919
|
+
return self._json({"status": "error", "message": t("api.param_format_error")}, 400)
|
|
920
|
+
try:
|
|
921
|
+
from fetcher import fetch_project
|
|
922
|
+
from dependency_audit import audit_project, audit_to_dict
|
|
923
|
+
info = fetch_project(project)
|
|
924
|
+
if not info.dependency_files:
|
|
925
|
+
return self._json({"status": "ok", "message": t("audit.no_deps"), "results": []})
|
|
926
|
+
results = audit_project(info.dependency_files)
|
|
927
|
+
self._json({"status": "ok", **audit_to_dict(results)})
|
|
928
|
+
try:
|
|
929
|
+
_db.record_event("audit", project=project, ip=self._client_ip())
|
|
930
|
+
except Exception:
|
|
931
|
+
pass
|
|
932
|
+
except Exception as e:
|
|
933
|
+
logger.warning("audit failed: %s", e)
|
|
934
|
+
self._json({"status": "error", "message": t("audit.failed")}, 502)
|
|
935
|
+
|
|
936
|
+
# ── API: 许可证检查 ────────────────────────
|
|
937
|
+
|
|
938
|
+
def _api_license(self):
|
|
939
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
940
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
941
|
+
project = qs.get("project", [""])[0].strip()
|
|
942
|
+
if not project:
|
|
943
|
+
return self._json({"status": "error", "message": t("api.missing_param", param="project")}, 400)
|
|
944
|
+
if not re.match(r'^[\w\-\.]+/[\w\-\.]+$', project):
|
|
945
|
+
return self._json({"status": "error", "message": t("api.param_format_error")}, 400)
|
|
946
|
+
try:
|
|
947
|
+
parts = project.split("/")
|
|
948
|
+
from license_check import fetch_license_from_github, analyze_license, license_to_dict
|
|
949
|
+
spdx_id, license_text = fetch_license_from_github(parts[0], parts[1])
|
|
950
|
+
if not spdx_id and not license_text:
|
|
951
|
+
return self._json({"status": "ok", "message": t("license.no_license"), "risk": "warning"})
|
|
952
|
+
result = analyze_license(spdx_id, license_text)
|
|
953
|
+
self._json({"status": "ok", **license_to_dict(result)})
|
|
954
|
+
try:
|
|
955
|
+
_db.record_event("license_check", project=project, ip=self._client_ip())
|
|
956
|
+
except Exception:
|
|
957
|
+
pass
|
|
958
|
+
except Exception as e:
|
|
959
|
+
logger.warning("license check failed: %s", e)
|
|
960
|
+
self._json({"status": "error", "message": t("license.check_failed")}, 502)
|
|
961
|
+
|
|
962
|
+
# ── API: 更新检查 ──────────────────────────
|
|
963
|
+
|
|
964
|
+
def _api_updates(self):
|
|
965
|
+
try:
|
|
966
|
+
from auto_update import InstallTracker, check_all_updates, updates_to_dict
|
|
967
|
+
tracker = InstallTracker()
|
|
968
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
969
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
970
|
+
action = qs.get("action", ["list"])[0]
|
|
971
|
+
|
|
972
|
+
if action == "check":
|
|
973
|
+
results = check_all_updates(tracker)
|
|
974
|
+
self._json({"status": "ok", **updates_to_dict(results)})
|
|
975
|
+
else:
|
|
976
|
+
projects = tracker.list_installed()
|
|
977
|
+
self._json({
|
|
978
|
+
"status": "ok",
|
|
979
|
+
"installed": [p.to_dict() for p in projects],
|
|
980
|
+
"total": len(projects),
|
|
981
|
+
})
|
|
982
|
+
try:
|
|
983
|
+
_db.record_event("updates_check", ip=self._client_ip())
|
|
984
|
+
except Exception:
|
|
985
|
+
pass
|
|
986
|
+
except Exception as e:
|
|
987
|
+
logger.warning("update check failed: %s", e)
|
|
988
|
+
self._json({"status": "error", "message": t("update.check_failed")}, 502)
|
|
989
|
+
|
|
990
|
+
# ── API: 卸载项目 ──────────────────────────
|
|
991
|
+
|
|
992
|
+
def _api_uninstall(self):
|
|
993
|
+
body = self._read_body()
|
|
994
|
+
try:
|
|
995
|
+
data = json.loads(body)
|
|
996
|
+
except json.JSONDecodeError:
|
|
997
|
+
return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
|
|
998
|
+
|
|
999
|
+
project = data.get("project", "").strip()
|
|
1000
|
+
if not project:
|
|
1001
|
+
return self._json({"status": "error", "message": t("api.missing_param", param="project")}, 400)
|
|
1002
|
+
if not re.match(r'^[\w\-\.]+/[\w\-\.]+$', project):
|
|
1003
|
+
return self._json({"status": "error", "message": t("api.param_format_error")}, 400)
|
|
1004
|
+
|
|
1005
|
+
keep_config = data.get("keep_config", False)
|
|
1006
|
+
confirm = data.get("confirm", False)
|
|
1007
|
+
|
|
1008
|
+
try:
|
|
1009
|
+
from auto_update import InstallTracker
|
|
1010
|
+
from uninstaller import plan_uninstall, execute_uninstall, uninstall_to_dict
|
|
1011
|
+
from fetcher import parse_repo_identifier
|
|
1012
|
+
|
|
1013
|
+
owner, repo = parse_repo_identifier(project)
|
|
1014
|
+
tracker = InstallTracker()
|
|
1015
|
+
proj = tracker.get_project(owner, repo)
|
|
1016
|
+
|
|
1017
|
+
if not proj:
|
|
1018
|
+
return self._json({"status": "error", "message": t("uninstall.not_found", project=project)}, 404)
|
|
1019
|
+
|
|
1020
|
+
plan = plan_uninstall(owner, repo, proj.install_dir,
|
|
1021
|
+
keep_config=keep_config, clean_only=False)
|
|
1022
|
+
|
|
1023
|
+
if not confirm:
|
|
1024
|
+
return self._json({"status": "ok", "action": "dry_run", **uninstall_to_dict(plan)})
|
|
1025
|
+
|
|
1026
|
+
result = execute_uninstall(plan, keep_config=keep_config)
|
|
1027
|
+
if result["success"]:
|
|
1028
|
+
tracker.remove_project(owner, repo)
|
|
1029
|
+
|
|
1030
|
+
self._json({"status": "ok" if result["success"] else "partial", **result})
|
|
1031
|
+
try:
|
|
1032
|
+
_db.record_event("uninstall", project=project, ip=self._client_ip())
|
|
1033
|
+
except Exception:
|
|
1034
|
+
pass
|
|
1035
|
+
except Exception as e:
|
|
1036
|
+
logger.warning("uninstall failed: %s", e)
|
|
1037
|
+
self._json({"status": "error", "message": t("uninstall.failed")}, 502)
|
|
1038
|
+
|
|
1039
|
+
# ── API: 功能开关 ─────────────────────────
|
|
1040
|
+
|
|
1041
|
+
def _api_flags(self):
|
|
1042
|
+
try:
|
|
1043
|
+
from feature_flags import get_all_status, format_flags_table
|
|
1044
|
+
status = get_all_status()
|
|
1045
|
+
self._json({"status": "ok", "flags": status})
|
|
1046
|
+
except Exception as e:
|
|
1047
|
+
logger.warning("flags query failed: %s", e)
|
|
1048
|
+
self._json({"status": "error", "message": t("api.query_failed")}, 502)
|
|
1049
|
+
|
|
1050
|
+
# ── API: 安装器注册表 ─────────────────────
|
|
1051
|
+
|
|
1052
|
+
def _api_registry(self):
|
|
1053
|
+
try:
|
|
1054
|
+
from installer_registry import InstallerRegistry
|
|
1055
|
+
registry = InstallerRegistry()
|
|
1056
|
+
self._json({
|
|
1057
|
+
"status": "ok",
|
|
1058
|
+
"total": len(registry.list_all()),
|
|
1059
|
+
"available": [i.info.name for i in registry.list_available()],
|
|
1060
|
+
"installers": registry.to_dict(),
|
|
1061
|
+
})
|
|
1062
|
+
except Exception as e:
|
|
1063
|
+
logger.warning("registry query failed: %s", e)
|
|
1064
|
+
self._json({"status": "error", "message": t("api.query_failed")}, 502)
|
|
1065
|
+
|
|
1066
|
+
# ── API: 事件历史 ─────────────────────────
|
|
1067
|
+
|
|
1068
|
+
def _api_events(self):
|
|
1069
|
+
try:
|
|
1070
|
+
from event_bus import get_event_bus
|
|
1071
|
+
bus = get_event_bus()
|
|
1072
|
+
history = bus.get_history(limit=50)
|
|
1073
|
+
self._json({
|
|
1074
|
+
"status": "ok",
|
|
1075
|
+
"events": [e.to_dict() for e in history],
|
|
1076
|
+
"total": len(history),
|
|
1077
|
+
})
|
|
1078
|
+
except Exception as e:
|
|
1079
|
+
logger.warning("events query failed: %s", e)
|
|
1080
|
+
self._json({"status": "error", "message": t("api.query_failed")}, 502)
|
|
1081
|
+
|
|
1082
|
+
# ── API: 知识库统计 ───────────────────────
|
|
1083
|
+
|
|
1084
|
+
def _api_kb_stats(self):
|
|
1085
|
+
try:
|
|
1086
|
+
from knowledge_base import KnowledgeBase
|
|
1087
|
+
kb = KnowledgeBase()
|
|
1088
|
+
stats = kb.get_stats()
|
|
1089
|
+
self._json({"status": "ok", **stats})
|
|
1090
|
+
except Exception as e:
|
|
1091
|
+
logger.warning("kb stats query failed: %s", e)
|
|
1092
|
+
self._json({"status": "error", "message": t("api.query_failed")}, 502)
|
|
1093
|
+
|
|
1094
|
+
# ── API: 知识库搜索 ───────────────────────
|
|
1095
|
+
|
|
1096
|
+
def _api_kb_search(self):
|
|
1097
|
+
try:
|
|
1098
|
+
body = json.loads(self._read_body())
|
|
1099
|
+
except (json.JSONDecodeError, ValueError):
|
|
1100
|
+
return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
|
|
1101
|
+
query = str(body.get("query", "")).strip()
|
|
1102
|
+
if not query:
|
|
1103
|
+
return self._json({"status": "error", "message": t("api.missing_param", param="query")}, 400)
|
|
1104
|
+
try:
|
|
1105
|
+
from knowledge_base import KnowledgeBase
|
|
1106
|
+
kb = KnowledgeBase()
|
|
1107
|
+
results = kb.search(project=query, limit=10)
|
|
1108
|
+
self._json({
|
|
1109
|
+
"status": "ok",
|
|
1110
|
+
"results": [{
|
|
1111
|
+
"project": r.entry.project,
|
|
1112
|
+
"score": r.score,
|
|
1113
|
+
"success": r.entry.success,
|
|
1114
|
+
"strategy": r.entry.strategy,
|
|
1115
|
+
"reasons": r.match_reasons,
|
|
1116
|
+
} for r in results],
|
|
1117
|
+
})
|
|
1118
|
+
except Exception as e:
|
|
1119
|
+
logger.warning("kb search failed: %s", e)
|
|
1120
|
+
self._json({"status": "error", "message": t("api.search_failed")}, 502)
|
|
1121
|
+
|
|
1122
|
+
# ── API: 依赖链分析 ───────────────────────
|
|
1123
|
+
|
|
1124
|
+
def _api_chain(self):
|
|
1125
|
+
try:
|
|
1126
|
+
body = json.loads(self._read_body())
|
|
1127
|
+
except (json.JSONDecodeError, ValueError):
|
|
1128
|
+
return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
|
|
1129
|
+
project = str(body.get("project", "")).strip()
|
|
1130
|
+
if not project:
|
|
1131
|
+
return self._json({"status": "error", "message": t("api.missing_param", param="project")}, 400)
|
|
1132
|
+
try:
|
|
1133
|
+
from main import cmd_plan
|
|
1134
|
+
from dep_chain import build_chain_from_plan
|
|
1135
|
+
plan_result = cmd_plan(project)
|
|
1136
|
+
if plan_result.get("status") != "ok":
|
|
1137
|
+
return self._json(plan_result, 400)
|
|
1138
|
+
chain = build_chain_from_plan(plan_result["plan"])
|
|
1139
|
+
self._json({
|
|
1140
|
+
"status": "ok",
|
|
1141
|
+
"project": project,
|
|
1142
|
+
"chain": chain.to_dict(),
|
|
1143
|
+
"has_cycle": chain.has_cycle(),
|
|
1144
|
+
})
|
|
1145
|
+
except Exception as e:
|
|
1146
|
+
logger.warning("chain analysis failed: %s", e)
|
|
1147
|
+
self._json({"status": "error", "message": t("api.query_failed")}, 502)
|
|
1148
|
+
|
|
1149
|
+
# ── 工具方法 ──────────────────────────────
|
|
1150
|
+
|
|
1151
|
+
def _json(self, data: dict, code: int = 200):
|
|
1152
|
+
body = json.dumps(data, ensure_ascii=False, indent=2).encode()
|
|
1153
|
+
self.send_response(code)
|
|
1154
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1155
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1156
|
+
self._add_security_headers()
|
|
1157
|
+
self.end_headers()
|
|
1158
|
+
self.wfile.write(body)
|
|
1159
|
+
|
|
1160
|
+
def _read_body(self) -> str:
|
|
1161
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
1162
|
+
if length > MAX_BODY_SIZE:
|
|
1163
|
+
raise ValueError(t("api.body_too_large"))
|
|
1164
|
+
if length <= 0:
|
|
1165
|
+
return ""
|
|
1166
|
+
return self.rfile.read(length).decode("utf-8")
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
# ─────────────────────────────────────────────
|
|
1170
|
+
# 启动入口
|
|
1171
|
+
# ─────────────────────────────────────────────
|
|
1172
|
+
|
|
1173
|
+
def start_server(
|
|
1174
|
+
port: int = 8080,
|
|
1175
|
+
host: str = "",
|
|
1176
|
+
open_browser: bool = True,
|
|
1177
|
+
ssl_certfile: str = None,
|
|
1178
|
+
ssl_keyfile: str = None,
|
|
1179
|
+
):
|
|
1180
|
+
"""启动 gitinstall Web UI 服务器
|
|
1181
|
+
|
|
1182
|
+
Args:
|
|
1183
|
+
port: 端口号,默认 8080
|
|
1184
|
+
host: 绑定地址,默认读取 GITINSTALL_HOST 环境变量,否则 127.0.0.1
|
|
1185
|
+
open_browser: 是否自动打开浏览器
|
|
1186
|
+
ssl_certfile: TLS 证书文件路径(启用 HTTPS)
|
|
1187
|
+
ssl_keyfile: TLS 私钥文件路径
|
|
1188
|
+
"""
|
|
1189
|
+
from log import configure
|
|
1190
|
+
configure()
|
|
1191
|
+
|
|
1192
|
+
bind_host = host or os.environ.get("GITINSTALL_HOST", "127.0.0.1")
|
|
1193
|
+
|
|
1194
|
+
# 环境变量覆盖 TLS 配置
|
|
1195
|
+
ssl_certfile = ssl_certfile or os.environ.get("GITINSTALL_TLS_CERT", "")
|
|
1196
|
+
ssl_keyfile = ssl_keyfile or os.environ.get("GITINSTALL_TLS_KEY", "")
|
|
1197
|
+
use_tls = bool(ssl_certfile and ssl_keyfile)
|
|
1198
|
+
|
|
1199
|
+
# 尝试多个端口
|
|
1200
|
+
server = None
|
|
1201
|
+
for p in range(port, port + 10):
|
|
1202
|
+
try:
|
|
1203
|
+
server = _ThreadedServer((bind_host, p), _Handler)
|
|
1204
|
+
port = p
|
|
1205
|
+
break
|
|
1206
|
+
except OSError:
|
|
1207
|
+
continue
|
|
1208
|
+
|
|
1209
|
+
if not server:
|
|
1210
|
+
print(t("server.port_unavailable", start=port, end=port + 9))
|
|
1211
|
+
sys.exit(1)
|
|
1212
|
+
|
|
1213
|
+
# 启用 HTTPS/TLS
|
|
1214
|
+
if use_tls:
|
|
1215
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
1216
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
1217
|
+
ctx.load_cert_chain(ssl_certfile, ssl_keyfile)
|
|
1218
|
+
server.socket = ctx.wrap_socket(server.socket, server_side=True)
|
|
1219
|
+
|
|
1220
|
+
protocol = "https" if use_tls else "http"
|
|
1221
|
+
display_host = bind_host if bind_host != "0.0.0.0" else "127.0.0.1"
|
|
1222
|
+
url = f"{protocol}://{display_host}:{port}"
|
|
1223
|
+
print()
|
|
1224
|
+
print(" ┌──────────────────────────────────────┐")
|
|
1225
|
+
print(" │ 🚀 gitinstall Web UI │")
|
|
1226
|
+
print(f" │ 🌐 {url:<30s} │")
|
|
1227
|
+
if use_tls:
|
|
1228
|
+
print(" │ 🔒 HTTPS/TLS enabled │")
|
|
1229
|
+
if bind_host == "0.0.0.0":
|
|
1230
|
+
print(f" │ {t('server.listening_all'):<35s} │")
|
|
1231
|
+
print(f" │ {t('server.exposed_warning'):<35s} │")
|
|
1232
|
+
print(" │ Ctrl+C to stop │")
|
|
1233
|
+
print(" └──────────────────────────────────────┘")
|
|
1234
|
+
print()
|
|
1235
|
+
|
|
1236
|
+
logger.info(t("server.started", host=bind_host, port=port))
|
|
1237
|
+
|
|
1238
|
+
# 定期清理过期 session(每小时一次)
|
|
1239
|
+
def _periodic_cleanup():
|
|
1240
|
+
import threading
|
|
1241
|
+
while True:
|
|
1242
|
+
time.sleep(3600)
|
|
1243
|
+
try:
|
|
1244
|
+
n = _db.cleanup_expired_sessions()
|
|
1245
|
+
if n:
|
|
1246
|
+
logger.info(t("server.session_cleanup", n=n))
|
|
1247
|
+
except Exception:
|
|
1248
|
+
logger.exception("清理过期会话失败")
|
|
1249
|
+
|
|
1250
|
+
_cleanup_thread = __import__("threading").Thread(target=_periodic_cleanup, daemon=True)
|
|
1251
|
+
_cleanup_thread.start()
|
|
1252
|
+
|
|
1253
|
+
if open_browser:
|
|
1254
|
+
import webbrowser
|
|
1255
|
+
webbrowser.open(url)
|
|
1256
|
+
|
|
1257
|
+
try:
|
|
1258
|
+
server.serve_forever()
|
|
1259
|
+
except KeyboardInterrupt:
|
|
1260
|
+
print(f"\n{t('server.stopped')}")
|
|
1261
|
+
server.server_close()
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
if __name__ == "__main__":
|
|
1265
|
+
import argparse
|
|
1266
|
+
p = argparse.ArgumentParser(description="gitinstall Web UI")
|
|
1267
|
+
p.add_argument("--port", type=int, default=8080, help="Port (default 8080)")
|
|
1268
|
+
p.add_argument("--no-open", action="store_true", help="Don't open browser")
|
|
1269
|
+
p.add_argument("--ssl-cert", default="", help="TLS cert file for HTTPS")
|
|
1270
|
+
p.add_argument("--ssl-key", default="", help="TLS key file for HTTPS")
|
|
1271
|
+
args = p.parse_args()
|
|
1272
|
+
start_server(
|
|
1273
|
+
port=args.port,
|
|
1274
|
+
open_browser=not args.no_open,
|
|
1275
|
+
ssl_certfile=args.ssl_cert or None,
|
|
1276
|
+
ssl_keyfile=args.ssl_key or None,
|
|
1277
|
+
)
|