- 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
285 lines
8.9 KiB
Python
285 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
"""
|
||
npm镜像代理处理器
|
||
支持Node.js包管理器
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import time
|
||
import urllib.request
|
||
import urllib.parse
|
||
from typing import Dict, List, Optional
|
||
from datetime import datetime
|
||
|
||
|
||
class NpmMirror:
|
||
"""npm镜像代理"""
|
||
|
||
def __init__(self, config: dict):
|
||
self.config = config
|
||
|
||
# 配置 - 使用 storage_dir(基于 base_dir)
|
||
self.upstream_url = config.get('upstream_url', 'https://registry.npmjs.org')
|
||
self.storage_dir = config.get('storage_dir', './downloads/npm')
|
||
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)
|
||
|
||
def handle_request(self, handler, path: str) -> bool:
|
||
"""
|
||
处理npm请求
|
||
路径格式: /lodash 或 /-/package/lodash/dist
|
||
"""
|
||
try:
|
||
parts = path.strip('/').split('/')
|
||
|
||
if not parts:
|
||
return self._handle_index(handler)
|
||
|
||
if parts[0] == '-':
|
||
# Scoped package 或其他特殊请求
|
||
if len(parts) >= 4 and parts[1] == 'package':
|
||
return self._handleScopedPackage(handler, parts[2], parts[3] if len(parts) > 3 else None)
|
||
elif len(parts) >= 3 and parts[1] == 'package':
|
||
return self._handle_package(handler, parts[2], None)
|
||
else:
|
||
handler.send_error(400, "Invalid npm API path")
|
||
return False
|
||
|
||
elif parts[0] == '@':
|
||
# Scoped package
|
||
if len(parts) >= 2:
|
||
scope = parts[0]
|
||
package = '/'.join(parts[1:])
|
||
return self._handle_scoped_package(handler, scope, package)
|
||
else:
|
||
handler.send_error(400, "Invalid scoped package")
|
||
return False
|
||
|
||
elif parts[0] == '-/':
|
||
# npm特殊路径
|
||
return self._handle_special(handler, '/'.join(parts))
|
||
|
||
elif len(parts) == 1:
|
||
# 单个包名
|
||
return self._handle_package(handler, parts[0], None)
|
||
|
||
else:
|
||
# 其他请求
|
||
return self._handle_package(handler, parts[0], parts[1] if len(parts) > 1 else None)
|
||
|
||
except Exception as e:
|
||
handler.send_error(500, str(e))
|
||
return False
|
||
|
||
def _handle_index(self, handler) -> bool:
|
||
"""处理索引请求"""
|
||
handler.send_json_response({
|
||
'registry_url': self.upstream_url,
|
||
'cache_stats': self.get_cache_stats()
|
||
})
|
||
return True
|
||
|
||
def _handle_package(self, handler, package: str, version: str = None) -> bool:
|
||
"""处理包元数据请求"""
|
||
cache_key = f"package:{package}:{version or 'latest'}"
|
||
|
||
cached = self._get_cache(cache_key)
|
||
if cached:
|
||
handler.send_response(200)
|
||
handler.send_header('Content-Type', 'application/json')
|
||
handler.end_headers()
|
||
handler.wfile.write(cached)
|
||
return True
|
||
|
||
# 从上游获取
|
||
if version:
|
||
url = f"{self.upstream_url}/{package}/{version}"
|
||
else:
|
||
url = f"{self.upstream_url}/{package}/latest"
|
||
|
||
try:
|
||
data = self._fetch(url)
|
||
|
||
if self.cache_enabled:
|
||
self._set_cache(cache_key, data)
|
||
|
||
handler.send_response(200)
|
||
handler.send_header('Content-Type', 'application/json')
|
||
handler.end_headers()
|
||
handler.wfile.write(data)
|
||
return True
|
||
|
||
except urllib.error.HTTPError as e:
|
||
handler.send_error(404, f"Package not found: {package}")
|
||
return False
|
||
|
||
def _handle_scoped_package(self, handler, scope: str, package: str) -> bool:
|
||
"""处理scoped包"""
|
||
full_name = f"{scope}/{package}"
|
||
return self._handle_package(handler, full_name, None)
|
||
|
||
def _handleScopedPackage(self, handler, scope: str, package: str) -> bool:
|
||
"""处理特殊路径的scoped包"""
|
||
full_name = f"{scope}/{package}"
|
||
return self._handle_package(handler, full_name, None)
|
||
|
||
def _handle_special(self, handler, path: str) -> bool:
|
||
"""处理特殊npm路径"""
|
||
# 简化实现:转发到上游
|
||
url = f"{self.upstream_url}/{path}"
|
||
|
||
try:
|
||
data = self._fetch(url)
|
||
|
||
handler.send_response(200)
|
||
handler.send_header('Content-Type', 'application/json')
|
||
handler.end_headers()
|
||
handler.wfile.write(data)
|
||
return True
|
||
|
||
except Exception as e:
|
||
handler.send_error(502, f"Failed to fetch: {str(e)}")
|
||
return False
|
||
|
||
def _handle_tarball(self, handler, package: str, filename: str) -> bool:
|
||
"""处理tarball下载"""
|
||
cache_key = f"tarball:{package}:{filename}"
|
||
|
||
cached = self._get_cache(cache_key)
|
||
if cached:
|
||
handler.send_response(200)
|
||
handler.send_header('Content-Type', 'application/octet-stream')
|
||
handler.send_header('Content-Length', str(len(cached)))
|
||
handler.end_headers()
|
||
handler.wfile.write(cached)
|
||
return True
|
||
|
||
# 从上游获取
|
||
url = f"{self.upstream_url}/{package}/-/{filename}"
|
||
|
||
try:
|
||
data = self._fetch(url)
|
||
|
||
if self.cache_enabled:
|
||
self._set_cache(cache_key, data)
|
||
|
||
handler.send_response(200)
|
||
handler.send_header('Content-Type', 'application/octet-stream')
|
||
handler.send_header('Content-Length', str(len(data)))
|
||
handler.end_headers()
|
||
handler.wfile.write(data)
|
||
return True
|
||
|
||
except Exception as e:
|
||
handler.send_error(502, f"Failed to fetch tarball: {str(e)}")
|
||
return False
|
||
|
||
def _fetch(self, url: str) -> Optional[bytes]:
|
||
"""从URL获取数据"""
|
||
try:
|
||
req = urllib.request.Request(url)
|
||
req.add_header('User-Agent', 'npm-Mirror/1.0')
|
||
req.add_header('Accept', 'application/json')
|
||
|
||
with urllib.request.urlopen(req, timeout=30) as response:
|
||
return response.read()
|
||
|
||
except Exception:
|
||
return None
|
||
|
||
def _get_cache(self, cache_key: str) -> Optional[bytes]:
|
||
"""获取缓存"""
|
||
if not self.cache_enabled:
|
||
return None
|
||
|
||
cache_path = self._get_cache_path(cache_key)
|
||
meta_path = cache_path + '.meta'
|
||
|
||
if not os.path.exists(cache_path):
|
||
return None
|
||
|
||
if os.path.exists(meta_path):
|
||
try:
|
||
with open(meta_path, 'r') as f:
|
||
meta = json.load(f)
|
||
if time.time() > meta.get('expires', 0):
|
||
return None
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
with open(cache_path, 'rb') as f:
|
||
return f.read()
|
||
except Exception:
|
||
return None
|
||
|
||
def _set_cache(self, cache_key: str, data: bytes):
|
||
"""设置缓存"""
|
||
cache_path = self._get_cache_path(cache_key)
|
||
meta_path = cache_path + '.meta'
|
||
|
||
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
|
||
|
||
try:
|
||
with open(cache_path, 'wb') as f:
|
||
f.write(data)
|
||
|
||
meta = {
|
||
'cached_at': time.time(),
|
||
'expires': time.time() + self.cache_ttl,
|
||
'size': len(data)
|
||
}
|
||
|
||
with open(meta_path, 'w') as f:
|
||
json.dump(meta, f)
|
||
|
||
except Exception as e:
|
||
print(f"npm缓存写入失败: {e}")
|
||
|
||
def _get_cache_path(self, cache_key: str) -> str:
|
||
"""获取缓存路径"""
|
||
subdir = cache_key[:2]
|
||
return os.path.join(self.storage_dir, subdir, cache_key)
|
||
|
||
def get_cache_stats(self) -> dict:
|
||
"""获取缓存统计"""
|
||
if not os.path.exists(self.storage_dir):
|
||
return {'files': 0, 'size': 0}
|
||
|
||
total_size = 0
|
||
file_count = 0
|
||
|
||
for root, dirs, files in os.walk(self.storage_dir):
|
||
for f in files:
|
||
if not f.endswith('.meta'):
|
||
file_count += 1
|
||
total_size += os.path.getsize(os.path.join(root, f))
|
||
|
||
return {
|
||
'files': file_count,
|
||
'size': total_size,
|
||
'size_formatted': self._format_size(total_size)
|
||
}
|
||
|
||
def _format_size(self, size_bytes: int) -> str:
|
||
"""格式化文件大小"""
|
||
if size_bytes == 0:
|
||
return "0 B"
|
||
|
||
units = ["B", "KB", "MB", "GB"]
|
||
i = 0
|
||
while size_bytes >= 1024 and i < len(units) - 1:
|
||
size_bytes /= 1024.0
|
||
i += 1
|
||
|
||
return f"{size_bytes:.2f} {units[i]}"
|