P1: 功能修复

- v2 三处 self.format_file_size -> format_file_size(3 个端点不再必崩)
- requirements 加 bcrypt、移除未使用的 aiohttp
- verify_user 改用 bcrypt.checkpw(DB basic 登录恢复);SHA256 回退改自描述 PBKDF2,去掉明文接受
- config.py token 模式只在缺失时生成(不再每次 update 失效)
- docker/npm/go/apt/yum 镜像补 cache_enabled/cache_ttl 初始化(不再必 500)
- docker 镜像兼容 upstream_url 配置键
- 定时同步链路: 回调委托 sync_starter 并注入 _sync_manager;cron 逗号列表修复;星期偏移修复
- v2 同步方法与 v1 签名兼容;MirrorSyncManager 补 get_source_status/stop_all_tasks_for_source
This commit is contained in:
HYC Fixer
2026-08-30 12:22:27 +08:00
parent 2a899c411e
commit 084164296d
13 changed files with 2089 additions and 2018 deletions
+16 -10
View File
@@ -1020,7 +1020,7 @@ class APIv2(APIv1):
"path": rel_path, "path": rel_path,
"type": mime_type, "type": mime_type,
"size": size, "size": size,
"size_formatted": self.format_file_size(size), "size_formatted": format_file_size(size),
"modified": datetime.fromtimestamp(mtime).isoformat(), "modified": datetime.fromtimestamp(mtime).isoformat(),
"match_score": self._calculate_match_score(file_name, search_term, search_mode) "match_score": self._calculate_match_score(file_name, search_term, search_mode)
} }
@@ -1222,7 +1222,7 @@ class APIv2(APIv1):
"total_files": total_files, "total_files": total_files,
"total_dirs": total_dirs, "total_dirs": total_dirs,
"total_size": total_size, "total_size": total_size,
"total_size_formatted": self.format_file_size(total_size) "total_size_formatted": format_file_size(total_size)
}, },
"file_types": dict(sorted(file_types.items(), key=lambda x: x[1], reverse=True)), "file_types": dict(sorted(file_types.items(), key=lambda x: x[1], reverse=True)),
"size_distribution": size_distribution, "size_distribution": size_distribution,
@@ -1238,7 +1238,7 @@ class APIv2(APIv1):
"largest_file": { "largest_file": {
"name": largest_file[0] if largest_file else None, "name": largest_file[0] if largest_file else None,
"size": largest_file[1] if largest_file else None, "size": largest_file[1] if largest_file else None,
"size_formatted": self.format_file_size(largest_file[1]) if largest_file else None "size_formatted": format_file_size(largest_file[1]) if largest_file else None
} }
}, },
"updated": datetime.now().isoformat() "updated": datetime.now().isoformat()
@@ -1263,7 +1263,7 @@ class APIv2(APIv1):
"path": filepath, "path": filepath,
"name": os.path.basename(filepath), "name": os.path.basename(filepath),
"size": info.st_size, "size": info.st_size,
"size_formatted": self.format_file_size(info.st_size), "size_formatted": format_file_size(info.st_size),
"modified": datetime.fromtimestamp(info.st_mtime).isoformat(), "modified": datetime.fromtimestamp(info.st_mtime).isoformat(),
"downloads": count "downloads": count
}) })
@@ -2249,8 +2249,10 @@ class APIv2(APIv1):
except Exception as e: except Exception as e:
handler.send_json_response({"error": str(e)}, 500) handler.send_json_response({"error": str(e)}, 500)
def api_start_sync(self, handler, source_name): def api_start_sync(self, handler, source_name=None):
"""启动同步""" """启动同步(兼容 v1 调用:无 source_name 时回退到 v1 行为)"""
if source_name is None:
return super().api_start_sync(handler)
if hasattr(handler, 'sync_manager') and handler.sync_manager: if hasattr(handler, 'sync_manager') and handler.sync_manager:
task_id = handler.sync_manager.start_sync(source_name) task_id = handler.sync_manager.start_sync(source_name)
if task_id: if task_id:
@@ -2268,8 +2270,10 @@ class APIv2(APIv1):
else: else:
handler.send_json_response({"error": "Sync manager not available"}, 500) handler.send_json_response({"error": "Sync manager not available"}, 500)
def api_stop_sync(self, handler, source_name): def api_stop_sync(self, handler, source_name=None):
"""停止同步""" """停止同步(兼容 v1 调用:无 source_name 时回退到 v1 行为)"""
if source_name is None:
return super().api_stop_sync(handler)
if hasattr(handler, 'sync_manager') and handler.sync_manager: if hasattr(handler, 'sync_manager') and handler.sync_manager:
handler.sync_manager.stop_all_tasks_for_source(source_name) handler.sync_manager.stop_all_tasks_for_source(source_name)
handler.send_json_response({ handler.send_json_response({
@@ -2279,8 +2283,10 @@ class APIv2(APIv1):
else: else:
handler.send_json_response({"error": "Sync manager not available"}, 500) handler.send_json_response({"error": "Sync manager not available"}, 500)
def api_get_sync_status(self, handler, source_name): def api_get_sync_status(self, handler, source_name=None):
"""获取同步状态(真实数据)""" """获取同步状态(真实数据;兼容 v1 调用)"""
if source_name is None:
return super().api_get_sync_status(handler)
# source_name 格式: source_name/status,需要提取 # source_name 格式: source_name/status,需要提取
if source_name.endswith('/status'): if source_name.endswith('/status'):
source_name = source_name[:-7] source_name = source_name[:-7]
+4 -3
View File
@@ -198,9 +198,10 @@ class ConfigManager:
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 失效
import secrets if not config.get('auth_token'):
config['auth_token'] = secrets.token_hex(32) import secrets
config['auth_token'] = secrets.token_hex(32)
# 验证上传大小配置 # 验证上传大小配置
if 'max_upload_size' in config: if 'max_upload_size' in config:
+28 -20
View File
@@ -1220,8 +1220,8 @@ class DatabaseManager:
return True return True
return False return False
def verify_user(self, username: str, password_hash: str) -> dict: def verify_user(self, username: str, password: str) -> dict:
"""验证用户登录""" """验证用户登录(password 为明文密码,用 bcrypt 校验)"""
with self.session() as session: with self.session() as session:
user = session.query(UserRecord).filter_by(username=username).first() user = session.query(UserRecord).filter_by(username=username).first()
@@ -1236,8 +1236,8 @@ class DatabaseManager:
if not user.enabled: if not user.enabled:
return {'valid': False, 'reason': '账号已被禁用'} return {'valid': False, 'reason': '账号已被禁用'}
# 验证密码 # 验证密码(bcrypt.checkpw,恒定时间比较)
if user.password_hash == password_hash: if self.verify_password(password, user.password_hash):
# 登录成功 # 登录成功
user.last_login = time.time() user.last_login = time.time()
user.login_count = (user.login_count or 0) + 1 user.login_count = (user.login_count or 0) + 1
@@ -1291,16 +1291,22 @@ class DatabaseManager:
print(f"[数据库] 已创建默认用户: {username}") print(f"[数据库] 已创建默认用户: {username}")
def hash_password(self, password: str) -> str: def hash_password(self, password: str) -> str:
"""使用 bcrypt 加密密码""" """使用 bcrypt 加密密码(回退: 自描述格式 PBKDF2-HMAC-SHA256)"""
try: try:
import bcrypt import bcrypt
salt = bcrypt.gensalt(rounds=12) salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
except ImportError: except ImportError:
# 如果 bcrypt 不可用,回退到 SHA256 # 如果 bcrypt 不可用,回退到 PBKDF2-HMAC-SHA256
# 格式: $pbkdf2-sha256$<iterations>$<salt_b64>$<hash_b64>
import hashlib import hashlib
salt = hashlib.sha256(str(time.time()).encode()).hexdigest()[:16] import os as _os
return hashlib.sha256((password + salt).encode()).hexdigest() import base64
iterations = 100_000
salt = _os.urandom(16)
dk = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, iterations)
return (f"$pbkdf2-sha256${iterations}${base64.b64encode(salt).decode()}"
f"${base64.b64encode(dk).decode()}")
def verify_password(self, password: str, password_hash: str) -> bool: def verify_password(self, password: str, password_hash: str) -> bool:
"""验证密码是否正确""" """验证密码是否正确"""
@@ -1308,19 +1314,21 @@ class DatabaseManager:
import bcrypt import bcrypt
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8')) return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
except ImportError: except ImportError:
# 如果 bcrypt 不可用,回退到 SHA256 验证 # 回退: 只接受自描述 PBKDF2 格式,绝不接受明文
import hashlib import hashlib
# 尝试直接比较(可能是旧格式) import base64
if password_hash == password: import hmac
return True if not password_hash.startswith('$pbkdf2-sha256$'):
# 尝试带 salt 验证 return False
for salt_len in range(1, 17): try:
salt = password_hash[:salt_len] if len(password_hash) > salt_len else "" _, algo, iters_s, salt_b64, hash_b64 = password_hash.split('$')
if len(salt) >= 4: iterations = int(iters_s)
test_hash = hashlib.sha256((password + salt).encode()).hexdigest() salt = base64.b64decode(salt_b64)
if test_hash == password_hash: expected = base64.b64decode(hash_b64)
return True dk = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, iterations)
return False return hmac.compare_digest(dk, expected)
except Exception:
return False
def get_user_by_token(self, token: str) -> dict: def get_user_by_token(self, token: str) -> dict:
"""通过 token 获取用户信息""" """通过 token 获取用户信息"""
+9
View File
@@ -1279,6 +1279,15 @@ class MirrorSyncManager:
with self.sync_lock: with self.sync_lock:
return self.sync_status.copy() return self.sync_status.copy()
def get_source_status(self, name):
"""获取指定同步源状态"""
with self.sync_lock:
return dict(self.sync_status.get(name, {}))
def stop_all_tasks_for_source(self, name):
"""停止指定同步源的所有任务(v2 API 兼容)"""
return self.stop_sync(name)
def start(self): def start(self):
"""启动同步管理器""" """启动同步管理器"""
self.running = True self.running = True
+8 -2
View File
@@ -98,7 +98,10 @@ class ScheduledTask:
# 处理列表: "1,2,3" # 处理列表: "1,2,3"
if ',' in part: if ',' in part:
return self._parse_cron_part(part.replace(',', ' '), min_val, max_val) result = []
for sub in part.split(','):
result.extend(self._parse_cron_part(sub.strip(), min_val, max_val))
return result
# 处理范围: "1-5" # 处理范围: "1-5"
if '-' in part: if '-' in part:
@@ -144,12 +147,15 @@ class ScheduledTask:
if not self.cron_parts: if not self.cron_parts:
return False return False
# cron 约定: 0=周日...6=周六; datetime.weekday(): 0=周一...6=周日
cron_weekday = (dt.weekday() + 1) % 7
return ( return (
dt.minute in self.cron_parts['minute'] and dt.minute in self.cron_parts['minute'] and
dt.hour in self.cron_parts['hour'] and dt.hour in self.cron_parts['hour'] and
dt.day in self.cron_parts['day'] and dt.day in self.cron_parts['day'] and
dt.month in self.cron_parts['month'] and dt.month in self.cron_parts['month'] and
dt.weekday() in self.cron_parts['weekday'] cron_weekday in self.cron_parts['weekday']
) )
def get_next_run_time(self) -> Optional[datetime]: def get_next_run_time(self) -> Optional[datetime]:
+2
View File
@@ -92,6 +92,8 @@ class MirrorServer:
if self.config.get('enable_sync', True): if self.config.get('enable_sync', True):
self.sync_manager = MirrorSyncManager(self.config) self.sync_manager = MirrorSyncManager(self.config)
self.sync_manager.start() self.sync_manager.start()
# 注入到配置,供 SyncScheduler 定时回调使用
self.config['_sync_manager'] = self.sync_manager
# 创建系统监控器(仅当启用时) # 创建系统监控器(仅当启用时)
self.monitor = None self.monitor = None
+20 -2
View File
@@ -52,6 +52,9 @@ class SyncScheduler:
self.on_file_updated: Optional[Callable] = None self.on_file_updated: Optional[Callable] = None
self.on_sync_complete: Optional[Callable] = None self.on_sync_complete: Optional[Callable] = None
# 定时同步执行器(注入 MirrorSyncManager.start_sync 或等效 callable)
self.sync_starter: Optional[Callable] = None
# 待同步队列 # 待同步队列
self._pending_add = [] # 待添加的文件 self._pending_add = [] # 待添加的文件
self._pending_update = [] # 待更新的文件 self._pending_update = [] # 待更新的文件
@@ -97,10 +100,25 @@ class SyncScheduler:
"""创建同步回调函数""" """创建同步回调函数"""
def sync_callback(task_name: str, config: dict): def sync_callback(task_name: str, config: dict):
logger.info(f"定时同步任务触发: {source_name}") logger.info(f"定时同步任务触发: {source_name}")
self.start_sync(source_name) # 委托给注入的同步执行器(MirrorSyncManager.start_sync)
return True starter = self.sync_starter or self._default_sync_starter()
if starter:
try:
return bool(starter(source_name))
except Exception as e:
logger.error(f"触发同步 {source_name} 失败: {e}")
return False
logger.error(f"无法触发同步 {source_name}: 未配置同步执行器(sync_starter)")
return False
return sync_callback return sync_callback
def _default_sync_starter(self):
"""尝试从配置获取 MirrorSyncManager 实例(server.py 启动时注入)"""
mgr = self.config.get('_sync_manager')
if mgr and hasattr(mgr, 'start_sync'):
return mgr.start_sync
return None
def start(self): def start(self):
"""启动同步调度器""" """启动同步调度器"""
if self._running: if self._running:
+4
View File
@@ -33,6 +33,10 @@ class APTMirror:
self.default_components = config.get('components', ['main', 'restricted', 'universe', 'multiverse']) self.default_components = config.get('components', ['main', 'restricted', 'universe', 'multiverse'])
self.default_arch = config.get('arch', 'amd64') self.default_arch = config.get('arch', 'amd64')
# 缓存配置(缺失时默认启用,避免 AttributeError)
self.cache_enabled = config.get('cache_enabled', True)
self.cache_ttl = config.get('cache_ttl', 3600)
# 确保存储目录存在 # 确保存储目录存在
os.makedirs(self.storage_dir, exist_ok=True) os.makedirs(self.storage_dir, exist_ok=True)
+7 -2
View File
@@ -24,8 +24,9 @@ class DockerMirror:
def __init__(self, config: dict): def __init__(self, config: dict):
self.config = config self.config = config
# 配置 - 使用 storage_dir(基于 base_dir) # 配置 - 使用 storage_dir(基于 base_dir);兼容 v2 分支传入的 upstream_url 键
self.registry_url = config.get('registry_url', 'https://registry-1.docker.io') self.registry_url = (config.get('registry_url') or config.get('upstream_url')
or 'https://registry-1.docker.io')
self.mirror_url = config.get('mirror_url', '') self.mirror_url = config.get('mirror_url', '')
self.storage_dir = config.get('storage_dir', './downloads/docker') self.storage_dir = config.get('storage_dir', './downloads/docker')
self.base_dir = config.get('base_dir', './downloads') self.base_dir = config.get('base_dir', './downloads')
@@ -34,6 +35,10 @@ class DockerMirror:
self.username = config.get('username') self.username = config.get('username')
self.password = config.get('password') self.password = config.get('password')
# 缓存配置(缺失时默认启用,避免 AttributeError)
self.cache_enabled = config.get('cache_enabled', True)
self.cache_ttl = config.get('cache_ttl', 3600)
# 确保存储目录存在 # 确保存储目录存在
os.makedirs(self.storage_dir, exist_ok=True) os.makedirs(self.storage_dir, exist_ok=True)
+4
View File
@@ -28,6 +28,10 @@ class GoProxy:
self.base_dir = config.get('base_dir', './downloads') self.base_dir = config.get('base_dir', './downloads')
self.mode = config.get('mode', 'proxy') # proxy | direct self.mode = config.get('mode', 'proxy') # proxy | direct
# 缓存配置(缺失时默认启用,避免 AttributeError)
self.cache_enabled = config.get('cache_enabled', True)
self.cache_ttl = config.get('cache_ttl', 3600)
# 确保存储目录存在 # 确保存储目录存在
os.makedirs(self.storage_dir, exist_ok=True) os.makedirs(self.storage_dir, exist_ok=True)
+4
View File
@@ -26,6 +26,10 @@ class NpmMirror:
self.storage_dir = config.get('storage_dir', './downloads/npm') self.storage_dir = config.get('storage_dir', './downloads/npm')
self.base_dir = config.get('base_dir', './downloads') self.base_dir = config.get('base_dir', './downloads')
# 缓存配置(缺失时默认启用,避免 AttributeError)
self.cache_enabled = config.get('cache_enabled', True)
self.cache_ttl = config.get('cache_ttl', 3600)
# 确保存储目录存在 # 确保存储目录存在
os.makedirs(self.storage_dir, exist_ok=True) os.makedirs(self.storage_dir, exist_ok=True)
+4
View File
@@ -29,6 +29,10 @@ class YUMMirror:
self.repo_id = config.get('repo_id', 'baseos') self.repo_id = config.get('repo_id', 'baseos')
self.arch = config.get('arch', 'x86_64') self.arch = config.get('arch', 'x86_64')
# 缓存配置(缺失时默认启用,避免 AttributeError)
self.cache_enabled = config.get('cache_enabled', True)
self.cache_ttl = config.get('cache_ttl', 3600)
# 确保存储目录存在 # 确保存储目录存在
os.makedirs(self.storage_dir, exist_ok=True) os.makedirs(self.storage_dir, exist_ok=True)
+1 -1
View File
@@ -2,7 +2,7 @@ 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
aiohttp>=3.9.0 bcrypt>=4.0.0
cachetools>=5.3.0 cachetools>=5.3.0
requests>=2.31.0 requests>=2.31.0
croniter>=2.0.0 croniter>=2.0.0