# file_server.py - 极简版文件托管服务器 from flask import Flask, send_from_directory, jsonify, request, Response, send_file from flask_cors import CORS import os import argparse import datetime import logging from logging.handlers import RotatingFileHandler from pathlib import Path import configparser import sys # pymsg 日志客户端(集中式日志 → dbv2 Logs 表) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # 附加 ../pymsg 源码路径作为无 venv 时的开发兜底(生产由 venv 提供 pymsg) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "pymsg")) from log_client import logs # noqa: E402 from security_middleware import apply_security_headers # noqa: E402 # 基准目录:编译后取可执行文件所在目录,开发时取脚本所在目录 # 解决 Nuitka onefile 解压到 /tmp 后相对路径失效的问题 if getattr(sys, 'frozen', False): BASE_DIR = os.path.dirname(os.path.abspath(sys.executable)) else: BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # 配置日志 logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) file_handler = RotatingFileHandler(os.path.join(BASE_DIR, 'app.log'), maxBytes=10485760, backupCount=10) file_handler.setFormatter(logging.Formatter( '%(asctime)s %(levelname)s: %(message)s' )) file_handler.setLevel(logging.INFO) logger.addHandler(file_handler) parser = argparse.ArgumentParser(description='File Server') args = parser.parse_args() app = Flask(__name__) apply_security_headers(app) # ==================== 配置加载(两层:confs 强依赖 → 环境变量覆盖) ==================== # confs 是 static 启动的强依赖:必须成功从 confs 拉取 static.ini,否则直接退出。 # 环境变量优先级最高(Docker 兼容),可覆盖 confs 中的配置值。 # confs 自身的连接地址通过引导配置确定:环境变量 CONFS_HOST/CONFS_PORT 优先, # 缺省使用 127.0.0.1:8010(与其他服务一致的约定)。 # 引导配置:confs 连接地址(不依赖 confs 本身) _confs_host = os.environ.get('CONFS_HOST', '127.0.0.1') try: _confs_port = int(os.environ.get('CONFS_PORT', '8010')) except ValueError: print('[static] 致命错误: CONFS_PORT 不是合法整数', file=sys.stderr) sys.exit(1) # 白名单结构:_merge_ini 仅覆盖此处列出的 section/key,防止意外字段 file_config = { 'server': {'host': '', 'port': ''}, 'directory': {'root': ''}, 'ssl': {'enabled': '', 'cert_file': '', 'key_file': ''}, 'cors': {'allowed_origins': ''}, } def _merge_ini(text): """把 ini 文本合并进 file_config(仅覆盖已知的 section/key)。""" ini = configparser.ConfigParser(interpolation=None) ini.read_string(text) for section in file_config: if ini.has_section(section): for key, value in ini[section].items(): if key in file_config[section]: file_config[section][key] = value def _fetch_from_confs(): """从 confs 统一配置服务拉取 static.ini 全文。 使用 retry_on_refused=True:confs 未就绪时 pymsg 自动阻塞重试(每秒一次, 每 5 秒打印一次等待提示),直到 confs 启动后自动连接成功。 RpcError(如配置文件不存在)仍直接报错退出——这是配置错误而非启动时序问题。 """ from pymsg import RpcClient, RpcError # noqa: E402 client = RpcClient(_confs_host, _confs_port, 'static', timeout=5, retry_on_refused=True) try: data = client.call('get', {'name': 'static.ini'}) except RpcError as e: print(f'[static] 致命错误: 从 confs 获取 static.ini 失败: {e}', file=sys.stderr) sys.exit(1) return data.get('content') _confs_text = _fetch_from_confs() _merge_ini(_confs_text) # 环境变量覆盖(优先级最高,Docker 兼容) if os.environ.get('LISTEN_ADDR'): file_config['server']['host'] = os.environ['LISTEN_ADDR'] if os.environ.get('LISTEN_PORT'): file_config['server']['port'] = os.environ['LISTEN_PORT'] if os.environ.get('STATIC_ROOT'): file_config['directory']['root'] = os.environ['STATIC_ROOT'] # 配置完整性校验 _required = { 'server': ['host', 'port'], 'directory': ['root'], 'cors': ['allowed_origins'], } for section, keys in _required.items(): for key in keys: if not file_config[section][key]: print(f'[static] 致命错误: 配置项 [{section}] {key} 缺失(static.ini 中未配置且无环境变量覆盖)', file=sys.stderr) sys.exit(1) # ssl 段有合理默认(enabled=false,cert/key 有默认文件名),不强制要求配置 if not file_config['ssl']['enabled']: file_config['ssl']['enabled'] = 'false' if not file_config['ssl']['cert_file']: file_config['ssl']['cert_file'] = 'cert.pem' if not file_config['ssl']['key_file']: file_config['ssl']['key_file'] = 'key.pem' _config_source = f'confs (static.ini @ {_confs_host}:{_confs_port})' print(f'[static] 配置来源: {_config_source}') logger.info(f"配置来源: {_config_source}") logs.info("static", f"配置来源: {_config_source}") # ==================== CORS(配置加载完成后初始化) ==================== _cors_origins = [o.strip() for o in file_config['cors']['allowed_origins'].split(',') if o.strip()] CORS(app, origins=_cors_origins) HTML_ROOT_DIR = file_config['directory']['root'] if HTML_ROOT_DIR and not os.path.isabs(HTML_ROOT_DIR): HTML_ROOT_DIR = os.path.join(BASE_DIR, HTML_ROOT_DIR) FILESYSTEM_ENABLED = bool(HTML_ROOT_DIR and HTML_ROOT_DIR.strip()) if FILESYSTEM_ENABLED: try: Path(HTML_ROOT_DIR).mkdir(parents=True, exist_ok=True) logger.info(f"Created directory: {HTML_ROOT_DIR}") logs.info("static", f"目录已创建: {HTML_ROOT_DIR}") except Exception as e: logger.error(f"Failed to create directory: {str(e)}") logs.error("static", f"目录创建失败: {str(e)}", extra={"path": HTML_ROOT_DIR}) FILESYSTEM_ENABLED = False SSL_ENABLED = file_config['ssl'].get('enabled', 'false').lower() == 'true' SSL_CERT_FILE = file_config['ssl'].get('cert_file', 'cert.pem') SSL_KEY_FILE = file_config['ssl'].get('key_file', 'key.pem') if not os.path.isabs(SSL_CERT_FILE): SSL_CERT_FILE = os.path.join(BASE_DIR, SSL_CERT_FILE) if not os.path.isabs(SSL_KEY_FILE): SSL_KEY_FILE = os.path.join(BASE_DIR, SSL_KEY_FILE) # 视图文件名:根路径与目录访问时固定返回此静态文件,由前端 JS 调用 /api/filelist 拉取数据 VIEW_FILE = '__view.html' # ==================== 视频文件支持 ==================== VIDEO_MIME_TYPES = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska' } AUDIO_MIME_TYPES = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac', '.aac': 'audio/aac', '.m4a': 'audio/mp4' } def is_video_file(filename): return os.path.splitext(filename)[1].lower() in VIDEO_MIME_TYPES def is_audio_file(filename): return os.path.splitext(filename)[1].lower() in AUDIO_MIME_TYPES def get_media_mime_type(filename): ext = os.path.splitext(filename)[1].lower() return VIDEO_MIME_TYPES.get(ext) or AUDIO_MIME_TYPES.get(ext) or 'application/octet-stream' def safe_path_join(base_dir, user_path): if not user_path or user_path.strip() == '' or user_path.strip() == '/': return os.path.realpath(base_dir) if user_path.startswith('/'): user_path = user_path[1:] full_path = os.path.normpath(os.path.join(base_dir, user_path)) base_dir_real = os.path.realpath(base_dir) full_path_real = os.path.realpath(full_path) # 边界校验:必须等于 base_dir 或以 base_dir + 路径分隔符开头, # 防止 /app/public_secret 绕过 /app/public 前缀校验(NEW-5) if full_path_real != base_dir_real and not full_path_real.startswith(base_dir_real + os.sep): return None return full_path_real def format_file_size(size): for unit in ['B', 'KB', 'MB', 'GB']: if size < 1024.0: return f"{size:.1f}{unit}" size /= 1024.0 return f"{size:.1f}TB" # ==================== API 接口 ==================== @app.route('/api/filelist', methods=['GET']) def get_file_list(): try: if not FILESYSTEM_ENABLED: return jsonify({'error': '文件系统未启用'}), 403 requested_path = request.args.get('path', '') full_path = safe_path_join(HTML_ROOT_DIR, requested_path) if full_path is None: return jsonify({'error': '无效的路径'}), 403 if not os.path.exists(full_path) or not os.path.isdir(full_path): return jsonify({'error': '目录不存在'}), 404 items = [] for item in os.listdir(full_path): # 过滤系统项目:以双下划线开头的文件和文件夹(如 __pycache__、__view.html、__icons 等) if item.startswith('__'): continue item_path = os.path.join(full_path, item) item_stat = os.stat(item_path) is_dir = os.path.isdir(item_path) item_rel_path = os.path.join(requested_path, item) if requested_path else item file_type = 'dir' if is_dir else ('video' if is_video_file(item) else ('audio' if is_audio_file(item) else 'file')) items.append({ 'name': item, 'type': file_type, 'path': item_rel_path, 'size': item_stat.st_size if not is_dir else 0, 'size_formatted': format_file_size(item_stat.st_size) if not is_dir else '', 'modified': datetime.datetime.fromtimestamp(item_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S') }) items.sort(key=lambda x: (x['type'] != 'dir', x['name'].lower())) return jsonify({ 'success': True, 'path': requested_path or '/', 'parent_path': os.path.dirname(requested_path) if requested_path and os.path.dirname(requested_path) != '/' else '', 'items': items }) except Exception as e: logger.error(f"Get file list error: {str(e)}") logs.error("static", f"获取文件列表失败: {str(e)}", extra={"path": requested_path}) return jsonify({'error': str(e)}), 500 @app.route('/video/', methods=['GET']) def stream_video(file_path): try: if not FILESYSTEM_ENABLED: return jsonify({'error': '文件系统未启用'}), 403 full_path = safe_path_join(HTML_ROOT_DIR, file_path) if full_path is None or not os.path.exists(full_path) or not os.path.isfile(full_path): return jsonify({'error': '文件不存在'}), 404 if not is_video_file(full_path) and not is_audio_file(full_path): return jsonify({'error': '不支持的文件类型'}), 400 file_size = os.path.getsize(full_path) mime_type = get_media_mime_type(full_path) range_header = request.headers.get('Range', None) if range_header: byte_range = range_header.replace('bytes=', '').split('-') try: start = int(byte_range[0]) if byte_range[0] else 0 end = int(byte_range[1]) if len(byte_range) > 1 and byte_range[1] else file_size - 1 except (ValueError, IndexError): return jsonify({'error': '无效的 Range 请求'}), 400 # 边界校验(P2-1):start/end 必须在 [0, file_size-1] 且 end >= start if start < 0 or start >= file_size or end >= file_size or end < start: return jsonify({'error': '请求范围超出文件大小'}), 416 length = end - start + 1 with open(full_path, 'rb') as f: f.seek(start) data = f.read(length) response = Response(data, 206, mimetype=mime_type, direct_passthrough=True) response.headers.add('Content-Range', f'bytes {start}-{end}/{file_size}') response.headers.add('Accept-Ranges', 'bytes') response.headers.add('Content-Length', str(length)) response.headers.add('Cache-Control', 'no-cache') return response else: return send_file(full_path, mimetype=mime_type, as_attachment=False) except Exception as e: logger.error(f"Video streaming error: {str(e)}") logs.error("static", f"视频流传输失败: {str(e)}", extra={"file": file_path}) return jsonify({'error': str(e)}), 500 # db 服务 RPC 调用辅助函数(替代直连 SQLite) # NEW-9:模块级单例 client + 环境变量 + retry_on_refused,避免每次调用新建连接 _db_host = os.environ.get('DB_HOST', '127.0.0.1') try: _db_port = int(os.environ.get('DB_PORT', '8005')) except ValueError: print('[static] 致命错误: DB_PORT 不是合法整数', file=sys.stderr) sys.exit(1) from pymsg import RpcClient as _RpcClient, RpcError as _RpcError _db_client = _RpcClient(_db_host, _db_port, 'static', timeout=5, retry_on_refused=True) def _db_call(action, table, where=None, values=None, columns=None): data = {"action": action, "table": table} if where is not None: data["where"] = where if values is not None: data["values"] = values if columns is not None: data["columns"] = columns result = _db_client.call('execute', data) if not result.get('success'): raise _RpcError(f"db execute failed: {result.get('error', 'unknown')}") return result.get('data', []) # ==================== 速率限制(P1-1:内存滑动窗口,无需外部依赖) ==================== import time import threading _rate_limit_lock = threading.Lock() _rate_limit_store = {} # key -> list of timestamps def _check_rate_limit(key, max_requests, window_seconds): """滑动窗口限速。返回 True 表示允许,False 表示超限。""" now = time.time() cutoff = now - window_seconds with _rate_limit_lock: timestamps = _rate_limit_store.get(key, []) # 清理过期记录 timestamps = [t for t in timestamps if t > cutoff] if len(timestamps) >= max_requests: _rate_limit_store[key] = timestamps return False timestamps.append(now) _rate_limit_store[key] = timestamps return True def _client_ip(): """获取客户端 IP(gateway 反代场景取 X-Forwarded-For 首段)。""" xff = request.headers.get('X-Forwarded-For', '') if xff: return xff.split(',')[0].strip() return request.remote_addr or 'unknown' @app.route('/api/hash', methods=['POST']) def api_hash(): import hashlib # P1-1:哈希预言机限速,每 IP 每分钟 20 次 if not _check_rate_limit(f"hash:{_client_ip()}", max_requests=20, window_seconds=60): return jsonify({"error": "rate limit exceeded"}), 429 # NEW-8:移除 force=True,增加类型校验 data = request.get_json(silent=True) or {} password = data.get('password', '') if not isinstance(password, str): return jsonify({"error": "invalid input"}), 400 h = hashlib.sha256(password.encode()).hexdigest() return jsonify({"hash": h}) # DISABLED: P0 security issue - no authentication, allows arbitrary password reset # @app.route('/api/reset_password', methods=['POST']) # def api_reset_password(): # from flask import request, jsonify # import sqlite3 as _sql # data = request.get_json(force=True) # email = data.get('email', '').strip() # new_hash = data.get('password_hash', '').strip() # if not email or not new_hash: # return jsonify({"success": False, "error": "missing params"}), 400 # conn = _sql.connect(GATEWAY_DB) # conn.execute("UPDATE Users SET password_hash=? WHERE email=?", (new_hash, email)) # conn.commit() # conn.close() # logs.info("static", "密码重置", extra={"email": email[:3] + "***"}) # return jsonify({"success": True}) @app.route('/api/check_user', methods=['POST']) def api_check_user(): # P1-1:邮箱枚举防护,每 IP 每分钟 10 次 if not _check_rate_limit(f"check_user:{_client_ip()}", max_requests=10, window_seconds=60): return jsonify({"success": False, "error": "rate limit exceeded"}), 429 data = request.get_json(silent=True) or {} email = data.get('email', '') if not isinstance(email, str) or not email.strip(): return jsonify({"success": False, "error": "missing email"}), 400 email = email.strip() try: rows = _db_call('select', 'Users', where={'email': email}, columns=['email']) return jsonify({"success": True, "found": len(rows) > 0}) except Exception as e: logs.warning("static", "check_user 查询失败", extra={"error": str(e)}) return jsonify({"success": False, "error": "query failed"}), 500 # DISABLED: yhdet OAuth 已失效,以下常量、辅助函数和视图函数全部禁用 # YHDET_VERIFY_URL = "https://yhdet.top/api/verify_auth_token" # YHDET_SECRET = "" # 已失效,原值: "yhdet_qsont_oauth_2026_secret_key" # # # def _yhdet_verify_token(token): # import traceback # import urllib.request, json # try: # data = json.dumps({"token": token}).encode() # req = urllib.request.Request(YHDET_VERIFY_URL, data=data, # headers={"Content-Type": "application/json"}, method="POST") # with urllib.request.urlopen(req, timeout=10) as resp: # return json.loads(resp.read().decode("utf-8")) # except Exception as e: # traceback.print_exc() # raise # @app.route('/yhdet_callback', methods=['GET', 'POST']) # def yhdet_callback(): # from flask import request, make_response # import hashlib, base64, json as _json, urllib.request as _urlreq # token = (request.args.get('yhdet_token', '') or request.form.get('yhdet_token', '')).strip() # if not token: # return make_response("No token", 400) # try: # result = _yhdet_verify_token(token) # if not result.get("success"): # return make_response("Verify failed: " + str(result), 401) # username = result.get("username", "") # user_id = result.get("user_id", 0) # except Exception as e: # import traceback # traceback.print_exc() # return make_response("Verify error: " + str(e), 500) # email = f"yhdet_{user_id}@yhdet.oauth" # password = f"oauth_{user_id}" # rows = _db_call('select', 'Users', where={'email': email}, columns=['email']) # if not rows: # fake_hash = hashlib.sha256(password.encode()).hexdigest() # _db_call('insert', 'Users', values={'email': email, 'password_hash': fake_hash}) # logs.info("static", "OAuth 新用户注册", extra={"email": email}) # try: # login_data = _json.dumps({"username": email, "password": password}).encode() # req = _urlreq.Request("https://qsont.me/login", data=login_data, # headers={"Content-Type": "application/json"}, method="POST") # with _urlreq.urlopen(req, timeout=10, context=__import__('ssl')._create_unverified_context()) as resp: # login_result = _json.loads(resp.read().decode("utf-8")) # real_token = login_result.get("data", {}).get("token", "") # except Exception as e: # import traceback # traceback.print_exc() # real_token = "" # html = "

