Commit b3ffdc72967001c04217b9f9e4c172e71be19668
1 parent
89fa3f3c
Sync legacy frontend entrypoint from 0a440fb
Showing
1 changed file
with
7 additions
and
271 deletions
Show diff stats
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | -""" | |
| 3 | -Simple HTTP server for saas-search frontend. | |
| 4 | -""" | |
| 2 | +"""Backward-compatible frontend server entrypoint.""" | |
| 5 | 3 | |
| 6 | -import http.server | |
| 7 | -import socketserver | |
| 8 | -import os | |
| 9 | -import sys | |
| 10 | -import logging | |
| 11 | -import time | |
| 12 | -import urllib.request | |
| 13 | -import urllib.error | |
| 14 | -from collections import defaultdict, deque | |
| 15 | -from pathlib import Path | |
| 16 | -from dotenv import load_dotenv | |
| 17 | - | |
| 18 | -# Load .env file | |
| 19 | -project_root = Path(__file__).parent.parent | |
| 20 | -load_dotenv(project_root / '.env') | |
| 21 | - | |
| 22 | -# Get API_BASE_URL from environment(默认不注入,避免被旧 .env 覆盖同源策略) | |
| 23 | -# 仅当显式设置 FRONTEND_INJECT_API_BASE_URL=1 时才注入 window.API_BASE_URL。 | |
| 24 | -API_BASE_URL = os.getenv('API_BASE_URL') or None | |
| 25 | -INJECT_API_BASE_URL = os.getenv('FRONTEND_INJECT_API_BASE_URL', '0') == '1' | |
| 26 | -# Backend proxy target for same-origin API forwarding | |
| 27 | -BACKEND_PROXY_URL = os.getenv('BACKEND_PROXY_URL', 'http://127.0.0.1:6002').rstrip('/') | |
| 28 | - | |
| 29 | -# Change to frontend directory | |
| 30 | -frontend_dir = os.path.join(os.path.dirname(__file__), '../frontend') | |
| 31 | -os.chdir(frontend_dir) | |
| 32 | - | |
| 33 | -# FRONTEND_PORT is the canonical config; keep PORT as a secondary fallback. | |
| 34 | -PORT = int(os.getenv('FRONTEND_PORT', os.getenv('PORT', 6003))) | |
| 35 | - | |
| 36 | -# Configure logging to suppress scanner noise | |
| 37 | -logging.basicConfig(level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s') | |
| 38 | - | |
| 39 | -class RateLimitingMixin: | |
| 40 | - """Mixin for rate limiting requests by IP address.""" | |
| 41 | - request_counts = defaultdict(deque) | |
| 42 | - rate_limit = 100 # requests per minute | |
| 43 | - window = 60 # seconds | |
| 44 | - | |
| 45 | - @classmethod | |
| 46 | - def is_rate_limited(cls, ip): | |
| 47 | - now = time.time() | |
| 48 | - | |
| 49 | - # Clean old requests | |
| 50 | - while cls.request_counts[ip] and cls.request_counts[ip][0] < now - cls.window: | |
| 51 | - cls.request_counts[ip].popleft() | |
| 52 | - | |
| 53 | - # Check rate limit | |
| 54 | - if len(cls.request_counts[ip]) > cls.rate_limit: | |
| 55 | - return True | |
| 56 | - | |
| 57 | - cls.request_counts[ip].append(now) | |
| 58 | - return False | |
| 59 | - | |
| 60 | -class MyHTTPRequestHandler(http.server.SimpleHTTPRequestHandler, RateLimitingMixin): | |
| 61 | - """Custom request handler with CORS support and robust error handling.""" | |
| 62 | - | |
| 63 | - def _is_proxy_path(self, path: str) -> bool: | |
| 64 | - """Return True for API paths that should be forwarded to backend service.""" | |
| 65 | - return path.startswith('/search/') or path.startswith('/admin/') or path.startswith('/indexer/') | |
| 66 | - | |
| 67 | - def _proxy_to_backend(self): | |
| 68 | - """Proxy current request to backend service on the GPU server.""" | |
| 69 | - target_url = f"{BACKEND_PROXY_URL}{self.path}" | |
| 70 | - method = self.command.upper() | |
| 71 | - | |
| 72 | - try: | |
| 73 | - content_length = int(self.headers.get('Content-Length', '0')) | |
| 74 | - except ValueError: | |
| 75 | - content_length = 0 | |
| 76 | - body = self.rfile.read(content_length) if content_length > 0 else None | |
| 4 | +from __future__ import annotations | |
| 77 | 5 | |
| 78 | - forward_headers = {} | |
| 79 | - for key, value in self.headers.items(): | |
| 80 | - lk = key.lower() | |
| 81 | - if lk in ('host', 'content-length', 'connection'): | |
| 82 | - continue | |
| 83 | - forward_headers[key] = value | |
| 84 | - | |
| 85 | - req = urllib.request.Request( | |
| 86 | - target_url, | |
| 87 | - data=body, | |
| 88 | - headers=forward_headers, | |
| 89 | - method=method, | |
| 90 | - ) | |
| 91 | - | |
| 92 | - try: | |
| 93 | - with urllib.request.urlopen(req, timeout=30) as resp: | |
| 94 | - resp_body = resp.read() | |
| 95 | - self.send_response(resp.getcode()) | |
| 96 | - for header, value in resp.getheaders(): | |
| 97 | - lh = header.lower() | |
| 98 | - if lh in ('transfer-encoding', 'connection', 'content-length'): | |
| 99 | - continue | |
| 100 | - self.send_header(header, value) | |
| 101 | - self.end_headers() | |
| 102 | - self.wfile.write(resp_body) | |
| 103 | - except urllib.error.HTTPError as e: | |
| 104 | - err_body = e.read() if hasattr(e, 'read') else b'' | |
| 105 | - self.send_response(e.code) | |
| 106 | - if e.headers: | |
| 107 | - for header, value in e.headers.items(): | |
| 108 | - lh = header.lower() | |
| 109 | - if lh in ('transfer-encoding', 'connection', 'content-length'): | |
| 110 | - continue | |
| 111 | - self.send_header(header, value) | |
| 112 | - self.end_headers() | |
| 113 | - if err_body: | |
| 114 | - self.wfile.write(err_body) | |
| 115 | - except Exception as e: | |
| 116 | - logging.error(f"Backend proxy error for {method} {self.path}: {e}") | |
| 117 | - self.send_response(502) | |
| 118 | - self.send_header('Content-Type', 'application/json; charset=utf-8') | |
| 119 | - self.end_headers() | |
| 120 | - self.wfile.write(b'{"error":"Bad Gateway: backend proxy failed"}') | |
| 121 | - | |
| 122 | - def do_GET(self): | |
| 123 | - """Handle GET requests with API config injection.""" | |
| 124 | - path = self.path.split('?')[0] | |
| 125 | - | |
| 126 | - # Proxy API paths to backend first | |
| 127 | - if self._is_proxy_path(path): | |
| 128 | - self._proxy_to_backend() | |
| 129 | - return | |
| 130 | - | |
| 131 | - # Route / to index.html | |
| 132 | - if path == '/' or path == '': | |
| 133 | - self.path = '/index.html' + (self.path.split('?', 1)[1] if '?' in self.path else '') | |
| 134 | - | |
| 135 | - # Inject API config for HTML files | |
| 136 | - if self.path.endswith('.html'): | |
| 137 | - self._serve_html_with_config() | |
| 138 | - else: | |
| 139 | - super().do_GET() | |
| 140 | - | |
| 141 | - def _serve_html_with_config(self): | |
| 142 | - """Serve HTML with optional API_BASE_URL injected.""" | |
| 143 | - try: | |
| 144 | - file_path = self.path.lstrip('/') | |
| 145 | - if not os.path.exists(file_path): | |
| 146 | - self.send_error(404) | |
| 147 | - return | |
| 148 | - | |
| 149 | - with open(file_path, 'r', encoding='utf-8') as f: | |
| 150 | - html = f.read() | |
| 151 | - | |
| 152 | - # 默认不注入 API_BASE_URL,避免历史 .env(如 http://xx:6002)覆盖同源调用。 | |
| 153 | - # 仅当 FRONTEND_INJECT_API_BASE_URL=1 且 API_BASE_URL 有值时才注入。 | |
| 154 | - if INJECT_API_BASE_URL and API_BASE_URL: | |
| 155 | - config_script = f'<script>window.API_BASE_URL="{API_BASE_URL}";</script>\n ' | |
| 156 | - html = html.replace('<script src="/static/js/app.js', config_script + '<script src="/static/js/app.js', 1) | |
| 157 | - | |
| 158 | - self.send_response(200) | |
| 159 | - self.send_header('Content-Type', 'text/html; charset=utf-8') | |
| 160 | - self.end_headers() | |
| 161 | - self.wfile.write(html.encode('utf-8')) | |
| 162 | - except Exception as e: | |
| 163 | - logging.error(f"Error serving HTML: {e}") | |
| 164 | - self.send_error(500) | |
| 165 | - | |
| 166 | - def do_POST(self): | |
| 167 | - """Handle POST requests. Proxy API requests to backend.""" | |
| 168 | - path = self.path.split('?')[0] | |
| 169 | - if self._is_proxy_path(path): | |
| 170 | - self._proxy_to_backend() | |
| 171 | - return | |
| 172 | - self.send_error(405, "Method Not Allowed") | |
| 173 | - | |
| 174 | - def setup(self): | |
| 175 | - """Setup with error handling.""" | |
| 176 | - try: | |
| 177 | - super().setup() | |
| 178 | - except Exception: | |
| 179 | - pass # Silently handle setup errors from scanners | |
| 180 | - | |
| 181 | - def handle_one_request(self): | |
| 182 | - """Handle single request with error catching.""" | |
| 183 | - try: | |
| 184 | - # Check rate limiting | |
| 185 | - client_ip = self.client_address[0] | |
| 186 | - if self.is_rate_limited(client_ip): | |
| 187 | - logging.warning(f"Rate limiting IP: {client_ip}") | |
| 188 | - self.send_error(429, "Too Many Requests") | |
| 189 | - return | |
| 190 | - | |
| 191 | - super().handle_one_request() | |
| 192 | - except (ConnectionResetError, BrokenPipeError): | |
| 193 | - # Client disconnected prematurely - common with scanners | |
| 194 | - pass | |
| 195 | - except UnicodeDecodeError: | |
| 196 | - # Binary data received - not HTTP | |
| 197 | - pass | |
| 198 | - except Exception as e: | |
| 199 | - # Log unexpected errors but don't crash | |
| 200 | - logging.debug(f"Request handling error: {e}") | |
| 201 | - | |
| 202 | - def log_message(self, format, *args): | |
| 203 | - """Suppress logging for malformed requests from scanners.""" | |
| 204 | - message = format % args | |
| 205 | - # Filter out scanner noise | |
| 206 | - noise_patterns = [ | |
| 207 | - "code 400", | |
| 208 | - "Bad request", | |
| 209 | - "Bad request version", | |
| 210 | - "Bad HTTP/0.9 request type", | |
| 211 | - "Bad request syntax" | |
| 212 | - ] | |
| 213 | - if any(pattern in message for pattern in noise_patterns): | |
| 214 | - return | |
| 215 | - # Only log legitimate requests | |
| 216 | - if message and not message.startswith(" ") and len(message) > 10: | |
| 217 | - super().log_message(format, *args) | |
| 218 | - | |
| 219 | - def end_headers(self): | |
| 220 | - # Add CORS headers | |
| 221 | - self.send_header('Access-Control-Allow-Origin', '*') | |
| 222 | - self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') | |
| 223 | - self.send_header('Access-Control-Allow-Headers', 'Content-Type') | |
| 224 | - # Add security headers | |
| 225 | - self.send_header('X-Content-Type-Options', 'nosniff') | |
| 226 | - self.send_header('X-Frame-Options', 'DENY') | |
| 227 | - self.send_header('X-XSS-Protection', '1; mode=block') | |
| 228 | - super().end_headers() | |
| 229 | - | |
| 230 | - def do_OPTIONS(self): | |
| 231 | - """Handle OPTIONS requests.""" | |
| 232 | - try: | |
| 233 | - path = self.path.split('?')[0] | |
| 234 | - if self._is_proxy_path(path): | |
| 235 | - self.send_response(204) | |
| 236 | - self.end_headers() | |
| 237 | - return | |
| 238 | - self.send_response(200) | |
| 239 | - self.end_headers() | |
| 240 | - except Exception: | |
| 241 | - pass | |
| 242 | - | |
| 243 | -class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): | |
| 244 | - """Threaded TCP server with better error handling.""" | |
| 245 | - allow_reuse_address = True | |
| 246 | - daemon_threads = True | |
| 6 | +import runpy | |
| 7 | +from pathlib import Path | |
| 247 | 8 | |
| 248 | -if __name__ == '__main__': | |
| 249 | - # Check if port is already in use | |
| 250 | - import socket | |
| 251 | - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
| 252 | - try: | |
| 253 | - sock.bind(("", PORT)) | |
| 254 | - sock.close() | |
| 255 | - except OSError: | |
| 256 | - print(f"ERROR: Port {PORT} is already in use.") | |
| 257 | - print(f"Please stop the existing server or use a different port.") | |
| 258 | - print(f"To stop existing server: kill $(lsof -t -i:{PORT})") | |
| 259 | - sys.exit(1) | |
| 260 | - | |
| 261 | - # Create threaded server for better concurrency | |
| 262 | - with ThreadedTCPServer(("", PORT), MyHTTPRequestHandler) as httpd: | |
| 263 | - print(f"Frontend server started at http://localhost:{PORT}") | |
| 264 | - print(f"Serving files from: {os.getcwd()}") | |
| 265 | - print("\nPress Ctrl+C to stop the server") | |
| 266 | 9 | |
| 267 | - try: | |
| 268 | - httpd.serve_forever() | |
| 269 | - except KeyboardInterrupt: | |
| 270 | - print("\nShutting down server...") | |
| 271 | - httpd.shutdown() | |
| 272 | - print("Server stopped") | |
| 273 | - sys.exit(0) | |
| 274 | - except Exception as e: | |
| 275 | - print(f"Server error: {e}") | |
| 276 | - sys.exit(1) | |
| 10 | +if __name__ == "__main__": | |
| 11 | + target = Path(__file__).resolve().parent / "frontend" / "frontend_server.py" | |
| 12 | + runpy.run_path(str(target), run_name="__main__") | ... | ... |