P2: 清理与一致性

- CLI: --preset 实际生效;--directory-listing/--enable-stats/--enable-*/--ignore-hidden 支持 true/false(修复 type=bool 陷阱),未显式传入时不再覆盖 settings.json
- 版本号统一 v2.3(settings.json/config.py/v1/v2/Dockerfile)
- 移除硬编码 /tmp/pypi_debug.log 写文件;v2 裸 except 改记录错误返回 500
- webhook id 非法输入返回 400;下载趋势 timedelta 导入提升修复 NameError
- 打包模式 auth_token 写入 exe 目录(不再写入 _MEIPASS)
- README 修正: 线程池架构、pyinstaller 构建方式
- mirrors/__init__.py ' Quay.io' 笔误;chunked 响应去掉 Content-Length;docs/ui realpath 边界
- SQLite WAL+busy_timeout;下载计数原子 UPDATE
- requirements 移除未使用的 cachetools/apscheduler
This commit is contained in:
HYC Fixer
2026-08-30 12:25:26 +08:00
parent 237b99b480
commit dfae749612
12 changed files with 4020 additions and 3968 deletions
+720 -723
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1474,7 +1474,7 @@ class APIv1:
"""API: 获取配置信息""" """API: 获取配置信息"""
handler.send_json_response({ handler.send_json_response({
"server_name": self.config.get("server_name", "Mirror Server"), "server_name": self.config.get("server_name", "Mirror Server"),
"version": "2.2", "version": "2.3",
"base_dir": self.config['base_dir'], "base_dir": self.config['base_dir'],
"directory_listing": self.config.get('directory_listing', True), "directory_listing": self.config.get('directory_listing', True),
"max_upload_size": self.config.get('max_upload_size'), "max_upload_size": self.config.get('max_upload_size'),
@@ -2252,7 +2252,7 @@ class APIv1:
"""API: 获取镜像站信息""" """API: 获取镜像站信息"""
info = { info = {
"server_name": self.config.get("server_name", "Mirror Server"), "server_name": self.config.get("server_name", "Mirror Server"),
"version": "2.1", "version": "2.3",
"uptime": time.time() - self.config.get('start_time', time.time()), "uptime": time.time() - self.config.get('start_time', time.time()),
"total_files": sum(1 for _, _, files in os.walk(self.config['base_dir']) for _ in files), "total_files": sum(1 for _, _, files in os.walk(self.config['base_dir']) for _ in files),
"total_size": self._get_dir_size(self.config['base_dir']), "total_size": self._get_dir_size(self.config['base_dir']),
+44 -15
View File
@@ -597,11 +597,6 @@ class APIv2(APIv1):
# 获取镜像类型 # 获取镜像类型
mirror_type = mirror_config.get('type', 'http') mirror_type = mirror_config.get('type', 'http')
# 调试
debug_file = '/tmp/pypi_debug.log'
with open(debug_file, 'a') as f:
f.write(f"[V2] mirror_type={mirror_type}, handler_class={get_mirror_handler(mirror_type)}\n")
# 获取处理器类 # 获取处理器类
handler_class = get_mirror_handler(mirror_type) handler_class = get_mirror_handler(mirror_type)
if not handler_class: if not handler_class:
@@ -887,8 +882,17 @@ class APIv2(APIv1):
# v2 端点都没匹配到,尝试调用 APIv1 # v2 端点都没匹配到,尝试调用 APIv1
try: try:
return super().handle_request(handler, method, path, query_params) return super().handle_request(handler, method, path, query_params)
except: except Exception as e:
pass # 记录真实错误而非吞掉(避免 v1 内部异常被误报为 404)
if handler._is_debug_enabled('error'):
import traceback
handler._debug_log('error', f"APIv1 处理失败 ({path}): {e}\n{traceback.format_exc()}")
handler.send_json_response({
"error": f"API处理错误: {str(e)}",
"version": "v1",
"path": path
}, 500)
return
# 404 - 未找到端点 # 404 - 未找到端点
handler.send_error(404) handler.send_error(404)
@@ -1283,6 +1287,8 @@ class APIv2(APIv1):
def api_get_download_trend(self, handler, query_params): def api_get_download_trend(self, handler, query_params):
"""获取下载趋势(按天统计)""" """获取下载趋势(按天统计)"""
days = int(query_params.get('days', [7])[0]) days = int(query_params.get('days', [7])[0])
from datetime import timedelta # 供数据库与回退分支共同使用
days = min(max(days, 1), 90) # 限制 1-90 天 days = min(max(days, 1), 90) # 限制 1-90 天
trend = [] trend = []
@@ -1291,7 +1297,6 @@ class APIv2(APIv1):
db = self.get_db() db = self.get_db()
if db: if db:
try: try:
from datetime import timedelta
from core.database import DownloadRecord from core.database import DownloadRecord
now = datetime.now() now = datetime.now()
@@ -1983,7 +1988,11 @@ class APIv2(APIv1):
def api_get_webhook(self, handler, webhook_id): def api_get_webhook(self, handler, webhook_id):
"""获取webhook详情""" """获取webhook详情"""
webhook_id = int(webhook_id) try:
webhook_id = int(webhook_id)
except (ValueError, TypeError):
handler.send_json_response({"error": "Invalid webhook id"}, 400)
return
if self.db_enabled and self.db: if self.db_enabled and self.db:
try: try:
@@ -2006,7 +2015,11 @@ class APIv2(APIv1):
def api_delete_webhook(self, handler, webhook_id): def api_delete_webhook(self, handler, webhook_id):
"""删除webhook""" """删除webhook"""
webhook_id = int(webhook_id) try:
webhook_id = int(webhook_id)
except (ValueError, TypeError):
handler.send_json_response({"error": "Invalid webhook id"}, 400)
return
if self.db_enabled and self.db: if self.db_enabled and self.db:
try: try:
@@ -2031,7 +2044,11 @@ class APIv2(APIv1):
def api_test_webhook(self, handler, webhook_id): def api_test_webhook(self, handler, webhook_id):
"""测试webhook""" """测试webhook"""
webhook_id = int(webhook_id) try:
webhook_id = int(webhook_id)
except (ValueError, TypeError):
handler.send_json_response({"error": "Invalid webhook id"}, 400)
return
if self.db_enabled and self.db: if self.db_enabled and self.db:
try: try:
@@ -2095,7 +2112,11 @@ class APIv2(APIv1):
def api_get_webhook_deliveries(self, handler, webhook_id): def api_get_webhook_deliveries(self, handler, webhook_id):
"""获取 webhook 交付历史""" """获取 webhook 交付历史"""
webhook_id = int(webhook_id) try:
webhook_id = int(webhook_id)
except (ValueError, TypeError):
handler.send_json_response({"error": "Invalid webhook id"}, 400)
return
# 验证 webhook 存在 # 验证 webhook 存在
if self.db_enabled and self.db: if self.db_enabled and self.db:
@@ -2126,7 +2147,11 @@ class APIv2(APIv1):
def api_get_webhook_stats(self, handler, webhook_id): def api_get_webhook_stats(self, handler, webhook_id):
"""获取 webhook 交付统计""" """获取 webhook 交付统计"""
webhook_id = int(webhook_id) try:
webhook_id = int(webhook_id)
except (ValueError, TypeError):
handler.send_json_response({"error": "Invalid webhook id"}, 400)
return
if self.db_enabled and self.db: if self.db_enabled and self.db:
try: try:
@@ -2146,7 +2171,11 @@ class APIv2(APIv1):
def api_update_webhook(self, handler, webhook_id): def api_update_webhook(self, handler, webhook_id):
"""更新 webhook 配置""" """更新 webhook 配置"""
webhook_id = int(webhook_id) try:
webhook_id = int(webhook_id)
except (ValueError, TypeError):
handler.send_json_response({"error": "Invalid webhook id"}, 400)
return
content_length = int(handler.headers.get('Content-Length', 0)) content_length = int(handler.headers.get('Content-Length', 0))
if content_length == 0: if content_length == 0:
@@ -3741,7 +3770,7 @@ class APIv2(APIv1):
handler.send_json_response({ handler.send_json_response({
"name": self.config.get('server_name', 'HYC下载站'), "name": self.config.get('server_name', 'HYC下载站'),
"version": "2.2.0", "version": "2.3.0",
"uptime_seconds": round(uptime_seconds, 2), "uptime_seconds": round(uptime_seconds, 2),
"uptime_formatted": uptime_str, "uptime_formatted": uptime_str,
"api_version": "v2", "api_version": "v2",
+351 -351
View File
@@ -1,351 +1,351 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""配置管理模块""" """配置管理模块"""
import os import os
import sys import sys
import json import json
import hashlib import hashlib
import time import time
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from .utils import parse_size from .utils import parse_size
def get_resource_path(relative_path: str) -> str: def get_resource_path(relative_path: str) -> str:
"""获取打包后的资源路径""" """获取打包后的资源路径"""
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# 打包后的路径 # 打包后的路径
return os.path.join(sys._MEIPASS, relative_path) return os.path.join(sys._MEIPASS, relative_path)
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), relative_path) return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), relative_path)
# 打包模式 # 打包模式
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# 外部目录:与 exe 同级 # 外部目录:与 exe 同级
external_path = os.path.join(os.path.dirname(sys.executable), relative_path) external_path = os.path.join(os.path.dirname(sys.executable), relative_path)
if os.path.exists(external_path): if os.path.exists(external_path):
return external_path return external_path
# 打包后的资源路径(_MEIPASS) # 打包后的资源路径(_MEIPASS)
bundled_path = os.path.join(sys._MEIPASS, relative_path) bundled_path = os.path.join(sys._MEIPASS, relative_path)
if os.path.exists(bundled_path): if os.path.exists(bundled_path):
return bundled_path return bundled_path
return external_path return external_path
# 开发模式 # 开发模式
return os.path.join(project_root, relative_path) return os.path.join(project_root, relative_path)
def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
""" """
深度合并配置 深度合并配置
- 如果 override 中有的键,会完全替换 base 中的值(除非都是 dict) - 如果 override 中有的键,会完全替换 base 中的值(除非都是 dict)
- 如果都是 dict,则递归合并 - 如果都是 dict,则递归合并
- 不会修改原参数 - 不会修改原参数
Args: Args:
base: 默认配置(基础配置) base: 默认配置(基础配置)
override: 要合并的配置(优先级更高) override: 要合并的配置(优先级更高)
Returns: Returns:
合并后的配置 合并后的配置
""" """
result = base.copy() result = base.copy()
for key, value in override.items(): for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict): if key in result and isinstance(result[key], dict) and isinstance(value, dict):
# 两者都是字典,递归合并 # 两者都是字典,递归合并
result[key] = deep_merge(result[key], value) result[key] = deep_merge(result[key], value)
else: else:
# 直接覆盖 # 直接覆盖
result[key] = value result[key] = value
return result return result
def load_json_config(file_path: str) -> Optional[Dict[str, Any]]: def load_json_config(file_path: str) -> Optional[Dict[str, Any]]:
"""加载 JSON 配置文件""" """加载 JSON 配置文件"""
if not os.path.exists(file_path): if not os.path.exists(file_path):
return None return None
try: try:
with open(file_path, 'r', encoding='utf-8') as f: with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
print(f"警告: 配置文件 {file_path} JSON 格式错误: {e}") print(f"警告: 配置文件 {file_path} JSON 格式错误: {e}")
return None return None
except Exception as e: except Exception as e:
print(f"警告: 无法读取配置文件 {file_path}: {e}") print(f"警告: 无法读取配置文件 {file_path}: {e}")
return None return None
class ConfigManager: class ConfigManager:
"""配置管理器""" """配置管理器"""
def __init__(self, config: Dict[str, Any] = None, settings_path: str = None): def __init__(self, config: Dict[str, Any] = None, settings_path: str = None):
""" """
初始化配置管理器 初始化配置管理器
Args: Args:
config: 传入的配置(会覆盖默认配置) config: 传入的配置(会覆盖默认配置)
settings_path: 默认配置文件路径(可以是位置参数或关键字参数) settings_path: 默认配置文件路径(可以是位置参数或关键字参数)
""" """
# 支持 settings_path 作为位置参数 # 支持 settings_path 作为位置参数
if isinstance(config, str): if isinstance(config, str):
settings_path = config settings_path = config
config = None config = None
elif settings_path is None: elif settings_path is None:
# 使用打包后的资源路径 # 使用打包后的资源路径
settings_path = get_resource_path('settings.json') settings_path = get_resource_path('settings.json')
elif settings_path: elif settings_path:
settings_path = settings_path settings_path = settings_path
self._settings_path = settings_path self._settings_path = settings_path
# 加载默认配置 # 加载默认配置
self.default_config = self._load_default_config() self.default_config = self._load_default_config()
# 合并传入的配置 # 合并传入的配置
if config: if config:
self.config = self._validate_config(deep_merge(self.default_config, config)) self.config = self._validate_config(deep_merge(self.default_config, config))
else: else:
self.config = self._validate_config(self.default_config.copy()) self.config = self._validate_config(self.default_config.copy())
def _load_default_config(self) -> Dict[str, Any]: def _load_default_config(self) -> Dict[str, Any]:
"""加载默认配置文件""" """加载默认配置文件"""
default_config = load_json_config(self._settings_path) default_config = load_json_config(self._settings_path)
if default_config is None: if default_config is None:
# 如果找不到默认配置,使用内联最小配置 # 如果找不到默认配置,使用内联最小配置
default_config = { default_config = {
'server_name': 'HYC下载站', 'server_name': 'HYC下载站',
'host': '0.0.0.0', 'host': '0.0.0.0',
'port': 8080, 'port': 8080,
'base_dir': './downloads', 'base_dir': './downloads',
'api_version': 'v2', 'api_version': 'v2',
'directory_listing': True, 'directory_listing': True,
'enable_stats': True, 'enable_stats': True,
'auth_type': 'none', 'auth_type': 'none',
'max_upload_size': 1024 * 1024 * 1024, 'max_upload_size': 1024 * 1024 * 1024,
'timeout': 30, 'timeout': 30,
'verbose': 0, 'verbose': 0,
'enable_range': True, 'enable_range': True,
'ignore_hidden': True, 'ignore_hidden': True,
'show_hash': False, 'show_hash': False,
'calculate_hash': False, 'calculate_hash': False,
'max_search_results': 100, 'max_search_results': 100,
'enable_ws': True, 'enable_ws': True,
'enable_sse': True, 'enable_sse': True,
'enable_monitor': True, 'enable_monitor': True,
'monitor_interval': 5, 'monitor_interval': 5,
'enable_sync': True, 'enable_sync': True,
'enable_mirrors': True, 'enable_mirrors': True,
'database': { 'database': {
'enabled': True, 'enabled': True,
'type': 'sqlite', 'type': 'sqlite',
'sqlite': {'path': './data/hyc.db'} 'sqlite': {'path': './data/hyc.db'}
}, },
'mirrors': { 'mirrors': {
'docker': {'enabled': True}, 'docker': {'enabled': True},
'apt': {'enabled': True}, 'apt': {'enabled': True},
'yum': {'enabled': True}, 'yum': {'enabled': True},
'pypi': {'enabled': True}, 'pypi': {'enabled': True},
'npm': {'enabled': True}, 'npm': {'enabled': True},
'go': {'enabled': True} 'go': {'enabled': True}
}, },
'sync_sources': {}, 'sync_sources': {},
'webhooks': {'enabled': False, 'storage': 'webhooks.json'}, 'webhooks': {'enabled': False, 'storage': 'webhooks.json'},
'auth_sessions_file': 'auth_sessions.json', 'auth_sessions_file': 'auth_sessions.json',
'auth_session_timeout': 3600, 'auth_session_timeout': 3600,
'auth_cookie_max_age': 86400 'auth_cookie_max_age': 86400
} }
print(f"警告: 未找到默认配置文件 ({self._settings_path}),使用内联默认配置") print(f"警告: 未找到默认配置文件 ({self._settings_path}),使用内联默认配置")
return default_config return default_config
@classmethod @classmethod
def from_settings(cls, custom_config: Dict[str, Any] = None, settings_path: str = None) -> 'ConfigManager': def from_settings(cls, custom_config: Dict[str, Any] = None, settings_path: str = None) -> 'ConfigManager':
""" """
从默认配置创建配置管理器 从默认配置创建配置管理器
Args: Args:
custom_config: 自定义配置,会覆盖默认配置 custom_config: 自定义配置,会覆盖默认配置
settings_path: 默认配置文件路径 settings_path: 默认配置文件路径
Returns: Returns:
ConfigManager 实例 ConfigManager 实例
""" """
return cls(config=custom_config, settings_path=settings_path) return cls(config=custom_config, settings_path=settings_path)
def _validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]: def _validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""验证和修复配置""" """验证和修复配置"""
# 确保必要配置存在 # 确保必要配置存在
required = ['base_dir', 'host', 'port'] required = ['base_dir', 'host', 'port']
for key in required: for key in required:
if key not in config: if key not in config:
raise ValueError(f"缺少必要配置: {key}") raise ValueError(f"缺少必要配置: {key}")
# 修复路径配置 # 修复路径配置
config['base_dir'] = os.path.abspath(config['base_dir']) config['base_dir'] = os.path.abspath(config['base_dir'])
# 设置默认值(不在 _validate_config 中处理,由默认配置提供) # 设置默认值(不在 _validate_config 中处理,由默认配置提供)
# 验证认证配置 # 验证认证配置
auth_type = config.get('auth_type', 'none') auth_type = config.get('auth_type', 'none')
if auth_type == 'basic': if auth_type == 'basic':
if 'auth_user' not in config: if 'auth_user' not in config:
config['auth_user'] = 'admin' config['auth_user'] = 'admin'
if 'auth_pass' not in config: if 'auth_pass' not in config:
config['auth_pass'] = 'admin123' config['auth_pass'] = 'admin123'
elif auth_type == 'token': elif auth_type == 'token':
# 只在缺失时生成 token,避免每次配置验证/更新都使已签发 token 失效 # 只在缺失时生成 token,避免每次配置验证/更新都使已签发 token 失效
if not config.get('auth_token'): if not config.get('auth_token'):
import secrets import secrets
config['auth_token'] = secrets.token_hex(32) config['auth_token'] = secrets.token_hex(32)
# 验证上传大小配置 # 验证上传大小配置
if 'max_upload_size' in config: if 'max_upload_size' in config:
try: try:
if isinstance(config['max_upload_size'], str): if isinstance(config['max_upload_size'], str):
config['max_upload_size'] = parse_size(config['max_upload_size']) config['max_upload_size'] = parse_size(config['max_upload_size'])
except ValueError as e: except ValueError as e:
print(f"警告: 无效的上传大小配置: {e}") print(f"警告: 无效的上传大小配置: {e}")
config['max_upload_size'] = 1024 * 1024 * 1024 config['max_upload_size'] = 1024 * 1024 * 1024
# 验证端口范围 # 验证端口范围
if 'port' in config: if 'port' in config:
port = config['port'] port = config['port']
if not (1 <= port <= 65535): if not (1 <= port <= 65535):
raise ValueError(f"无效的端口号: {port}") raise ValueError(f"无效的端口号: {port}")
# 验证并创建必要目录 # 验证并创建必要目录
base_dir = config['base_dir'] base_dir = config['base_dir']
try: try:
if not os.path.exists(base_dir): if not os.path.exists(base_dir):
os.makedirs(base_dir, exist_ok=True) os.makedirs(base_dir, exist_ok=True)
# 测试写入权限 # 测试写入权限
test_file = os.path.join(base_dir, '.write_test') test_file = os.path.join(base_dir, '.write_test')
with open(test_file, 'w') as f: with open(test_file, 'w') as f:
f.write('test') f.write('test')
os.remove(test_file) os.remove(test_file)
except Exception as e: except Exception as e:
raise ValueError(f"基础目录无法访问: {e}") raise ValueError(f"基础目录无法访问: {e}")
# 获取项目根目录(脚本所在目录) # 获取项目根目录(脚本所在目录)
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 在项目根目录创建必要的数据目录 # 在项目根目录创建必要的数据目录
necessary_dirs = [ necessary_dirs = [
os.path.join(project_root, 'data'), os.path.join(project_root, 'data'),
os.path.join(project_root, 'logs'), os.path.join(project_root, 'logs'),
] ]
for dir_path in necessary_dirs: for dir_path in necessary_dirs:
if not os.path.exists(dir_path): if not os.path.exists(dir_path):
try: try:
os.makedirs(dir_path, exist_ok=True) os.makedirs(dir_path, exist_ok=True)
except Exception as e: except Exception as e:
print(f"警告: 无法创建目录 {dir_path}: {e}") print(f"警告: 无法创建目录 {dir_path}: {e}")
# 更新配置指向项目根目录 # 更新配置指向项目根目录
config['data_dir'] = os.path.join(project_root, 'data') config['data_dir'] = os.path.join(project_root, 'data')
config['logs_dir'] = os.path.join(project_root, 'logs') config['logs_dir'] = os.path.join(project_root, 'logs')
return config return config
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:
"""获取配置项""" """获取配置项"""
return self.config.get(key, default) return self.config.get(key, default)
def update(self, updates: Dict[str, Any]): def update(self, updates: Dict[str, Any]):
"""更新配置""" """更新配置"""
self.config.update(updates) self.config.update(updates)
self.config = self._validate_config(self.config) self.config = self._validate_config(self.config)
def get_full_config(self) -> Dict[str, Any]: def get_full_config(self) -> Dict[str, Any]:
"""获取完整配置字典""" """获取完整配置字典"""
return self.config.copy() return self.config.copy()
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""返回配置字典(不包含敏感信息)""" """返回配置字典(不包含敏感信息)"""
safe_config = { safe_config = {
"server_name": self.config.get("server_name", "Mirror Server"), "server_name": self.config.get("server_name", "Mirror Server"),
"version": "2.2", "version": "2.3",
"base_dir": self.config['base_dir'], "base_dir": self.config['base_dir'],
"directory_listing": self.config.get('directory_listing', True), "directory_listing": self.config.get('directory_listing', True),
"max_upload_size": self.config.get('max_upload_size'), "max_upload_size": self.config.get('max_upload_size'),
"enable_stats": self.config.get('enable_stats', True), "enable_stats": self.config.get('enable_stats', True),
"auth_type": self.config.get('auth_type', 'none'), "auth_type": self.config.get('auth_type', 'none'),
"sort_by": self.config.get('sort_by', 'name'), "sort_by": self.config.get('sort_by', 'name'),
"sort_reverse": self.config.get('sort_reverse', False), "sort_reverse": self.config.get('sort_reverse', False),
"ignore_hidden": self.config.get('ignore_hidden', True), "ignore_hidden": self.config.get('ignore_hidden', True),
"enable_range": self.config.get('enable_range', True), "enable_range": self.config.get('enable_range', True),
"show_hash": self.config.get('show_hash', False), "show_hash": self.config.get('show_hash', False),
"calculate_hash": self.config.get('calculate_hash', False), "calculate_hash": self.config.get('calculate_hash', False),
"max_search_results": self.config.get('max_search_results', 100), "max_search_results": self.config.get('max_search_results', 100),
"api_version": self.config.get('api_version', 'v1'), "api_version": self.config.get('api_version', 'v1'),
"verbose": self.config.get('verbose', 0) "verbose": self.config.get('verbose', 0)
} }
return safe_config return safe_config
def load_config_file(config_path: str) -> Dict[str, Any]: def load_config_file(config_path: str) -> Dict[str, Any]:
"""加载配置文件""" """加载配置文件"""
if not os.path.exists(config_path): if not os.path.exists(config_path):
return {} return {}
try: try:
with open(config_path, 'r', encoding='utf-8') as f: with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
except Exception as e: except Exception as e:
print(f"错误: 无法加载配置文件 {config_path}: {e}") print(f"错误: 无法加载配置文件 {config_path}: {e}")
return {} return {}
def load_settings_with_override(settings_path: str, override_path: str = None) -> Dict[str, Any]: def load_settings_with_override(settings_path: str, override_path: str = None) -> Dict[str, Any]:
""" """
加载默认配置并合并覆盖配置 加载默认配置并合并覆盖配置
Args: Args:
settings_path: 默认配置文件路径 settings_path: 默认配置文件路径
override_path: 覆盖配置文件路径(可选) override_path: 覆盖配置文件路径(可选)
Returns: Returns:
合并后的完整配置 合并后的完整配置
""" """
# 加载默认配置 # 加载默认配置
default_config = load_json_config(settings_path) or {} default_config = load_json_config(settings_path) or {}
# 加载覆盖配置 # 加载覆盖配置
override_config = {} override_config = {}
if override_path: if override_path:
override_config = load_json_config(override_path) or {} override_config = load_json_config(override_path) or {}
# 深度合并 # 深度合并
return deep_merge(default_config, override_config) return deep_merge(default_config, override_config)
def save_config_file(config_path: str, config: Dict[str, Any]) -> bool: def save_config_file(config_path: str, config: Dict[str, Any]) -> bool:
""" """
保存配置文件 保存配置文件
Args: Args:
config_path: 保存路径 config_path: 保存路径
config: 配置字典 config: 配置字典
Returns: Returns:
是否保存成功 是否保存成功
""" """
try: try:
# 创建目录 # 创建目录
os.makedirs(os.path.dirname(config_path), exist_ok=True) os.makedirs(os.path.dirname(config_path), exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as f: with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=4) json.dump(config, f, ensure_ascii=False, indent=4)
return True return True
except Exception as e: except Exception as e:
print(f"错误: 无法保存配置文件 {config_path}: {e}") print(f"错误: 无法保存配置文件 {config_path}: {e}")
return False return False
+1570 -1553
View File
File diff suppressed because it is too large Load Diff
+73 -73
View File
@@ -1,73 +1,73 @@
# ============================================ # ============================================
# HYC下载站 v2.2 - Docker 构建配置 # HYC下载站 v2.3 - Docker 构建配置
# ============================================ # ============================================
# 构建阶段 # 构建阶段
FROM python:3.11-slim AS builder FROM python:3.11-slim AS builder
WORKDIR /app WORKDIR /app
# 安装构建依赖 # 安装构建依赖
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ gcc \
musl-dev \ musl-dev \
libffi-dev \ libffi-dev \
libssl-dev \ libssl-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# 安装 Python 依赖 # 安装 Python 依赖
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ============================================ # ============================================
# 运行阶段 # 运行阶段
FROM python:3.11-slim FROM python:3.11-slim
LABEL maintainer="HYC Download Station" LABEL maintainer="HYC Download Station"
LABEL description="HYC下载站 v2.2 - 镜像文件服务器 + 下载加速源" LABEL description="HYC下载站 v2.3 - 镜像文件服务器 + 下载加速源"
# 环境变量 # 环境变量
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV HYC_HOME=/app ENV HYC_HOME=/app
# 创建工作目录 # 创建工作目录
WORKDIR ${HYC_HOME} WORKDIR ${HYC_HOME}
# 复制 Python 和依赖 # 复制 Python 和依赖
COPY --from=builder /install /usr/local COPY --from=builder /install /usr/local
# 复制应用代码 # 复制应用代码
COPY main.py . COPY main.py .
COPY settings.json . COPY settings.json .
COPY requirements.txt . COPY requirements.txt .
COPY core/ ./core/ COPY core/ ./core/
COPY api/ ./api/ COPY api/ ./api/
COPY handlers/ ./handlers/ COPY handlers/ ./handlers/
COPY mirrors/ ./mirrors/ COPY mirrors/ ./mirrors/
COPY scripts/ ./scripts/ COPY scripts/ ./scripts/
# 创建数据目录 # 创建数据目录
RUN mkdir -p \ RUN mkdir -p \
/data \ /data \
/downloads \ /downloads \
&& chown -R nobody:nogroup /data /downloads && chown -R nobody:nogroup /data /downloads
# 设置工作用户 # 设置工作用户
USER nobody USER nobody
# 挂载卷 # 挂载卷
VOLUME ["/data", "/downloads"] VOLUME ["/data", "/downloads"]
# 暴露端口 # 暴露端口
EXPOSE 8080 EXPOSE 8080
# 健康检查 # 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/v1/health')" || exit 1 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/v1/health')" || exit 1
# 启动命令 # 启动命令
CMD ["python", "main.py", \ CMD ["python", "main.py", \
"--host", "0.0.0.0", \ "--host", "0.0.0.0", \
"--port", "8080", \ "--port", "8080", \
"--base-dir", "/downloads"] "--base-dir", "/downloads"]
+3 -3
View File
@@ -668,7 +668,7 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
file_path = os.path.join(docs_dir, rel_path) file_path = os.path.join(docs_dir, rel_path)
# 防止目录遍历 # 防止目录遍历
if not os.path.realpath(file_path).startswith(os.path.realpath(docs_dir)): if not os.path.realpath(file_path).startswith(os.path.realpath(docs_dir) + os.sep):
self.send_error(403, "Access denied") self.send_error(403, "Access denied")
return return
@@ -765,7 +765,7 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
file_path = os.path.join(ui_dir, rel_path) file_path = os.path.join(ui_dir, rel_path)
# 防止目录遍历 # 防止目录遍历
if not os.path.realpath(file_path).startswith(os.path.realpath(ui_dir)): if not os.path.realpath(file_path).startswith(os.path.realpath(ui_dir) + os.sep):
self.send_error(403, "Access denied") self.send_error(403, "Access denied")
return return
@@ -1200,7 +1200,7 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
mime_type, _ = mimetypes.guess_type(file_path) mime_type, _ = mimetypes.guess_type(file_path)
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", mime_type) self.send_header("Content-Type", mime_type)
self.send_header("Content-Length", str(file_size)) # 使用 chunked 时不发送 Content-Length(协议不允许同时存在)
self.send_header("Content-Disposition", self.send_header("Content-Disposition",
f'attachment; filename="{self._safe_disposition_filename(file_path)}"') f'attachment; filename="{self._safe_disposition_filename(file_path)}"')
self.send_header("Accept-Ranges", "bytes") self.send_header("Accept-Ranges", "bytes")
+648 -624
View File
File diff suppressed because it is too large Load Diff
+583 -583
View File
File diff suppressed because it is too large Load Diff
+8 -21
View File
@@ -35,6 +35,14 @@ class PyPIMirror:
os.makedirs(self.simple_dir, exist_ok=True) os.makedirs(self.simple_dir, exist_ok=True)
os.makedirs(self.web_dir, exist_ok=True) os.makedirs(self.web_dir, exist_ok=True)
def _debug_log(self, msg: str):
"""受 debug 配置控制的调试日志(原硬编码 /tmp/pypi_debug.log)"""
try:
if self.config and self.config.get('debug'):
print(f"[PyPI-Mirror] {msg}")
except Exception:
pass
def handle_request(self, handler, path: str) -> bool: def handle_request(self, handler, path: str) -> bool:
""" """
处理PyPI请求 处理PyPI请求
@@ -155,29 +163,17 @@ class PyPIMirror:
package = package.lower() package = package.lower()
# 调试 - 确保函数被调用 # 调试 - 确保函数被调用
debug_file = '/tmp/pypi_debug.log'
with open(debug_file, 'a') as f:
f.write(f"[HANDLE_SIMPLE] START package={package}\n")
# 检查客户端Accept header # 检查客户端Accept header
accept = handler.headers.get('Accept', '') accept = handler.headers.get('Accept', '')
wants_json = 'application/vnd.pypi.simple.v1+json' in accept wants_json = 'application/vnd.pypi.simple.v1+json' in accept
# 调试 # 调试
debug_file = '/tmp/pypi_debug.log'
with open(debug_file, 'a') as f:
f.write(f"[SIMPLE_INDEX] package={package}, wants_json={wants_json}, accept={accept[:50]}\n")
# 根据请求格式选择正确的缓存key,统一使用 simple/ 前缀 # 根据请求格式选择正确的缓存key,统一使用 simple/ 前缀
cache_key = f"simple/{package}" cache_key = f"simple/{package}"
cached = self._get_cache(cache_key) cached = self._get_cache(cache_key)
# 调试缓存 # 调试缓存
debug_file = '/tmp/pypi_debug.log'
with open(debug_file, 'a') as f:
f.write(f"[CACHE_CHECK] cache_key={cache_key}, cached={'YES' if cached else 'NO'}\n")
if cached: if cached:
# 返回缓存,使用正确的Content-Type # 返回缓存,使用正确的Content-Type
if wants_json: if wants_json:
@@ -213,9 +209,6 @@ class PyPIMirror:
cache_key = f"simple/{package}" cache_key = f"simple/{package}"
else: else:
# 转换为HTML格式 # 转换为HTML格式
debug_file = '/tmp/pypi_debug.log'
with open(debug_file, 'a') as f:
f.write(f"[CONVERT_CALL] Before conversion\n")
try: try:
proxy_data = self._convert_simple_html(package, data) proxy_data = self._convert_simple_html(package, data)
with open(debug_file, 'a') as f: with open(debug_file, 'a') as f:
@@ -564,9 +557,6 @@ class PyPIMirror:
import sys import sys
import os import os
# 写入调试文件 # 写入调试文件
debug_file = '/tmp/pypi_debug.log'
with open(debug_file, 'a') as f:
f.write(f"[PyPI] Converting HTML for package: {package}\n")
# 打印前几个链接用于调试 # 打印前几个链接用于调试
import re import re
test_matches = re.findall(r'href="([^"]+)"', html)[:3] test_matches = re.findall(r'href="([^"]+)"', html)[:3]
@@ -621,9 +611,6 @@ class PyPIMirror:
def convert_relative_match(match): def convert_relative_match(match):
"""转换相对路径链接""" """转换相对路径链接"""
href = match.group(1) href = match.group(1)
debug_file = '/tmp/pypi_debug.log'
with open(debug_file, 'a') as f:
f.write(f"[CONVERT] Input href: {href[:80]}...\n")
# 提取文件名 # 提取文件名
filename = href.split('/')[-1].split('#')[0] filename = href.split('/')[-1].split('#')[0]
# 提取完整的hash路径(从 packages/ 后面的所有部分除了文件名) # 提取完整的hash路径(从 packages/ 后面的所有部分除了文件名)
+17 -19
View File
@@ -1,19 +1,17 @@
psutil>=5.9.0 psutil>=5.9.0
pyinstaller>=6.0.0 pyinstaller>=6.0.0
python-multipart>=0.0.6 python-multipart>=0.0.6
paramiko>=2.11.0 paramiko>=2.11.0
bcrypt>=4.0.0 bcrypt>=4.0.0
cachetools>=5.3.0 requests>=2.31.0
requests>=2.31.0 croniter>=2.0.0
croniter>=2.0.0
# Database (SQLite 内置, 以下为可选)
# Database (SQLite 内置, 以下为可选) sqlalchemy>=2.0.0
sqlalchemy>=2.0.0
apscheduler>=3.10.0 # PostgreSQL 驱动
psycopg2-binary>=2.9.0
# PostgreSQL 驱动
psycopg2-binary>=2.9.0 # MySQL 驱动
pymysql>=1.1.0
# MySQL 驱动 cryptography>=41.0.0
pymysql>=1.1.0
cryptography>=41.0.0
+1 -1
View File
@@ -569,7 +569,7 @@
"shutdown_timeout": 10, "shutdown_timeout": 10,
"strategy": "graceful" "strategy": "graceful"
}, },
"version": "2.2", "version": "2.3",
"sort_by": "name", "sort_by": "name",
"sort_reverse": false, "sort_reverse": false,
"calculate_hash": false, "calculate_hash": false,