Login...

" # return make_response(html, 200) # # @app.route('/api/yhdet_login', methods=['POST']) # def yhdet_login(): # import hashlib, base64 # data = request.get_json(force=True) if request.is_json else {} # email = data.get("email", "").strip() # password = data.get("password", "").strip() # if not email or not password: # return jsonify({"success": False, "error": "Missing params"}), 400 # pwd_hash = hashlib.sha256(password.encode()).hexdigest() # rows = _db_call('select', 'Users', where={'email': email}, columns=['email', 'password_hash']) # if not rows: # logs.warning("static", "yhdet 登录失败-用户不存在", # extra={"email": email[:8] + "***"}) # return jsonify({"success": False, "error": "Invalid credentials"}) # if rows[0]['password_hash'] != pwd_hash: # logs.warning("static", "yhdet 登录失败-密码错误", # extra={"email": email[:8] + "***"}) # return jsonify({"success": False, "error": "Invalid credentials"}) # logs.info("static", "yhdet 登录成功", # extra={"email": email[:8] + "***"}) # token_data = f"{email}:{password}" # token = base64.urlsafe_b64encode(token_data.encode()).decode() # return jsonify({"success": True, "token": token}) # # # @app.route('/api/yhdet_verify', methods=['POST']) # def yhdet_verify(): # import base64, hashlib # data = request.get_json(force=True) # token = data.get("token", "") # if not token: # return jsonify({"success": False}), 400 # try: # decoded = base64.urlsafe_b64decode(token.encode()).decode() # parts = decoded.split(":") # if len(parts) == 3 and parts[1] == "oauth": # return jsonify({"success": True, "email": parts[0], "type": "oauth"}) # if len(parts) == 2: # email, password = parts # pwd_hash = hashlib.sha256(password.encode()).hexdigest() # rows = _db_call('select', 'Users', where={'email': email, 'password_hash': pwd_hash}, columns=['email']) # if rows: # return jsonify({"success": True, "email": email, "type": "password"}) # return jsonify({"success": False}) # except: # return jsonify({"success": False}) # PRIVATE_SECRET = "qsont_me_yhdet_2026_private" # YHDET_PRIVATE_API = "https://yhdet.top/api/v1/private" from flask import make_response import urllib.request as _urlreq, json as _json DNFS_URL = "http://127.0.0.1:8006" # dnfs 代理路由通用错误响应(NEW-6:不泄露内部异常细节) _DNFS_PROXY_ERROR = _json.dumps({"error": "upstream unavailable"}).encode() def _cors(resp): """dnfs 代理路由 CORS:按白名单回显 Origin,不再通配 *(NEW-3)。""" origin = request.headers.get("Origin") if origin and origin in _cors_origins: resp.headers["Access-Control-Allow-Origin"] = origin resp.headers["Vary"] = "Origin" resp.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" return resp def _proxy_to_dnfs(route_name): """dnfs 代理通用逻辑:转发 JSON + Authorization,返回响应。""" data = request.get_json(force=True) h = {"Content-Type": "application/json"} if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"] elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer req = _urlreq.Request(f"{DNFS_URL}/{route_name}", data=_json.dumps(data).encode(), headers=h, method="POST") with _urlreq.urlopen(req, timeout=15) as r: return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"})) @app.route('/get_notes', methods=['POST', 'OPTIONS']) def proxy_get_notes(): if request.method == "OPTIONS": return _cors(make_response("", 204)) try: return _proxy_to_dnfs("get_notes") except Exception as e: logs.warning("static", "dnfs get_notes 代理失败", extra={"error": str(e)}) return _cors(make_response(_DNFS_PROXY_ERROR, 502, {"Content-Type": "application/json"})) @app.route('/add_note', methods=['POST', 'OPTIONS']) def proxy_add_note(): if request.method == "OPTIONS": return _cors(make_response("", 204)) try: return _proxy_to_dnfs("add_note") except Exception as e: logs.warning("static", "dnfs add_note 代理失败", extra={"error": str(e)}) return _cors(make_response(_DNFS_PROXY_ERROR, 502, {"Content-Type": "application/json"})) @app.route('/delete_note', methods=['POST', 'OPTIONS']) def proxy_delete_note(): if request.method == "OPTIONS": return _cors(make_response("", 204)) try: return _proxy_to_dnfs("delete_note") except Exception as e: logs.warning("static", "dnfs delete_note 代理失败", extra={"error": str(e)}) return _cors(make_response(_DNFS_PROXY_ERROR, 502, {"Content-Type": "application/json"})) @app.route('/update_note', methods=['POST', 'OPTIONS']) def proxy_update_note(): if request.method == "OPTIONS": return _cors(make_response("", 204)) try: return _proxy_to_dnfs("update_note") except Exception as e: logs.warning("static", "dnfs update_note 代理失败", extra={"error": str(e)}) return _cors(make_response(_DNFS_PROXY_ERROR, 502, {"Content-Type": "application/json"})) # @app.route('/api/proxy_send_code', methods=['POST']) # def proxy_send_code(): # from flask import request, jsonify # import urllib.request as _urlreq, json as _json, __import__ as _imp # data = request.get_json(force=True) # req_data = _json.dumps(data).encode() # req = _urlreq.Request(f"{YHDET_PRIVATE_API}/send_code", data=req_data, # headers={"Content-Type": "application/json", "X-Forwarded-For": "8.137.52.11", "X-Secret": PRIVATE_SECRET}, method="POST") # ctx = _imp('ssl').create_default_context() # ctx.check_hostname = False # ctx.verify_mode = _imp('ssl').CERT_NONE # with _urlreq.urlopen(req, timeout=15, context=ctx) as resp: # return jsonify(_json.loads(resp.read().decode("utf-8"))) # req = _urlreq.Request(f"{YHDET_PRIVATE_API}/check_code", data=req_data, # headers={"Content-Type": "application/json", "X-Forwarded-For": "8.137.52.11", "X-Secret": PRIVATE_SECRET}, method="POST") # ctx = _imp('ssl').create_default_context() # ctx.check_hostname = False # ctx.verify_mode = _imp('ssl').CERT_NONE # with _urlreq.urlopen(req, timeout=15, context=ctx) as resp: # return jsonify(_json.loads(resp.read().decode("utf-8"))) # ==================== favicon 代理(dnfs 特殊节点"链接"卡片用) ==================== # 服务端拉取目标站 /favicon.ico:前端 CSP img-src 'self' 禁止外域图片,且跨域无法直取。 # SSRF 防护:仅 http/https;解析目标 IP,禁止内网/环回/保留地址;重定向目标同样校验;512KB 上限。 import ipaddress import socket from urllib.parse import urlparse _FAVICON_MAX_BYTES = 512 * 1024 _FAVICON_TIMEOUT = 5 def _is_private_host(hostname): """主机解析后任一 IP 命中内网/环回/链路本地/保留/组播地址即视为非法(fail-closed)。""" try: infos = socket.getaddrinfo(hostname, None) except Exception: return True # 解析失败一律拒绝 if not infos: return True for info in infos: try: ip = ipaddress.ip_address(info[4][0]) except ValueError: return True if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified): return True return False class _FaviconRedirectHandler(_urlreq.HTTPRedirectHandler): """重定向时重新校验目标主机,防止 302 跳转绕过 SSRF 检查。""" def redirect_request(self, req, fp, code, msg, headers, newurl): if _is_private_host(urlparse(newurl).hostname or ''): return None return super().redirect_request(req, fp, code, msg, headers, newurl) @app.route('/api/favicon', methods=['GET']) def api_favicon(): target = (request.args.get('url') or '').strip() try: parsed = urlparse(target) except Exception: return jsonify({'error': 'bad url'}), 400 if parsed.scheme not in ('http', 'https') or not parsed.hostname: return jsonify({'error': 'bad url'}), 400 # NEW-10:校验端口范围,防止端口扫描 if parsed.port is not None and not (1 <= parsed.port <= 65535): return jsonify({'error': 'bad port'}), 400 if _is_private_host(parsed.hostname): return jsonify({'error': 'forbidden host'}), 403 favicon_url = f"{parsed.scheme}://{parsed.netloc}/favicon.ico" try: opener = _urlreq.build_opener(_FaviconRedirectHandler()) req = _urlreq.Request(favicon_url, headers={'User-Agent': 'qsont-favicon-fetcher/1.0'}) with opener.open(req, timeout=_FAVICON_TIMEOUT) as resp: data = resp.read(_FAVICON_MAX_BYTES + 1) if len(data) > _FAVICON_MAX_BYTES: return jsonify({'error': 'favicon too large'}), 502 ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip() if not ctype.startswith('image/'): ctype = 'image/x-icon' # 部分站点 favicon 返回 text/html 等,统一兜底 out = Response(data, 200, mimetype=ctype) out.headers['Cache-Control'] = 'public, max-age=86400' return out except Exception as e: logs.info("static", "favicon 拉取失败", extra={"url": target[:128], "error": str(e)}) return jsonify({'error': 'fetch failed'}), 502 # ==================== 主路由 ==================== @app.route('/') def serve_view(): """根路径:优先返回 index.html(社区首页),不存在时回退到 __view.html(文件浏览)""" if not FILESYSTEM_ENABLED: return "文件系统未启用", 403 index_path = os.path.join(HTML_ROOT_DIR, 'index.html') if os.path.exists(index_path): return send_file(index_path, mimetype='text/html') view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE) if not os.path.exists(view_path): return f"视图文件 {VIEW_FILE} 不存在", 500 return send_file(view_path, mimetype='text/html') @app.route('/') def serve_file(path): try: if not FILESYSTEM_ENABLED: return "文件系统未启用", 403 full_path = safe_path_join(HTML_ROOT_DIR, path) if full_path is None: return "无效的路径", 403 if not os.path.exists(full_path): return "路径不存在", 404 # 文件处理 if os.path.isfile(full_path): if request.args.get('download') == 'true': return send_file(full_path, as_attachment=True, download_name=os.path.basename(full_path)) if is_video_file(full_path) or is_audio_file(full_path): return send_file(full_path, mimetype=get_media_mime_type(full_path)) # 普通文件:使用 send_file 流式传输,避免大文件全量读入内存(NEW-7) ext = os.path.splitext(full_path)[1].lower() mime_map = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.txt': 'text/plain', '.pdf': 'application/pdf', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' } return send_file(full_path, mimetype=mime_map.get(ext, 'application/octet-stream')) # 目录处理:返回 __view.html,由前端 JS 根据 URL 路径调用 /api/filelist 拉取文件列表 view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE) if os.path.exists(view_path): return send_file(view_path, mimetype='text/html') return "目录无法浏览", 404 except Exception as e: logger.error(f"Error: {str(e)}") logs.error("static", f"静态文件服务错误: {str(e)}", extra={"path": path}) import traceback traceback.print_exc() return f"服务器内部错误: {str(e)}", 500 @app.route('/health', methods=['GET']) def health_check(): return jsonify({"status": "ok"}) if __name__ == '__main__': host = file_config['server']['host'] try: port = int(file_config['server']['port']) except (ValueError, TypeError): print('[static] 致命错误: server.port 不是合法整数', file=sys.stderr) sys.exit(1) print("\n" + "="*50) print("📁 文件托管服务器 (极简版)") print("="*50) print(f"地址: http://{host}:{port}") print(f"目录: {os.path.abspath(HTML_ROOT_DIR) if FILESYSTEM_ENABLED else '未启用'}") print("="*50) print("\n快捷键: / 或 Ctrl+F 搜索, ESC 清空搜索") print("\n") if SSL_ENABLED and os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE): # NEW-12(已知限制):Waitress 不支持 TLS,SSL 场景回退到 Flask 内置开发服务器。 # 生产环境应在 waf/gateway 层终止 TLS,不应在此处启用 SSL。 # 若运维启用此处 SSL,将面临单线程、无并发、调试信息泄露等风险。 logger.error("SSL enabled at static layer: using Flask dev server (NOT production-safe). " "TLS should be terminated at waf/gateway, not here.") app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False) else: from waitress import serve serve(app, host=host, port=port, threads=8)